@amityco/social-plus-vise 0.8.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/README.md +107 -40
- package/dist/capabilities.js +447 -0
- package/dist/outcomes.js +463 -5
- package/dist/server.js +115 -3
- package/dist/tools/ast.js +25 -0
- package/dist/tools/compliance.js +88 -20
- package/dist/tools/debug.js +267 -0
- package/dist/tools/design.js +1496 -0
- package/dist/tools/docs.js +9 -4
- package/dist/tools/harness.js +17 -1
- package/dist/tools/integration.js +83 -7
- package/dist/tools/project.js +872 -67
- package/dist/tools/sdkVersion.js +129 -0
- package/dist/types.js +4 -0
- package/package.json +27 -6
- package/rules/auth.yaml +298 -38
- package/rules/comments.yaml +0 -72
- package/rules/feed.yaml +1151 -12
- package/rules/live-data.yaml +316 -36
- package/rules/push.yaml +140 -0
- package/rules/sdk-lifecycle.yaml +1428 -138
- package/rules/security.yaml +60 -0
- package/skills/social-plus-vise/SKILL.md +98 -55
- package/skills/social-plus-vise/reference/debugging.md +39 -0
- package/skills/social-plus-vise/reference/operations.md +59 -0
- package/skills/vise-harness-engineer/SKILL.md +35 -0
- package/social.plus-vise.png +0 -0
package/dist/tools/project.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
|
|
4
|
-
import { findCallExpressions, parse, pickObjectProperty, resolveLiteralValue, stripComments } from "./ast.js";
|
|
4
|
+
import { findCallExpressions, parse, tryParse, pickObjectProperty, resolveLiteralValue, stripComments } from "./ast.js";
|
|
5
5
|
async function exists(filePath) {
|
|
6
6
|
try {
|
|
7
7
|
await access(filePath);
|
|
@@ -74,11 +74,16 @@ async function inspectRoot(root) {
|
|
|
74
74
|
}
|
|
75
75
|
// When react-native is detected alongside generic typescript signals, prefer react-native
|
|
76
76
|
// so that platform-specific rules (react-native.*) are used for init/check/run-sensors.
|
|
77
|
+
// Same for android: an agent may create package.json (e.g. to enable npm run sp-check) which
|
|
78
|
+
// would normally trigger typescript detection — suppress it so only android rules apply.
|
|
77
79
|
const rawPlatforms = Array.from(new Set(signals.map((signal) => signal.platform)));
|
|
78
80
|
const hasRN = rawPlatforms.includes("react-native");
|
|
81
|
+
const hasAndroid = rawPlatforms.includes("android");
|
|
79
82
|
const platforms = hasRN
|
|
80
83
|
? ["react-native", ...rawPlatforms.filter((p) => p !== "react-native" && p !== "typescript")]
|
|
81
|
-
:
|
|
84
|
+
: hasAndroid
|
|
85
|
+
? rawPlatforms.filter((p) => p !== "typescript")
|
|
86
|
+
: rawPlatforms;
|
|
82
87
|
return { platforms, signals, designSignals: await detectDesignSignals(root) };
|
|
83
88
|
}
|
|
84
89
|
export const inspectProjectTool = {
|
|
@@ -166,7 +171,14 @@ async function validateAndroid(root) {
|
|
|
166
171
|
const findings = [];
|
|
167
172
|
const manifestPath = "app/src/main/AndroidManifest.xml";
|
|
168
173
|
const manifest = await readIfExists(path.join(root, manifestPath));
|
|
169
|
-
|
|
174
|
+
// Scan ALL Gradle build scripts, not just app/root — a multi-module app idiomatically declares the
|
|
175
|
+
// SDK dependency in the consuming FEATURE module's build.gradle(.kts) (e.g. feature/community/impl).
|
|
176
|
+
// Only checking app/build.gradle false-fired android.dependency.sdk on those (brownfield Now-in-Android).
|
|
177
|
+
const gradleScripts = (await findFiles(root, [".gradle", ".kts"], 200)).filter((f) => /(?:^|[\\/])(?:build|settings)\.gradle(?:\.kts)?$/.test(f));
|
|
178
|
+
const buildFiles = [...new Set([
|
|
179
|
+
...(await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])),
|
|
180
|
+
...gradleScripts,
|
|
181
|
+
])];
|
|
170
182
|
const buildContent = await readMany(buildFiles);
|
|
171
183
|
const sourceFiles = await findFiles(root, [".kt", ".java"], 300);
|
|
172
184
|
const sourceContent = await readMany(sourceFiles);
|
|
@@ -196,7 +208,17 @@ async function validateAndroid(root) {
|
|
|
196
208
|
findings.push(finding("android.setup.present", "warning", "No obvious AmityCoreClient.setup call was found in Kotlin/Java files.", undefined, "Call SDK setup once during application startup before any social.plus API usage."));
|
|
197
209
|
}
|
|
198
210
|
else {
|
|
199
|
-
const activitySetup = setupFiles.find((file) =>
|
|
211
|
+
const activitySetup = setupFiles.find((file) => {
|
|
212
|
+
const c = sourceContent.get(file) ?? "";
|
|
213
|
+
// Application-scoped setup (a Hilt @Module / @HiltAndroidApp Application / SingletonComponent
|
|
214
|
+
// provider) is the CORRECT place — never flag it. The old marker matched the bare word
|
|
215
|
+
// "Activity" anywhere (incl. a comment like "outlives any Activity/ViewModel" in a DI module),
|
|
216
|
+
// false-firing on idiomatic Hilt setup (brownfield Now-in-Android).
|
|
217
|
+
if (/@Module\b|@HiltAndroidApp\b|SingletonComponent\b|@ApplicationContext\b|:\s*Application\b|\bApplication\s*\(\s*\)/.test(c))
|
|
218
|
+
return false;
|
|
219
|
+
// Otherwise flag setup in an actual UI lifecycle: an Activity/Fragment class, a @Composable, or onCreateView.
|
|
220
|
+
return /\bclass\s+\w*(?:Activity|Fragment)\b|:\s*(?:ComponentActivity|AppCompatActivity|Activity|Fragment)\b|@Composable\b|\bonCreateView\b/.test(c);
|
|
221
|
+
});
|
|
200
222
|
if (activitySetup) {
|
|
201
223
|
findings.push(finding("android.setup.lifecycle", "warning", "SDK setup appears near Activity/Fragment/UI lifecycle code.", relativeFile(root, activitySetup), "Prefer Application.onCreate so the SDK is initialized once and not reinitialized on screen recreation."));
|
|
202
224
|
}
|
|
@@ -208,14 +230,16 @@ async function validateAndroid(root) {
|
|
|
208
230
|
findings.push(finding("android.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity, and before subscribing to social.plus collections."));
|
|
209
231
|
}
|
|
210
232
|
else if (!containsAny(sourceContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
|
|
211
|
-
findings.push(finding("android.session.renewal", "warning", "Login was found but no
|
|
233
|
+
findings.push(finding("android.session.renewal", "warning", "Login was found but no SessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass a SessionHandler to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
|
|
212
234
|
}
|
|
213
235
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
214
236
|
findings.push(finding("android.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
|
|
215
237
|
}
|
|
216
238
|
// Android push payload: if onMessageReceived exists, require push-specific SDK forwarding
|
|
239
|
+
// Nexus gate (see iOS): a generic FCM handler with no social.plus push registration is the host
|
|
240
|
+
// app's own push, not a social.plus forwarding gap — require social.plus push registration first.
|
|
217
241
|
const androidPayloadFiles = filesMatching(sourceContent, [/onMessageReceived/, /FirebaseMessagingService/]);
|
|
218
|
-
if (androidPayloadFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
242
|
+
if (androidPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
219
243
|
findings.push(finding("android.push.payload-contract-respected", "warning", "Push payload handler (onMessageReceived) exists but no social.plus push forwarding call was detected.", relativeFile(root, androidPayloadFiles[0]), "Forward incoming FCM payloads to the social.plus SDK push handler (e.g. AmityPush.handleNotification) so social notifications are routed correctly."));
|
|
220
244
|
}
|
|
221
245
|
if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
|
|
@@ -232,6 +256,13 @@ async function validateAndroid(root) {
|
|
|
232
256
|
findings.push(...validateComments(root, "android", sourceContent));
|
|
233
257
|
findings.push(...validateModeration(root, "android", sourceContent));
|
|
234
258
|
findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
|
|
259
|
+
findings.push(...validatePostDataTypeHandled(root, "android", sourceContent));
|
|
260
|
+
findings.push(...validateAvatarFromSdk(root, "android", sourceContent));
|
|
261
|
+
findings.push(...validatePollAnswerDataShape(root, "android", sourceContent));
|
|
262
|
+
findings.push(...validateCommentsQueryHasLimit(root, "android", sourceContent));
|
|
263
|
+
findings.push(...validateCommentCreationAffordance(root, "android", sourceContent));
|
|
264
|
+
findings.push(...validateCommunityDisplayNameFromSdk(root, "android", sourceContent));
|
|
265
|
+
findings.push(...validateRoomPostFetched(root, "android", sourceContent));
|
|
235
266
|
findings.push(...validatePostsStatusFilter(root, "android", sourceContent));
|
|
236
267
|
findings.push(...validatePaginationCursorOpaque(root, "android", sourceContent));
|
|
237
268
|
findings.push(...validatePostsParentChild(root, "android", sourceContent));
|
|
@@ -250,6 +281,7 @@ async function validateAndroid(root) {
|
|
|
250
281
|
findings.push(...validateSessionHandlerRetention(root, "android", sourceContent));
|
|
251
282
|
findings.push(...validateChat(root, "android", sourceContent));
|
|
252
283
|
findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
|
|
284
|
+
findings.push(...(await validateEnvSecretHygiene(root, "android", sourceContent)));
|
|
253
285
|
return findings;
|
|
254
286
|
}
|
|
255
287
|
async function validateFlutter(root) {
|
|
@@ -278,7 +310,14 @@ async function validateFlutter(root) {
|
|
|
278
310
|
if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
|
|
279
311
|
findings.push(finding("flutter.binding.initialized", "warning", "SDK setup was found but WidgetsFlutterBinding.ensureInitialized was not detected.", relativeFile(root, setupFiles[0]), "Call WidgetsFlutterBinding.ensureInitialized before async SDK setup in main()."));
|
|
280
312
|
}
|
|
281
|
-
|
|
313
|
+
// Region is configured via AmityRegion.* OR an explicit endpoint (the idiomatic Flutter
|
|
314
|
+
// forms — `httpEndpoint:` is not matched by `\bendpoint:` because of the camelCase boundary
|
|
315
|
+
// + case). The real endpoint symbols are httpEndpoint/mqttEndpoint/uploadEndpoint and the
|
|
316
|
+
// AmityRegional{Http,Mqtt}Endpoint enums (verified against the SDK surface — there is no
|
|
317
|
+
// `socketEndpoint`). NOTE: do NOT key on AmityCoreClientOption — that builder is used by
|
|
318
|
+
// EVERY setup regardless of whether a region is set, so it would silence this rule for any
|
|
319
|
+
// standard setup (a false negative).
|
|
320
|
+
if (!containsAnyForFiles(dartContent, setupFiles, [/AmityEndpoint\./, /\bendpoint\s*:/, /\bregion\s*:/, /\bhttpEndpoint\s*:/i, /\bmqttEndpoint\s*:/i, /\buploadEndpoint\s*:/i, /\bAmityRegional(?:Http|Mqtt)Endpoint\b/, /\bAmityRegion\b/])) {
|
|
282
321
|
findings.push(finding("flutter.setup.region", "warning", "SDK setup was found but no explicit endpoint/region marker was detected.", relativeFile(root, setupFiles[0]), "Set the endpoint/region explicitly to match the customer's social.plus console project."));
|
|
283
322
|
}
|
|
284
323
|
}
|
|
@@ -286,14 +325,16 @@ async function validateFlutter(root) {
|
|
|
286
325
|
findings.push(finding("flutter.login.present", "warning", "No obvious AmityCoreClient.login call was found.", undefined, "Call login after the app has a known user identity and before social.plus queries/subscriptions."));
|
|
287
326
|
}
|
|
288
327
|
else if (!containsAny(dartContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /SessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
|
|
289
|
-
findings.push(finding("flutter.session.renewal", "warning", "Login was found but no
|
|
328
|
+
findings.push(finding("flutter.session.renewal", "warning", "Login was found but no session handler callback with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass a session handler callback (AccessTokenRenewal renewal) to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
|
|
290
329
|
}
|
|
291
330
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
292
331
|
findings.push(finding("flutter.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
|
|
293
332
|
}
|
|
294
333
|
// Flutter push payload: if FirebaseMessaging.onMessage/onBackgroundMessage exists, require push-specific SDK forwarding
|
|
334
|
+
// Nexus gate (see iOS): require social.plus push registration before demanding forwarding, so a
|
|
335
|
+
// host app's own FirebaseMessaging handler doesn't false-fire when push isn't a social.plus concern.
|
|
295
336
|
const flutterPayloadFiles = filesMatching(dartContent, [/FirebaseMessaging\.onMessage/, /onBackgroundMessage/, /FirebaseMessaging\.instance/]);
|
|
296
|
-
if (flutterPayloadFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
337
|
+
if (flutterPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
297
338
|
findings.push(finding("flutter.push.payload-contract-respected", "warning", "Push payload handler (FirebaseMessaging) exists but no social.plus push forwarding call was detected.", relativeFile(root, flutterPayloadFiles[0]), "Forward incoming Firebase push payloads to the social.plus SDK push handler (e.g. AmityPush.handleNotification) so social notifications are routed correctly."));
|
|
298
339
|
}
|
|
299
340
|
if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
|
|
@@ -310,6 +351,13 @@ async function validateFlutter(root) {
|
|
|
310
351
|
findings.push(...validateComments(root, "flutter", dartContent));
|
|
311
352
|
findings.push(...validateModeration(root, "flutter", dartContent));
|
|
312
353
|
findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
|
|
354
|
+
findings.push(...validatePostDataTypeHandled(root, "flutter", dartContent));
|
|
355
|
+
findings.push(...validateAvatarFromSdk(root, "flutter", dartContent));
|
|
356
|
+
findings.push(...validatePollAnswerDataShape(root, "flutter", dartContent));
|
|
357
|
+
findings.push(...validateCommentsQueryHasLimit(root, "flutter", dartContent));
|
|
358
|
+
findings.push(...validateCommentCreationAffordance(root, "flutter", dartContent));
|
|
359
|
+
findings.push(...validateCommunityDisplayNameFromSdk(root, "flutter", dartContent));
|
|
360
|
+
findings.push(...validateRoomPostFetched(root, "flutter", dartContent));
|
|
313
361
|
findings.push(...validatePostsStatusFilter(root, "flutter", dartContent));
|
|
314
362
|
findings.push(...validatePaginationCursorOpaque(root, "flutter", dartContent));
|
|
315
363
|
findings.push(...validatePostsParentChild(root, "flutter", dartContent));
|
|
@@ -328,6 +376,7 @@ async function validateFlutter(root) {
|
|
|
328
376
|
findings.push(...validateSessionHandlerRetention(root, "flutter", dartContent));
|
|
329
377
|
findings.push(...validateChat(root, "flutter", dartContent));
|
|
330
378
|
findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
|
|
379
|
+
findings.push(...(await validateEnvSecretHygiene(root, "flutter", dartContent)));
|
|
331
380
|
return findings;
|
|
332
381
|
}
|
|
333
382
|
async function validateTypeScript(root, platform) {
|
|
@@ -335,11 +384,18 @@ async function validateTypeScript(root, platform) {
|
|
|
335
384
|
const packageJson = await readIfExists(path.join(root, "package.json"));
|
|
336
385
|
const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500);
|
|
337
386
|
const sourceContent = await readMany(sourceFiles);
|
|
338
|
-
const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /createClient\s*\(/]);
|
|
387
|
+
const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/]);
|
|
339
388
|
const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
|
|
340
389
|
const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
|
|
341
|
-
|
|
342
|
-
|
|
390
|
+
// Skip .d.ts declarations when checking for unregister — type stubs declare both register and
|
|
391
|
+
// unregister methods but should not suppress the "unregister missing" finding.
|
|
392
|
+
const implContent = new Map([...sourceContent].filter(([f]) => !path.basename(f).endsWith('.d.ts')));
|
|
393
|
+
const pushUnregisterFiles = filesMatching(implContent, [/unregisterPushNotification/, /disablePushNotification/]);
|
|
394
|
+
// Only REACTIVE subscriptions need cleanup. A one-shot `await queryPosts(...)` /
|
|
395
|
+
// `await getPost(...)` returns a Promise with nothing to unsubscribe, so bare
|
|
396
|
+
// queryPosts(/getPost( are NOT live-data markers (they'd false-fire live.cleanup on
|
|
397
|
+
// one-shot awaits — found on a weak-model build). The reactive forms below remain.
|
|
398
|
+
const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /\.onData\s*\(/, /onSnapshot/]);
|
|
343
399
|
if (!packageJson) {
|
|
344
400
|
findings.push(finding("typescript.package.present", "warning", "No package.json file was found.", "package.json", "Point repoPath at the TypeScript or React Native project root."));
|
|
345
401
|
}
|
|
@@ -360,11 +416,17 @@ async function validateTypeScript(root, platform) {
|
|
|
360
416
|
// process.env.<*REGION*> | <*ENDPOINT*>, including NEXT_PUBLIC_, EXPO_PUBLIC_,
|
|
361
417
|
// VITE_ prefixes; also import.meta.env.<*REGION*> for Vite/Astro.
|
|
362
418
|
// Any one of these is sufficient evidence the integration is region-aware.
|
|
363
|
-
|
|
364
|
-
|
|
419
|
+
// `API_REGIONS` is the canonical social.plus TS SDK region enum
|
|
420
|
+
// (API_REGIONS.SG/EU/US) — its presence is definitive region evidence. The
|
|
421
|
+
// named-decl form is case-insensitive so `const REGION = API_REGIONS.SG` counts.
|
|
422
|
+
// (d) positional region argument: createClient(API_KEY, AMITY_REGION) — a 2nd arg whose name
|
|
423
|
+
// contains "region" (incl. an uppercase env const like AMITY_REGION). Idiom (b)'s regex only
|
|
424
|
+
// caught a `const region` decl named exactly "region"; the positional call site was missed (RN build).
|
|
425
|
+
const setupHasKeywordRegion = containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/, /\bAPI_REGIONS\b/, /createClient\s*\([^,)]+,\s*[\w.$]*[Rr]egion/i]);
|
|
426
|
+
const setupHasNamedRegionDecl = containsAnyForFiles(sourceContent, setupFiles, [/\b(?:const|let)\s+(?:region|apiRegion|endpoint|apiEndpoint)\b/i]);
|
|
365
427
|
const projectHasEnvRegion = containsAny(sourceContent, [/process\.env\.[A-Z0-9_]*(?:REGION|ENDPOINT)[A-Z0-9_]*/, /import\.meta\.env\.[A-Z0-9_]*(?:REGION|ENDPOINT)[A-Z0-9_]*/]);
|
|
366
428
|
if (!setupHasKeywordRegion && !setupHasNamedRegionDecl && !projectHasEnvRegion) {
|
|
367
|
-
findings.push(finding("typescript.client.region", "warning", "Client initialization was found but no explicit region or endpoint marker was detected.", relativeFile(root, setupFiles[0]), "Pass the region or endpoint that matches the customer's social.plus console project."));
|
|
429
|
+
findings.push(finding("typescript.client.region", "warning", "Client initialization was found but no explicit region or endpoint marker was detected.", relativeFile(root, setupFiles[0]), "Pass the region or endpoint that matches the customer's social.plus console project. Reuse the app's existing config or environment source of truth if one exists, and do not hardcode a guessed default region just to make setup appear green."));
|
|
368
430
|
}
|
|
369
431
|
const renderCycleSetup = setupFiles.find((file) => /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*\s*=/.test(sourceContent.get(file) ?? ""));
|
|
370
432
|
if (renderCycleSetup && platform === "react-native") {
|
|
@@ -375,12 +437,16 @@ async function validateTypeScript(root, platform) {
|
|
|
375
437
|
findings.push(finding("typescript.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity, and await it before subscribing to live collections."));
|
|
376
438
|
}
|
|
377
439
|
if (loginFiles.length > 0 && !containsAny(sourceContent, [/sessionWillRenewAccessToken/, /sessionHandler/, /renewal\.renew/])) {
|
|
378
|
-
findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) so sessions refresh in production."));
|
|
440
|
+
findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) in the existing login path so sessions refresh in production. Preserve the current session owner and retain the handler for the full session lifetime."));
|
|
379
441
|
}
|
|
380
442
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
381
|
-
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
|
|
443
|
+
findings.push(finding(`${platform}.push.unregister.present`, "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens when the user logs out or the component unmounts. In React/React Native, return the unregister call from useEffect: return () => { NotificationRepository.unregisterPushNotification(deviceToken).catch(() => {}); }. Also update any local registration-state you maintain. Missing unregistration means notifications continue arriving after logout, which is a privacy and UX bug."));
|
|
382
444
|
}
|
|
383
|
-
|
|
445
|
+
// A function whose RETURN TYPE is Amity.Unsubscriber hands the cleanup contract to
|
|
446
|
+
// its caller — the idiomatic repository/store pattern. Anchored on the return-type
|
|
447
|
+
// annotation `): Unsubscriber` (not a bare mention), so a disposer captured and
|
|
448
|
+
// dropped on the floor still fires.
|
|
449
|
+
if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /cleanup/i, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/])) {
|
|
384
450
|
findings.push(finding(`${platform}.live.cleanup`, "warning", "Live Object/Collection subscription was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Return or call the unsubscribe cleanup from the owning component, route, or store lifecycle."));
|
|
385
451
|
}
|
|
386
452
|
findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
|
|
@@ -395,6 +461,17 @@ async function validateTypeScript(root, platform) {
|
|
|
395
461
|
findings.push(...validateModeration(root, platform, sourceContent));
|
|
396
462
|
findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
|
|
397
463
|
findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
|
|
464
|
+
findings.push(...validateActivityPostsTagFilter(root, platform, sourceContent));
|
|
465
|
+
findings.push(...validatePostDataTypeHandled(root, platform, sourceContent));
|
|
466
|
+
findings.push(...validateAvatarFromSdk(root, platform, sourceContent));
|
|
467
|
+
findings.push(...validatePollAnswerDataShape(root, platform, sourceContent));
|
|
468
|
+
findings.push(...validateCommentsQueryHasLimit(root, platform, sourceContent));
|
|
469
|
+
findings.push(...validateCommentCreationAffordance(root, platform, sourceContent));
|
|
470
|
+
findings.push(...validateCommunityDisplayNameFromSdk(root, platform, sourceContent));
|
|
471
|
+
findings.push(...validateRoomPostFetched(root, platform, sourceContent));
|
|
472
|
+
findings.push(...validateReactionStalePostRef(root, platform, sourceContent));
|
|
473
|
+
findings.push(...validateFollowStatusSubscription(root, platform, sourceContent));
|
|
474
|
+
findings.push(...validateMembershipUsesLiveCollection(root, platform, sourceContent));
|
|
398
475
|
findings.push(...validatePaginationCursorOpaque(root, platform, sourceContent));
|
|
399
476
|
findings.push(...validatePostsParentChild(root, platform, sourceContent));
|
|
400
477
|
findings.push(...validateFeedTargetTypeExplicit(root, platform, sourceContent));
|
|
@@ -413,24 +490,82 @@ async function validateTypeScript(root, platform) {
|
|
|
413
490
|
findings.push(...validateChat(root, platform, sourceContent));
|
|
414
491
|
findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
|
|
415
492
|
if (platform === "typescript") {
|
|
416
|
-
findings.push(...(
|
|
493
|
+
findings.push(...validateClientNoSsrInit(root, sourceContent));
|
|
417
494
|
}
|
|
495
|
+
findings.push(...(await validateEnvSecretHygiene(root, platform, sourceContent)));
|
|
418
496
|
return findings;
|
|
419
497
|
}
|
|
420
|
-
async function
|
|
498
|
+
async function validateEnvSecretHygiene(root, platform, sourceContent) {
|
|
421
499
|
const findings = [];
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
500
|
+
if (platform === "typescript") {
|
|
501
|
+
const envSecretFiles = await committedEnvSecretFiles(root);
|
|
502
|
+
for (const file of envSecretFiles) {
|
|
503
|
+
findings.push(finding("typescript.secret.committed-env", "warning", "A local env file appears to contain a social.plus secret.", file, "Do not commit .env files with real API keys. Commit a placeholder .env.example and keep local env files ignored."));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (platform === "typescript" || platform === "react-native") {
|
|
507
|
+
if (usesEnvSecretConfig(sourceContent)) {
|
|
508
|
+
const gitignore = await readIfExists(path.join(root, ".gitignore"));
|
|
509
|
+
if (!gitignoreIgnoresEnvFiles(gitignore)) {
|
|
510
|
+
findings.push(finding(`${platform}.secret.env-gitignore`, "warning", "Source uses env-based social.plus secret config but .gitignore does not ignore local env files.", ".gitignore", "Add .env, .env.local, and environment-specific local env files to .gitignore."));
|
|
511
|
+
}
|
|
512
|
+
const hasExample = await exists(path.join(root, ".env.example")) || await exists(path.join(root, ".env.sample"));
|
|
513
|
+
if (!hasExample) {
|
|
514
|
+
findings.push(finding(`${platform}.secret.env-example`, "warning", "Source uses env-based social.plus secret config but no env example file was found.", ".env.example", "Commit .env.example with placeholder keys so agents and developers know which values to provide locally."));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (platform === "flutter") {
|
|
519
|
+
const gitignore = await readIfExists(path.join(root, ".gitignore"));
|
|
520
|
+
const envContent = await readIfExists(path.join(root, ".env"));
|
|
521
|
+
const secretsDartContent = await readIfExists(path.join(root, "lib/secrets.dart")) || await readIfExists(path.join(root, "secrets.dart"));
|
|
522
|
+
const hasSecretValue = (content) => /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(content) && !content.includes("your_api_key");
|
|
523
|
+
const hasTargetEnv = envContent && hasSecretValue(envContent);
|
|
524
|
+
const hasTargetDart = secretsDartContent && hasSecretValue(secretsDartContent);
|
|
525
|
+
if ((hasTargetEnv && !gitignore.includes(".env")) || (hasTargetDart && !gitignore.includes("secrets.dart"))) {
|
|
526
|
+
findings.push(finding("flutter.secret.env-gitignore", "warning", ".env or secrets.dart contains a social.plus key but is not in .gitignore.", ".gitignore", "Add .env or secrets.dart to .gitignore."));
|
|
527
|
+
}
|
|
425
528
|
}
|
|
426
|
-
if (
|
|
529
|
+
if (platform === "android") {
|
|
427
530
|
const gitignore = await readIfExists(path.join(root, ".gitignore"));
|
|
428
|
-
|
|
429
|
-
|
|
531
|
+
const localProps = await readIfExists(path.join(root, "local.properties"));
|
|
532
|
+
if (localProps && /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(localProps) && !gitignore.includes("local.properties")) {
|
|
533
|
+
findings.push(finding("android.secret.env-gitignore", "warning", "local.properties contains a social.plus key but is not in .gitignore.", ".gitignore", "Add local.properties to .gitignore."));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (platform === "ios") {
|
|
537
|
+
const gitignore = await readIfExists(path.join(root, ".gitignore"));
|
|
538
|
+
const ignoresSecrets = gitignore.includes("Secrets.plist") || gitignore.includes("xcconfig");
|
|
539
|
+
let hasIosSecretFile = false;
|
|
540
|
+
for (const [file, content] of sourceContent) {
|
|
541
|
+
if ((file.endsWith("Secrets.plist") || file.endsWith(".xcconfig")) && /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(content)) {
|
|
542
|
+
hasIosSecretFile = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (hasIosSecretFile && !ignoresSecrets) {
|
|
547
|
+
findings.push(finding("ios.secret.env-gitignore", "warning", "Secrets.plist or .xcconfig contains a social.plus key but is not in .gitignore.", ".gitignore", "Add Secrets.plist and/or *.xcconfig to .gitignore."));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return findings;
|
|
551
|
+
}
|
|
552
|
+
function validateClientNoSsrInit(root, sourceContent) {
|
|
553
|
+
const findings = [];
|
|
554
|
+
const clientCreatePatterns = [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/];
|
|
555
|
+
const serverContextPathPatterns = [/layout\.tsx?$/, /page\.tsx?$/];
|
|
556
|
+
const serverContextContentPatterns = [/getServerSideProps/, /getStaticProps/];
|
|
557
|
+
for (const [file, content] of sourceContent) {
|
|
558
|
+
if (!clientCreatePatterns.some((p) => p.test(content))) {
|
|
559
|
+
continue;
|
|
430
560
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
561
|
+
if (/["']use client["']/.test(content)) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const basename = path.basename(file);
|
|
565
|
+
const isServerContext = serverContextPathPatterns.some((p) => p.test(basename)) || serverContextContentPatterns.some((p) => p.test(content));
|
|
566
|
+
if (isServerContext) {
|
|
567
|
+
findings.push(finding("typescript.client.no-ssr-init", "error", "TypeScript SDK client must not be initialized in a server-side context.", relativeFile(root, file), "If this is a Next.js project, SDK initialization must happen client-side only. Wrap the client initialization call (Client.create() or createClient()) in a 'use client' component, a useEffect, or use dynamic() with { ssr: false }. Do not call it in a Server Component, layout file, or getServerSideProps."));
|
|
568
|
+
break;
|
|
434
569
|
}
|
|
435
570
|
}
|
|
436
571
|
return findings;
|
|
@@ -664,7 +799,7 @@ function validateFeedPagination(root, platform, sourceContent) {
|
|
|
664
799
|
}
|
|
665
800
|
}
|
|
666
801
|
return [
|
|
667
|
-
finding(`${platform}.feed.pagination-wired`, "warning", "A feed/posts observer is present but no pagination mechanism was detected.", relativeFile(root, file), "Wire pagination (hasMore/loadMore/nextPage, FlatList.onEndReached, LazyColumn scroll trigger, ScrollController, etc.) to avoid loading all content on mount."),
|
|
802
|
+
finding(`${platform}.feed.pagination-wired`, "warning", "A feed/posts observer is present but no pagination mechanism was detected.", relativeFile(root, file), "Wire pagination (hasMore/loadMore/nextPage, pageToken, FlatList.onEndReached, LazyColumn scroll trigger, ScrollController, etc.) to avoid loading all content on mount. When repairing a live feed, preserve any existing pagination inputs or state instead of dropping them."),
|
|
668
803
|
];
|
|
669
804
|
}
|
|
670
805
|
return [];
|
|
@@ -704,8 +839,11 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
|
|
|
704
839
|
}
|
|
705
840
|
// ─── Logout on user-switch ───────────────────────────────────────────────────
|
|
706
841
|
const USER_SWITCH_SYMBOLS = [
|
|
707
|
-
|
|
708
|
-
|
|
842
|
+
// setActiveUser is the real SDK user-switch. `setCurrentUser`/`setUser` were REMOVED — they collide
|
|
843
|
+
// with ubiquitous React useState setters (`const [u, setCurrentUser] = useState(...)`) and false-fired
|
|
844
|
+
// logout-on-user-switch on an RN feed screen that merely READ the active user into state.
|
|
845
|
+
/switchUser/i, /setActiveUser/i, /authStateChanged/i, /onAuthStateChanged/i,
|
|
846
|
+
/logoutAndLogin/i, /signInAs/i, /changeUser/i,
|
|
709
847
|
];
|
|
710
848
|
const LOGIN_PATTERNS_BY_PLATFORM = {
|
|
711
849
|
android: [/AmityCoreClient\.login/, /\.login\s*\(/],
|
|
@@ -735,7 +873,7 @@ function validateLogoutOnUserSwitch(root, platform, sourceContent) {
|
|
|
735
873
|
if (containsAny(sourceContent, logoutMarkers))
|
|
736
874
|
return [];
|
|
737
875
|
return [
|
|
738
|
-
finding(`${platform}.auth.logout-on-user-switch`, "warning", "User-switch pattern detected with social.plus login, but no logout/disconnect call was found. Switching users without logout can leak state across identities.", relativeFile(root, switchFiles[0]), "Call logout or disconnect on the current session before logging in as a different user.
|
|
876
|
+
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."),
|
|
739
877
|
];
|
|
740
878
|
}
|
|
741
879
|
// ─── No anonymous write ──────────────────────────────────────────────────────
|
|
@@ -746,9 +884,14 @@ const WRITE_CALL_PATTERNS = [
|
|
|
746
884
|
/\.createPost\s*\(/, /\.createComment\s*\(/, /\.sendMessage\s*\(/,
|
|
747
885
|
];
|
|
748
886
|
const AUTH_GATE_MARKERS = [
|
|
749
|
-
|
|
887
|
+
// `currentUser` prefix, NOT \bcurrentUser\b — a write gated on `currentUserId` (the
|
|
888
|
+
// session-derived user id, idiomatically passed into a component) is a valid
|
|
889
|
+
// anonymous-write guard, and \b after "currentUser" would not match "currentUserId".
|
|
890
|
+
/\bcurrentUser\w*/, /\bisAuthenticated\b/, /\brequireAuth\b/,
|
|
750
891
|
/\bsession\b/, /\bisLoggedIn\b/, /\bauthState\b/,
|
|
751
|
-
|
|
892
|
+
// \w* not \b: `getCurrentUserId()` (the session-derived id used to gate writes) must match —
|
|
893
|
+
// the trailing \b would stop at "getCurrentUser" and miss the camelCase suffix (recurring pattern).
|
|
894
|
+
/\bgetCurrentUser\w*/, /\bgetAccessToken\b/, /\baccessToken\b/,
|
|
752
895
|
/\bauthGuard\b/, /\bwithAuth\b/, /\bProtectedRoute\b/, /\bAuthProvider\b/,
|
|
753
896
|
];
|
|
754
897
|
function validateNoAnonymousWrite(root, platform, sourceContent) {
|
|
@@ -811,8 +954,8 @@ const COMMENT_PRESENCE_PATTERNS = [
|
|
|
811
954
|
const COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
|
|
812
955
|
android: [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/],
|
|
813
956
|
flutter: [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/],
|
|
814
|
-
typescript: [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/],
|
|
815
|
-
"react-native": [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/, /useEffect.*return/],
|
|
957
|
+
typescript: [/unsubscribe/, /unobserve/, /\.off\s*\(/, /removeListener/, /dispose/, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/, /return\s*\(\s*\)\s*=>/],
|
|
958
|
+
"react-native": [/unsubscribe/, /unobserve/, /\.off\s*\(/, /removeListener/, /dispose/, /useEffect.*return/, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/, /return\s*\(\s*\)\s*=>/],
|
|
816
959
|
ios: [/\.invalidate\s*\(/, /AmityNotificationToken/, /deinit/, /viewWillDisappear/],
|
|
817
960
|
};
|
|
818
961
|
const COMMENT_UI_STATE_MARKERS = [
|
|
@@ -833,9 +976,19 @@ function validateComments(root, platform, sourceContent) {
|
|
|
833
976
|
break;
|
|
834
977
|
}
|
|
835
978
|
}
|
|
836
|
-
// observer-cleanup:
|
|
979
|
+
// observer-cleanup: require cleanup only for a REACTIVE comment observer. The
|
|
980
|
+
// distinguisher is `await`: a one-shot `await queryComments(...)` returns a Promise
|
|
981
|
+
// with nothing to clean up (false-fired on a weak-model build), whereas a non-awaited
|
|
982
|
+
// getComments/queryComments creates a live collection that must be disposed. Strip the
|
|
983
|
+
// awaited one-shot queries, then any remaining (bare-call, assigned, or chained) comment
|
|
984
|
+
// query is a live observer.
|
|
985
|
+
const hasReactiveCommentObserver = [...sourceContent.values()].some((c) => {
|
|
986
|
+
const stripped = c.replace(/\bawait\b[^\n;]*?(?:get|query)Comments\s*\(/g, "");
|
|
987
|
+
return /(?:get|query)Comments\s*\(/.test(stripped)
|
|
988
|
+
|| /CommentLiveCollection|AmityCommentCollection|commentCollection/.test(c);
|
|
989
|
+
});
|
|
837
990
|
const cleanupMarkers = COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
|
|
838
|
-
if (!containsAny(sourceContent, cleanupMarkers)) {
|
|
991
|
+
if (commentFiles.length > 0 && hasReactiveCommentObserver && !containsAny(sourceContent, cleanupMarkers)) {
|
|
839
992
|
findings.push(finding(`${platform}.comments.observer-cleanup`, "warning", "Comment observer/subscription was found but no obvious cleanup was detected.", relativeFile(root, commentFiles[0]), "Dispose or unsubscribe comment observers when the lifecycle owner is destroyed."));
|
|
840
993
|
}
|
|
841
994
|
// ui-states-present: require loading/empty/error states
|
|
@@ -938,6 +1091,25 @@ function validateChat(root, platform, sourceContent) {
|
|
|
938
1091
|
const chatFiles = filesMatching(sourceContent, CHAT_PRESENCE_PATTERNS);
|
|
939
1092
|
if (chatFiles.length === 0)
|
|
940
1093
|
return findings;
|
|
1094
|
+
// channel-type-dm: createChannel with type 'community' AND userIds indicates DM intent with wrong type.
|
|
1095
|
+
// Disambiguating condition: community channels do not pass userIds in createChannel (membership is
|
|
1096
|
+
// managed separately). If userIds appears alongside type:'community', the intent is clearly a DM
|
|
1097
|
+
// conversation but the wrong channel type was used. Do NOT fire on community channels without userIds.
|
|
1098
|
+
for (const filePath of chatFiles) {
|
|
1099
|
+
const content = sourceContent.get(filePath) ?? "";
|
|
1100
|
+
if (/\bcreateChannel\b/.test(content)) {
|
|
1101
|
+
const hasCommunityType = /createChannel[\s\S]{0,400}type\s*:\s*['"]community['"]/.test(content);
|
|
1102
|
+
const hasUserIds = /createChannel[\s\S]{0,400}\buserIds\b/.test(content);
|
|
1103
|
+
const hasConversationType = /createChannel[\s\S]{0,400}type\s*:\s*['"]conversation['"]/.test(content);
|
|
1104
|
+
// Only fire on react-native/typescript where this rule is registered.
|
|
1105
|
+
// Only fire when both type:'community' AND userIds are present — that combination signals
|
|
1106
|
+
// DM intent. Community channel creation does not pass userIds to createChannel.
|
|
1107
|
+
if ((platform === "react-native" || platform === "typescript") && hasCommunityType && hasUserIds && !hasConversationType) {
|
|
1108
|
+
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."));
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
941
1113
|
// channel-target-resolved: check for hardcoded channelId/conversationId
|
|
942
1114
|
for (const filePath of chatFiles) {
|
|
943
1115
|
const content = sourceContent.get(filePath) ?? "";
|
|
@@ -1013,6 +1185,9 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
|
|
|
1013
1185
|
/\bListView\b/,
|
|
1014
1186
|
/\bRecyclerView\b/,
|
|
1015
1187
|
/\bLazyColumn\b/,
|
|
1188
|
+
/\bLazyRow\b/, // Jetpack Compose lists
|
|
1189
|
+
/\bLazyVerticalGrid\b/,
|
|
1190
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1016
1191
|
/\bForEach\b/,
|
|
1017
1192
|
/\bList\s*\(/,
|
|
1018
1193
|
/\bsubmitList\b/,
|
|
@@ -1027,19 +1202,39 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
|
|
|
1027
1202
|
/\bonData\b/,
|
|
1028
1203
|
/\bStreamBuilder\b/,
|
|
1029
1204
|
/\bLiveData\b/,
|
|
1205
|
+
/\bMutableLiveData\b/, // Android LiveData (modern; `\bLiveData\b` misses Mutable/Mediator forms)
|
|
1206
|
+
/\bMediatorLiveData\b/,
|
|
1207
|
+
/collectAsState/, // Jetpack Compose: Flow/StateFlow → State (collectAsState, collectAsStateWithLifecycle)
|
|
1208
|
+
/observeAsState/, // Jetpack Compose: LiveData → State
|
|
1030
1209
|
/Amity\w*Flow\b/,
|
|
1031
1210
|
/\bPagingData\b/,
|
|
1211
|
+
/LazyPagingItems/, // Paging3 Compose: getX().collectAsLazyPagingItems() in a LazyColumn — reactive
|
|
1212
|
+
/collectAsLazyPagingItems/,
|
|
1213
|
+
/\.on\s*\(\s*['"]data[A-Z]/, // event-emitter SDK pattern: .on('dataUpdated', ...)
|
|
1214
|
+
/\$snapshots\b/, // iOS Combine: AmityCollection.$snapshots.sink { ... }
|
|
1215
|
+
/\bAmityCollection\b/, // iOS live collection type, consumed reactively (@Published / .snapshots in a List)
|
|
1216
|
+
/\bLiveCollectionCallback\b/, // TS/RN live-collection callback type (Amity.LiveCollectionCallback)
|
|
1217
|
+
/\bLiveObjectCallback\b/, // TS/RN live-object callback type
|
|
1218
|
+
// TS/RN reactive form: getPosts(params, callback) / getComments(params, cb) — the
|
|
1219
|
+
// 2-arg callback form IS the live collection (callback fires on every update). Earlier
|
|
1220
|
+
// markers only knew getLiveCollection/observe and missed this idiomatic v6 form. The
|
|
1221
|
+
// first arg is a flat params OBJECT or an identifier; the callback is a genuine SECOND
|
|
1222
|
+
// argument — matching `{...},` (not a comma *inside* the object, which would falsely
|
|
1223
|
+
// count a one-shot `queryPosts({ a, b })` as reactive).
|
|
1224
|
+
/\b(?:get|query)(?:Posts|Comments|Channels|Communities|Users|Members|Reactions)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1032
1225
|
/\/\/\s*vise:\s*one-shot query/i
|
|
1033
1226
|
];
|
|
1034
1227
|
for (const [file, content] of sourceContent) {
|
|
1035
1228
|
const rel = relativeFile(root, file);
|
|
1229
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1230
|
+
continue;
|
|
1036
1231
|
const hasListQuery = LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1037
1232
|
if (hasListQuery) {
|
|
1038
1233
|
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1039
1234
|
if (hasListUI) {
|
|
1040
1235
|
const hasReactivity = REACTIVE_MARKERS.some((p) => p.test(content));
|
|
1041
1236
|
if (!hasReactivity) {
|
|
1042
|
-
findings.push(finding(ruleId, "warning", `A list query (like getPosts) is present but no reactive markers (getLiveCollection, observe, onData) were found in the same file.`, rel, "Use the reactive LiveCollection API for lists so the UI updates when new items arrive. If a one-shot query is truly intended, add comment // vise: one-shot query."));
|
|
1237
|
+
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."));
|
|
1043
1238
|
}
|
|
1044
1239
|
}
|
|
1045
1240
|
}
|
|
@@ -1052,7 +1247,10 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
|
|
|
1052
1247
|
const POST_QUERY_PATTERNS = [
|
|
1053
1248
|
/\bgetPosts\b/,
|
|
1054
1249
|
/\bqueryPosts\b/,
|
|
1055
|
-
|
|
1250
|
+
// getCommunityFeed intentionally NOT matched: it is also a common name for a
|
|
1251
|
+
// user-defined wrapper, and firing on a wrapper CALL SITE (which has no query
|
|
1252
|
+
// params to filter) is a false positive. The explicit getPosts/queryPosts cover
|
|
1253
|
+
// the real query path where filters are actually applied.
|
|
1056
1254
|
];
|
|
1057
1255
|
const FILTER_MARKERS = [
|
|
1058
1256
|
/\bfeedTypes?\b/,
|
|
@@ -1078,6 +1276,99 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
|
|
|
1078
1276
|
}
|
|
1079
1277
|
return findings;
|
|
1080
1278
|
}
|
|
1279
|
+
function validateActivityPostsTagFilter(root, platform, sourceContent) {
|
|
1280
|
+
const findings = [];
|
|
1281
|
+
const ruleId = `${platform}.posts.activity-tag-filter`;
|
|
1282
|
+
for (const [file, content] of sourceContent) {
|
|
1283
|
+
const rel = relativeFile(root, file);
|
|
1284
|
+
const filename = path.basename(file).toLowerCase();
|
|
1285
|
+
if (filename.endsWith('.d.ts'))
|
|
1286
|
+
continue;
|
|
1287
|
+
// Only fire for files that are clearly activity / toast / notification hooks
|
|
1288
|
+
if (!/activity|toast|notification|event/.test(filename))
|
|
1289
|
+
continue;
|
|
1290
|
+
if (!/\bgetPosts\b/.test(content))
|
|
1291
|
+
continue;
|
|
1292
|
+
if (/\btags\s*:/.test(content))
|
|
1293
|
+
continue;
|
|
1294
|
+
if (/\/\/\s*vise:\s*untagged posts intentional/i.test(content))
|
|
1295
|
+
continue;
|
|
1296
|
+
findings.push(finding(ruleId, "warning", "A getPosts query in an activity or toast hook is missing a tags filter.", rel, "Add a tags: ['activity'] parameter to the existing query object — e.g. change PostRepository.getPosts({ communityId }) to PostRepository.getPosts({ communityId, tags: ['activity'] }). Without this filter every community post enters the toast pipeline, not just activity events. Do not rewrite the query style; just add the tags field."));
|
|
1297
|
+
}
|
|
1298
|
+
return findings;
|
|
1299
|
+
}
|
|
1300
|
+
function validateReactionStalePostRef(root, platform, sourceContent) {
|
|
1301
|
+
const findings = [];
|
|
1302
|
+
const ruleId = `${platform}.posts.reaction-stale-post-ref`;
|
|
1303
|
+
for (const [file, content] of sourceContent) {
|
|
1304
|
+
const rel = relativeFile(root, file);
|
|
1305
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1306
|
+
continue;
|
|
1307
|
+
// Only check files that have reaction calls
|
|
1308
|
+
if (!/\baddReaction\b|\bremoveReaction\b/.test(content))
|
|
1309
|
+
continue;
|
|
1310
|
+
// Only check files that use a ref (React hook — TypeScript/React Native only)
|
|
1311
|
+
if (!/\buseRef\b/.test(content))
|
|
1312
|
+
continue;
|
|
1313
|
+
// Look for .current passed into the reaction call — the stale-ref pattern
|
|
1314
|
+
if (!/.current\b/.test(content))
|
|
1315
|
+
continue;
|
|
1316
|
+
// If the file already reads post.postId from live state it is correct
|
|
1317
|
+
if (/\bpost\s*\??\.\s*postId\b/.test(content))
|
|
1318
|
+
continue;
|
|
1319
|
+
if (/\/\/\s*vise:\s*captured ref intentional/i.test(content))
|
|
1320
|
+
continue;
|
|
1321
|
+
findings.push(finding(ruleId, "warning", "A reaction call (addReaction/removeReaction) uses a captured ref instead of reading postId from live state.", rel, "Delete the captured ref variable (capturedPostId / similar) completely — remove the useRef declaration, the assignment inside the subscription callback, and every reference to .current. Then pass post?.postId directly to addReaction and removeReaction, and add the live post variable to the useCallback dependency array: useCallback(async (name) => { await addReaction('post', post?.postId, name); }, [post]). Keeping the ref — even without the null-guard — is still the stale-ref pattern."));
|
|
1322
|
+
}
|
|
1323
|
+
return findings;
|
|
1324
|
+
}
|
|
1325
|
+
function validateFollowStatusSubscription(root, platform, sourceContent) {
|
|
1326
|
+
const findings = [];
|
|
1327
|
+
const ruleId = `${platform}.follow.status-subscription`;
|
|
1328
|
+
for (const [file, content] of sourceContent) {
|
|
1329
|
+
const rel = relativeFile(root, file);
|
|
1330
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1331
|
+
continue;
|
|
1332
|
+
if (!/\bgetFollowStatus\b/.test(content))
|
|
1333
|
+
continue;
|
|
1334
|
+
// Positive markers: live object subscription or explicit escape hatch.
|
|
1335
|
+
// The canonical "live" pattern is `getFollowStatus(userId, ({ data }) => ...)` —
|
|
1336
|
+
// a callback that receives the live snapshot. That's also what the
|
|
1337
|
+
// recommendation below suggests. If the call passes a function-style argument
|
|
1338
|
+
// (arrow or function), treat it as a live subscription.
|
|
1339
|
+
if (/\.on\s*\(\s*['"]dataUpdated['"]/.test(content) ||
|
|
1340
|
+
/\bLiveObject\b/.test(content) ||
|
|
1341
|
+
/\bobserve\s*\(/.test(content) ||
|
|
1342
|
+
// `getFollowStatus(userId, callback)` — two-arg form (comma inside the
|
|
1343
|
+
// first level of parens). Matches the recommended pattern whether the
|
|
1344
|
+
// callback is inline `({ data }) => …` or a named reference.
|
|
1345
|
+
// [^()]* keeps us inside the first level — disqualifies promise chains
|
|
1346
|
+
// like `getFollowStatus(userId).then(...)` which have no comma inside.
|
|
1347
|
+
/getFollowStatus\s*\([^()]*,/.test(content) ||
|
|
1348
|
+
/\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
|
|
1349
|
+
continue;
|
|
1350
|
+
findings.push(finding(ruleId, "warning", "getFollowStatus is called without a live subscription. Follow state will not update when the relationship changes.", rel, "Subscribe to the live object returned by getFollowStatus and dispose on cleanup: const unsubFollow = UserRepository.getFollowStatus(userId, ({ data }) => { if (data) setFollowStatus(data.status); }); return () => { unsubFollow(); };"));
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
return findings;
|
|
1354
|
+
}
|
|
1355
|
+
function validateMembershipUsesLiveCollection(root, platform, sourceContent) {
|
|
1356
|
+
const findings = [];
|
|
1357
|
+
const ruleId = `${platform}.membership.use-live-collection`;
|
|
1358
|
+
for (const [file, content] of sourceContent) {
|
|
1359
|
+
const rel = relativeFile(root, file);
|
|
1360
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1361
|
+
continue;
|
|
1362
|
+
if (!/\bsearchMembers\b/.test(content))
|
|
1363
|
+
continue;
|
|
1364
|
+
if (/\bgetMembers\b/.test(content))
|
|
1365
|
+
continue; // already uses live collection
|
|
1366
|
+
if (/\/\/\s*vise:\s*one-shot membership intentional/i.test(content))
|
|
1367
|
+
continue;
|
|
1368
|
+
findings.push(finding(ruleId, "warning", "searchMembers is a one-shot query — it fetches membership once and will not update when members join or leave.", rel, "Replace searchMembers with CommunityRepository.Membership.getMembers({ communityId }) which returns a LiveCollection. Subscribe to dataUpdated for real-time membership changes and call dispose() for cleanup: const liveCollection = CommunityRepository.Membership.getMembers({ communityId }); liveCollection.on('dataUpdated', (members) => setMembers(members)); return () => liveCollection.dispose();"));
|
|
1369
|
+
}
|
|
1370
|
+
return findings;
|
|
1371
|
+
}
|
|
1081
1372
|
function validatePaginationCursorOpaque(root, platform, sourceContent) {
|
|
1082
1373
|
const findings = [];
|
|
1083
1374
|
const ruleId = `${platform}.pagination.cursor-opaque`;
|
|
@@ -1115,23 +1406,57 @@ function validatePaginationCursorOpaque(root, platform, sourceContent) {
|
|
|
1115
1406
|
function validatePostsParentChild(root, platform, sourceContent) {
|
|
1116
1407
|
const findings = [];
|
|
1117
1408
|
const ruleId = `${platform}.posts.parent-child-rendered`;
|
|
1409
|
+
// Direct text-field reads that signal a component is rendering the post body.
|
|
1410
|
+
// Deliberately NOT a bare `post.data` — that also matches passing post.data as
|
|
1411
|
+
// an argument to a content/share helper (e.g. textFromContent(post.dataType,
|
|
1412
|
+
// post.data)) in a non-rendering file like an actions menu, which produced a
|
|
1413
|
+
// false positive on a correctly-factored app where rendering and the actions
|
|
1414
|
+
// menu live in separate files.
|
|
1415
|
+
// Case-SENSITIVE on purpose: lowercase `post.data.text` is post-body rendering,
|
|
1416
|
+
// but the case-insensitive form also matched Kotlin/Swift sealed-class type
|
|
1417
|
+
// discriminators like `AmityComment.Data.TEXT` / `AmityMessage.Data.TEXT` —
|
|
1418
|
+
// which are type checks in comment/message code, not post rendering. That
|
|
1419
|
+
// produced a large false-positive cluster on real Kotlin (Android sample app).
|
|
1118
1420
|
const POST_RENDER_PATTERNS = [
|
|
1119
|
-
/\.data\.text\b
|
|
1120
|
-
/\bpost\.
|
|
1121
|
-
/\bpost\.text\b/i
|
|
1421
|
+
/\.data\.text\b/,
|
|
1422
|
+
/\bpost\.text\b/
|
|
1122
1423
|
];
|
|
1123
1424
|
const CHILD_REFERENCE_PATTERNS = [
|
|
1124
1425
|
/\.\s*children\b/,
|
|
1125
1426
|
/\.\s*childrenPosts\b/,
|
|
1126
1427
|
/\bgetChildren\b/
|
|
1127
1428
|
];
|
|
1429
|
+
// Child-typed media the parent-child model carries (TS/RN string dataTypes).
|
|
1430
|
+
const CHILD_MEDIA_TYPES = ["poll", "video", "room", "file", "audio"];
|
|
1128
1431
|
for (const [filename, text] of sourceContent) {
|
|
1129
1432
|
const rel = relativeFile(root, filename);
|
|
1130
|
-
|
|
1131
|
-
if (
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1433
|
+
// Escape-hatch checked on RAW content (stripComments would delete the marker).
|
|
1434
|
+
if (/\/\/\s*vise:\s*child-types intentional/i.test(text))
|
|
1435
|
+
continue;
|
|
1436
|
+
const astLang = astLanguageForFile(filename, platform);
|
|
1437
|
+
const checkContent = astLang ? stripComments(astLang, text) : text;
|
|
1438
|
+
const hasPostRender = POST_RENDER_PATTERNS.some((p) => p.test(checkContent));
|
|
1439
|
+
const hasChildRef = CHILD_REFERENCE_PATTERNS.some((p) => p.test(checkContent));
|
|
1440
|
+
// (1) Presence: renders post text but never inspects children at all.
|
|
1441
|
+
if (hasPostRender && !hasChildRef) {
|
|
1442
|
+
findings.push(finding(ruleId, "warning", `A component renders post text but does not appear to inspect post children for media.`, rel, "Amity models images and videos as child posts. A UI that only renders the parent post's text will silently drop attachments. Inspect post.children or call getChildren()."));
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
// (2) Inconsistency (TS/RN): the renderer DOES inspect children (so the
|
|
1446
|
+
// parent-child model is in play) but gates some media types on the PARENT
|
|
1447
|
+
// post's dataType with no matching child lookup. A feed parent is dataType
|
|
1448
|
+
// 'text', so `post.dataType === 'poll'` never matches and that content
|
|
1449
|
+
// silently never renders. Only fires when child handling is already
|
|
1450
|
+
// present for something — which keeps false positives low (a feed with
|
|
1451
|
+
// genuinely top-level posts won't be inspecting childrenPosts at all).
|
|
1452
|
+
if (hasChildRef && (platform === "typescript" || platform === "react-native")) {
|
|
1453
|
+
const missed = CHILD_MEDIA_TYPES.filter((t) => {
|
|
1454
|
+
const parentGated = new RegExp(`post\\s*\\.\\s*dataType\\s*===\\s*['"\`]${t}['"\`]`).test(checkContent);
|
|
1455
|
+
const childResolved = new RegExp(`childrenPosts[\\s\\S]{0,80}dataType\\s*===\\s*['"\`]${t}['"\`]`).test(checkContent);
|
|
1456
|
+
return parentGated && !childResolved;
|
|
1457
|
+
});
|
|
1458
|
+
if (missed.length > 0) {
|
|
1459
|
+
findings.push(finding(ruleId, "warning", `Post renderer resolves children for some types but gates ${missed.join("/")} on the parent post's dataType. Feed parents are dataType 'text', so these branches never match and that content silently never renders.`, rel, "Resolve every child media type from post.childrenPosts, e.g. post.childrenPosts.find(c => c.dataType === 'poll')?.getPollInfo() — the same way images are handled. Checking post.dataType === 'poll' on a feed parent never matches. Add // vise: child-types intentional if these are genuinely top-level posts."));
|
|
1135
1460
|
}
|
|
1136
1461
|
}
|
|
1137
1462
|
}
|
|
@@ -1223,7 +1548,9 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1223
1548
|
continue;
|
|
1224
1549
|
const rel = relativeFile(root, file);
|
|
1225
1550
|
const lang = file.endsWith(".tsx") ? "tsx" : "typescript";
|
|
1226
|
-
const tree =
|
|
1551
|
+
const tree = tryParse(lang, content);
|
|
1552
|
+
if (!tree)
|
|
1553
|
+
continue; // oversized/unparseable file — regex passes above still apply
|
|
1227
1554
|
// ── auth.no-literal-user-id via .login({ userId: CONST }) ──
|
|
1228
1555
|
const userIdRule = `${platform}.auth.no-literal-user-id`;
|
|
1229
1556
|
if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
|
|
@@ -1295,7 +1622,9 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1295
1622
|
if (!file.endsWith(".kt"))
|
|
1296
1623
|
continue;
|
|
1297
1624
|
const rel = relativeFile(root, file);
|
|
1298
|
-
const tree =
|
|
1625
|
+
const tree = tryParse("kotlin", content);
|
|
1626
|
+
if (!tree)
|
|
1627
|
+
continue; // oversized/unparseable file — regex passes above still apply
|
|
1299
1628
|
// ── auth.no-literal-user-id via .login(CONST) ──
|
|
1300
1629
|
const userIdRule = `${platform}.auth.no-literal-user-id`;
|
|
1301
1630
|
if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
|
|
@@ -1450,11 +1779,15 @@ async function validateIos(root) {
|
|
|
1450
1779
|
const findings = [];
|
|
1451
1780
|
const manifestFiles = await existingFiles(root, ["Podfile", "Package.swift"]);
|
|
1452
1781
|
const manifestContent = await readMany(manifestFiles);
|
|
1453
|
-
const swiftFiles = await findFiles(root, [".swift"], 400);
|
|
1782
|
+
const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400);
|
|
1454
1783
|
const swiftContent = await readMany(swiftFiles);
|
|
1455
1784
|
const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
|
|
1456
1785
|
const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
|
|
1457
|
-
|
|
1786
|
+
// social.plus push REGISTRATION only (real iOS symbol: registerPushNotification). Do NOT key
|
|
1787
|
+
// on the generic APNs primitive `didRegisterForRemoteNotifications` — that is the host app's own
|
|
1788
|
+
// push, present in any iOS app with notifications, and made the social.plus-push rules false-fire
|
|
1789
|
+
// on brownfield apps whose social.plus integration never included push.
|
|
1790
|
+
const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/]);
|
|
1458
1791
|
const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
|
|
1459
1792
|
const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
|
|
1460
1793
|
if (manifestFiles.length === 0) {
|
|
@@ -1485,7 +1818,7 @@ async function validateIos(root) {
|
|
|
1485
1818
|
findings.push(finding("ios.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity and handle session renewal for production auth."));
|
|
1486
1819
|
}
|
|
1487
1820
|
else if (!containsAny(swiftContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\.renew\s*\(/])) {
|
|
1488
|
-
findings.push(finding("ios.session.renewal", "warning", "Login was found but no
|
|
1821
|
+
findings.push(finding("ios.session.renewal", "warning", "Login was found but no SessionHandler with sessionWillRenewAccessToken / renewal.renew() was detected.", relativeFile(root, loginFiles[0]), "Implement a SessionHandler and call renewal.renew() in sessionWillRenewAccessToken so the session can refresh in production."));
|
|
1489
1822
|
}
|
|
1490
1823
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
1491
1824
|
findings.push(finding("ios.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
|
|
@@ -1496,8 +1829,11 @@ async function validateIos(root) {
|
|
|
1496
1829
|
}
|
|
1497
1830
|
// iOS push payload: if didReceiveRemoteNotification or userNotificationCenter(_:didReceive:) exists,
|
|
1498
1831
|
// require push-specific SDK forwarding marker.
|
|
1832
|
+
// Nexus gate: only demand social.plus forwarding when social.plus push is actually set up
|
|
1833
|
+
// (registration present). A host app's generic push payload handler with no social.plus push
|
|
1834
|
+
// registration is not a social.plus concern (brownfield false-positive).
|
|
1499
1835
|
const iosPayloadHandlerFiles = filesMatching(swiftContent, [/didReceiveRemoteNotification/, /userNotificationCenter\s*\(\s*_\s*,\s*didReceive/]);
|
|
1500
|
-
if (iosPayloadHandlerFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
1836
|
+
if (iosPayloadHandlerFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
1501
1837
|
findings.push(finding("ios.push.payload-contract-respected", "warning", "Push payload handler exists but no social.plus push forwarding call was detected.", relativeFile(root, iosPayloadHandlerFiles[0]), "Forward incoming push payloads to the social.plus SDK push handler (e.g. AmityPush.handleNotification) so social notifications are routed correctly."));
|
|
1502
1838
|
}
|
|
1503
1839
|
if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
|
|
@@ -1514,6 +1850,13 @@ async function validateIos(root) {
|
|
|
1514
1850
|
findings.push(...validateComments(root, "ios", swiftContent));
|
|
1515
1851
|
findings.push(...validateModeration(root, "ios", swiftContent));
|
|
1516
1852
|
findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
|
|
1853
|
+
findings.push(...validatePostDataTypeHandled(root, "ios", swiftContent));
|
|
1854
|
+
findings.push(...validateAvatarFromSdk(root, "ios", swiftContent));
|
|
1855
|
+
findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
|
|
1856
|
+
findings.push(...validateCommentsQueryHasLimit(root, "ios", swiftContent));
|
|
1857
|
+
findings.push(...validateCommentCreationAffordance(root, "ios", swiftContent));
|
|
1858
|
+
findings.push(...validateCommunityDisplayNameFromSdk(root, "ios", swiftContent));
|
|
1859
|
+
findings.push(...validateRoomPostFetched(root, "ios", swiftContent));
|
|
1517
1860
|
findings.push(...validatePostsStatusFilter(root, "ios", swiftContent));
|
|
1518
1861
|
findings.push(...validatePaginationCursorOpaque(root, "ios", swiftContent));
|
|
1519
1862
|
findings.push(...validatePostsParentChild(root, "ios", swiftContent));
|
|
@@ -1532,6 +1875,7 @@ async function validateIos(root) {
|
|
|
1532
1875
|
findings.push(...validateSessionHandlerRetention(root, "ios", swiftContent));
|
|
1533
1876
|
findings.push(...validateChat(root, "ios", swiftContent));
|
|
1534
1877
|
findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
|
|
1878
|
+
findings.push(...(await validateEnvSecretHygiene(root, "ios", swiftContent)));
|
|
1535
1879
|
return findings;
|
|
1536
1880
|
}
|
|
1537
1881
|
function statusForFindings(findings) {
|
|
@@ -1812,6 +2156,16 @@ function astLanguageForFile(filePath, platform) {
|
|
|
1812
2156
|
}
|
|
1813
2157
|
function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
|
|
1814
2158
|
const findings = [];
|
|
2159
|
+
// TypeScript/React Native: the SDK types referenceType as the string-literal
|
|
2160
|
+
// union Amity.CommentReferenceType ('post' | 'story' | 'content') — there is
|
|
2161
|
+
// no enum to use, and `referenceType: 'post'` is the documented, idiomatic
|
|
2162
|
+
// value (this ruleset's own comment-limit / creation-affordance remediation
|
|
2163
|
+
// strings instruct exactly that). A wrong casing like 'POST' is not assignable
|
|
2164
|
+
// to the union, so tsc already catches it. Flagging the correct string here was
|
|
2165
|
+
// a pure false positive; the rule remains active on android/flutter/ios where a
|
|
2166
|
+
// real AmityCommentReferenceType enum exists.
|
|
2167
|
+
if (platform === "typescript" || platform === "react-native")
|
|
2168
|
+
return findings;
|
|
1815
2169
|
const ruleId = `${platform}.comment.reference-type-enum`;
|
|
1816
2170
|
const REFERENCE_TYPE_PATTERNS = [
|
|
1817
2171
|
/referenceType\s*[:=\(]\s*(['"][^'"]+['"])/i
|
|
@@ -1868,7 +2222,23 @@ function validateReactionConfiguredNameUsed(root, platform, sourceContent) {
|
|
|
1868
2222
|
if (/\/\/\s*vise:\s*reaction\s*"[^"]+"\s*matches\s*console\s*config/i.test(text)) {
|
|
1869
2223
|
continue;
|
|
1870
2224
|
}
|
|
1871
|
-
|
|
2225
|
+
// The reaction NAME is the tenant-specific argument. The TS/RN SDK signature is
|
|
2226
|
+
// addReaction(referenceType, referenceId, reactionName) — the first literal is the
|
|
2227
|
+
// referenceType ('post'/'comment'/'message'/'story'), which is LEGITIMATELY a literal
|
|
2228
|
+
// and must not be mistaken for a hardcoded reaction name. Flag only when a string
|
|
2229
|
+
// literal that is NOT a known reference-type appears in the call.
|
|
2230
|
+
const REF_TYPE = /^(?:post|comment|message|story|community|user|channel|content)$/i;
|
|
2231
|
+
const reactionCallRe = /\b(?:addReaction|removeReaction|flagReaction|react)\s*\(([^)]*)\)/gi;
|
|
2232
|
+
let hardcodesReactionName = false;
|
|
2233
|
+
let rcm;
|
|
2234
|
+
while ((rcm = reactionCallRe.exec(text)) !== null) {
|
|
2235
|
+
const literals = [...rcm[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((x) => x[1]);
|
|
2236
|
+
if (literals.some((lit) => !REF_TYPE.test(lit))) {
|
|
2237
|
+
hardcodesReactionName = true;
|
|
2238
|
+
break;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
if (hardcodesReactionName) {
|
|
1872
2242
|
findings.push(finding(ruleId, "warning", `File ${rel} hardcodes a reaction name string literal.`, rel, `Reaction names are per-tenant. Retrieve the reaction name from a configuration file or prop instead of hardcoding it. If it matches console config, add comment // vise: reaction "<name>" matches console config.`));
|
|
1873
2243
|
}
|
|
1874
2244
|
}
|
|
@@ -1986,23 +2356,67 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
|
|
|
1986
2356
|
}
|
|
1987
2357
|
return findings;
|
|
1988
2358
|
}
|
|
2359
|
+
// The ban-state / role-gated / flagCount-leak rules all flag a MISSING UI guard.
|
|
2360
|
+
// That claim only makes sense for files that render UI. Service and data layers —
|
|
2361
|
+
// repositories, managers, datasources, API clients, stores, interactors — wrap the
|
|
2362
|
+
// SDK call but legitimately leave the guard to the UI layer above them, so firing on
|
|
2363
|
+
// them is a false positive. Observed on the official Amity sample apps, where
|
|
2364
|
+
// PostFlagManager / StoryManager / MembershipListDataSource tripped all three rules
|
|
2365
|
+
// despite the guards living correctly in their View/ViewController callers.
|
|
2366
|
+
function isNonUiSourceFile(filename) {
|
|
2367
|
+
const base = path.basename(filename).replace(/\.(kt|swift|dart|tsx?|jsx?)$/i, "");
|
|
2368
|
+
// PascalCase class-named files (Swift, Kotlin, TS): suffix match. Note "Controller"
|
|
2369
|
+
// and "View(Model)" are deliberately absent — iOS ViewControllers/SwiftUI Views DO
|
|
2370
|
+
// render and must keep firing.
|
|
2371
|
+
if (/(?:Manager|Repository|Repo|Service|DataSource|Datasource|Provider|Client|Store|Mapper|Interactor|UseCase|Bloc|Cubit|Notifier|Mock|Fake|Stub|Test|Tests|Spec)$/.test(base))
|
|
2372
|
+
return true;
|
|
2373
|
+
// snake_case files (Dart, sometimes RN): same non-UI layers, plus Flutter's BLoC /
|
|
2374
|
+
// Cubit / ChangeNotifier state-management layers. "_page"/"_popup"/"_screen" are UI
|
|
2375
|
+
// and intentionally not matched.
|
|
2376
|
+
if (/_(?:manager|repository|repo|service|data_source|datasource|provider|client|store|mapper|interactor|use_case|usecase|bloc|cubit|notifier|mock|fake|stub|test|spec)$/i.test(base))
|
|
2377
|
+
return true;
|
|
2378
|
+
// dotted test/spec helpers (foo.test.ts, foo.spec.ts)
|
|
2379
|
+
if (/\.(?:test|spec)$/i.test(base))
|
|
2380
|
+
return true;
|
|
2381
|
+
return false;
|
|
2382
|
+
}
|
|
1989
2383
|
function validateUserBanStateRespected(root, platform, sourceContent) {
|
|
1990
2384
|
const findings = [];
|
|
1991
2385
|
const ruleId = platform + '.user.ban-state-respected';
|
|
1992
2386
|
for (const [filename, text] of sourceContent) {
|
|
1993
2387
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
1994
|
-
//
|
|
1995
|
-
|
|
2388
|
+
// Skip type declaration files — they declare the method signatures but are not
|
|
2389
|
+
// the implementation that needs the ban-state guard.
|
|
2390
|
+
if (path.basename(filename).endsWith('.d.ts'))
|
|
2391
|
+
continue;
|
|
2392
|
+
// Skip non-UI service/data layers — the ban gate belongs in the UI that renders
|
|
2393
|
+
// the interaction button, not in the manager/repository that wraps the SDK call.
|
|
2394
|
+
if (isNonUiSourceFile(filename))
|
|
2395
|
+
continue;
|
|
2396
|
+
// Check if interaction methods are used in the file (includes flag actions — banned users
|
|
2397
|
+
// should also not be able to flag content, preventing spurious moderation queue spam)
|
|
2398
|
+
if (!/\b(createPost|createComment|sendMessage|addReaction|flagComment|flagPost)\b/.test(text)) {
|
|
1996
2399
|
continue;
|
|
1997
2400
|
}
|
|
1998
|
-
// Ban-state positive markers
|
|
1999
|
-
|
|
2401
|
+
// Ban-state positive markers. Accepts both member-access form
|
|
2402
|
+
// (`user.isBanned`, `currentUser.isGlobalBan`) and bare-variable form
|
|
2403
|
+
// (`disabled={isBanned}`, `!isBanned && ...`) — the latter is common
|
|
2404
|
+
// when a hook destructures the flag and the screen consumes it directly.
|
|
2405
|
+
const hasBanCheck =
|
|
2406
|
+
// Any ban-STATE boolean: isBanned, isGlobalBan, isGlobalBanned, isUserBanned,
|
|
2407
|
+
// currentUser.isGlobalBan(ned). The `is` prefix anchors this to ban-state flags
|
|
2408
|
+
// and excludes the banUser/bannedUserIds ACTION names. Real SDK ships both
|
|
2409
|
+
// isGlobalBan and isGlobalBanned; agents commonly derive a local `isGlobalBanned`.
|
|
2410
|
+
/\bis\w*Ban(?:ned)?\b/.test(text) ||
|
|
2411
|
+
// camelCase boolean form: `currentUserIsBanned` / `userIsBanned` — typically the ban state
|
|
2412
|
+
// fetched in a parent and passed down as a prop, then used to gate the write. The lowercase
|
|
2413
|
+
// `is`-anchored marker above misses it (capital `Is`, no leading word boundary mid-identifier).
|
|
2414
|
+
/\b\w*[Ii]sBanned\b/.test(text) ||
|
|
2000
2415
|
/isCurrentUserBannedFromCommunity/.test(text) ||
|
|
2001
2416
|
/community\.bannedUserIds\.(?:contains|includes)/i.test(text) ||
|
|
2002
2417
|
/channel\.isMuted/.test(text) ||
|
|
2003
2418
|
/member\.isMuted/.test(text) ||
|
|
2004
2419
|
/user\.banState/.test(text) ||
|
|
2005
|
-
/\.isBanned/.test(text) ||
|
|
2006
2420
|
/\/\/\s*vise:\s*ban state checked at/i.test(text);
|
|
2007
2421
|
if (!hasBanCheck) {
|
|
2008
2422
|
findings.push(finding(ruleId, "warning", "File " + rel + " renders an interaction action without checking ban state.", rel, "Banned users still see post-create, like, comment, or send-message buttons, which fail on the server. To avoid poor UX and support tickets, check the ban state before showing these actions."));
|
|
@@ -2015,6 +2429,10 @@ function validateFlagCountNotLeaked(root, platform, sourceContent) {
|
|
|
2015
2429
|
const ruleId = platform + '.flag-count.not-leaked-to-non-mods';
|
|
2016
2430
|
for (const [filename, text] of sourceContent) {
|
|
2017
2431
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2432
|
+
// Non-UI service/data layers read flagCount to pass it upward; the role gate
|
|
2433
|
+
// that decides whether to RENDER it lives in the UI layer, so skip them.
|
|
2434
|
+
if (isNonUiSourceFile(filename))
|
|
2435
|
+
continue;
|
|
2018
2436
|
// Check if flagCount is used in the file
|
|
2019
2437
|
if (!/\bflagCount\b/.test(text)) {
|
|
2020
2438
|
continue;
|
|
@@ -2041,11 +2459,25 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
|
|
|
2041
2459
|
const ruleId = platform + '.moderation.role-gated-action';
|
|
2042
2460
|
for (const [filename, text] of sourceContent) {
|
|
2043
2461
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2044
|
-
//
|
|
2045
|
-
|
|
2462
|
+
// Skip non-UI service/data layers for BOTH branches. The ownership gate
|
|
2463
|
+
// (edit/delete) is a UI concern; and on real code (Amity's MembershipListDataSource,
|
|
2464
|
+
// a UITableView data source that wraps muteMembers) the mod-only ban/mute gate also
|
|
2465
|
+
// belongs in the presenting ViewController, not the data layer — firing on the
|
|
2466
|
+
// service reads as a false positive. The SDK enforces ban/mute server-side regardless.
|
|
2467
|
+
if (isNonUiSourceFile(filename))
|
|
2468
|
+
continue;
|
|
2469
|
+
// Moderator-ONLY actions: only an admin/moderator can ban or mute members.
|
|
2470
|
+
// (flagPost/flagComment/unflag are deliberately NOT here — flagging/reporting
|
|
2471
|
+
// is a user-level action available to any authenticated member, so gating it
|
|
2472
|
+
// on a moderator role is itself a bug.)
|
|
2473
|
+
const hasModOnlyAction = /\b(banUser|unbanUser|banMembers|muteChannelMember|unmuteChannelMember|muteMembers)\b/.test(text);
|
|
2474
|
+
// Delete/edit: valid for the content AUTHOR (ownership) OR a moderator/admin
|
|
2475
|
+
// — per the SDK, "only post owners and admins can delete posts".
|
|
2476
|
+
const hasOwnableAction = /\b(deletePost|softDeletePost|hardDeletePost|deleteComment|softDeleteComment|hardDeleteComment|editPost|updateComment)\b/.test(text);
|
|
2477
|
+
if (!hasModOnlyAction && !hasOwnableAction) {
|
|
2046
2478
|
continue;
|
|
2047
2479
|
}
|
|
2048
|
-
//
|
|
2480
|
+
// Moderator role markers
|
|
2049
2481
|
const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
|
|
2050
2482
|
/\.hasRole\s*\(.*moderator/i.test(text) ||
|
|
2051
2483
|
/community\.hasModeratorRole/.test(text) ||
|
|
@@ -2054,9 +2486,26 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
|
|
|
2054
2486
|
/member\.isModerator/.test(text) ||
|
|
2055
2487
|
/roles\.includes\s*\(.*moderator/i.test(text) ||
|
|
2056
2488
|
/permissions\.(?:contains|includes).*moderation/i.test(text) ||
|
|
2489
|
+
/\bhasPermission\s*\(/.test(text) ||
|
|
2057
2490
|
/\/\/\s*vise:\s*role check applied/i.test(text);
|
|
2058
|
-
|
|
2059
|
-
|
|
2491
|
+
// Ownership markers (valid alternative to a role check for delete/edit).
|
|
2492
|
+
const hasOwnershipCheck = /\bisAuthor\b/.test(text) ||
|
|
2493
|
+
/\bisOwner\b/.test(text) ||
|
|
2494
|
+
/postedUserId\s*===?=?\s*\w+/.test(text) ||
|
|
2495
|
+
/\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(text) ||
|
|
2496
|
+
/creator\s*\??\.\s*userId\s*===?=?/.test(text) ||
|
|
2497
|
+
// Ownership by comparing the entity's userId to the current user — the
|
|
2498
|
+
// idiomatic Swift/Kotlin form, e.g. `comment.userId == amity.currentUserId`.
|
|
2499
|
+
/\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(text) ||
|
|
2500
|
+
/\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(text) ||
|
|
2501
|
+
/\/\/\s*vise:\s*owner action\b/i.test(text);
|
|
2502
|
+
// Moderator-only actions require a role check.
|
|
2503
|
+
if (hasModOnlyAction && !hasRoleCheck) {
|
|
2504
|
+
findings.push(finding(ruleId, "warning", "File " + rel + " invokes a moderator-only action (ban/mute) without a role check.", rel, "Ban/mute require the moderator role and fail on the server otherwise. Gate the action behind a role check (e.g. currentUser.roles.includes('moderator'), or client.hasPermission(...))."));
|
|
2505
|
+
}
|
|
2506
|
+
else if (hasOwnableAction && !hasRoleCheck && !hasOwnershipCheck) {
|
|
2507
|
+
// Delete/edit need EITHER ownership OR a moderator role.
|
|
2508
|
+
findings.push(finding(ruleId, "warning", "File " + rel + " deletes/edits content without an ownership or moderator-role check.", rel, "Only the content author or a moderator/admin can delete or edit a post/comment. Gate the action behind an ownership check (post.postedUserId === currentUserId) or a moderator role check. Add // vise: owner action if the author-ownership gate lives elsewhere."));
|
|
2060
2509
|
}
|
|
2061
2510
|
}
|
|
2062
2511
|
return findings;
|
|
@@ -2073,6 +2522,11 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
|
|
|
2073
2522
|
if (!matches)
|
|
2074
2523
|
continue;
|
|
2075
2524
|
for (const match of matches) {
|
|
2525
|
+
// A standard TEXT post (`data: { text: ... }`, incl. quoted keys for Dart/Swift) legitimately
|
|
2526
|
+
// has no dataType — only CUSTOM data payloads need the dataType tag. Don't mistake the canonical
|
|
2527
|
+
// text-post shape for a custom post (the weak-model bench build hit this with `data: { text }`).
|
|
2528
|
+
if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*:/i.test(match))
|
|
2529
|
+
continue;
|
|
2076
2530
|
if (!/dataType\s*[:=\(]/i.test(match)) {
|
|
2077
2531
|
findings.push(finding(ruleId, "warning", `File ${rel} creates a custom data post but does not declare a dataType.`, rel, `When creating a post with a custom data payload, the 'dataType' tag must be explicitly provided so the SDK can route it correctly. If missing, it defaults to a text post, which stringifies the payload.`));
|
|
2078
2532
|
}
|
|
@@ -2080,6 +2534,342 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
|
|
|
2080
2534
|
}
|
|
2081
2535
|
return findings;
|
|
2082
2536
|
}
|
|
2537
|
+
// ── Post data-type check ──────────────────────────────────────────────────────
|
|
2538
|
+
// Fires when a file renders post content but only reads the text field without
|
|
2539
|
+
// branching on the post's data type. getGlobalFeed / getPosts return every
|
|
2540
|
+
// post type (image, video, file, poll, liveStream…); text-only rendering leaves
|
|
2541
|
+
// all non-text posts silently blank.
|
|
2542
|
+
function validatePostDataTypeHandled(root, platform, sourceContent) {
|
|
2543
|
+
const findings = [];
|
|
2544
|
+
const ruleId = `${platform}.feed.post-datatype-handled`;
|
|
2545
|
+
const TEXT_ACCESS = {
|
|
2546
|
+
typescript: /post\s*\??\.\s*data\s*(?:as\s*\{|\.text\b|\?\.text\b)/,
|
|
2547
|
+
"react-native": /post\s*\??\.\s*data\s*(?:as\s*\{|\.text\b|\?\.text\b)/,
|
|
2548
|
+
flutter: /\.getData\s*<|\bAmityTextPost\b|\bAmityPostData\b/,
|
|
2549
|
+
ios: /\.getData\(\)\s*as\?.*AmityPost\.Data\.TEXT|post\s*\.\s*text\b/,
|
|
2550
|
+
// POST text only — a bare `.getText()` also matches a COMMENT renderer reading
|
|
2551
|
+
// AmityComment.Data.TEXT (comments are text by nature), which is not a post
|
|
2552
|
+
// renderer and must not trip this rule. Scope to AmityPost.Data.TEXT.
|
|
2553
|
+
android: /\.getData\(\)\s*as\??\s*AmityPost\.Data\.TEXT/,
|
|
2554
|
+
};
|
|
2555
|
+
const TYPE_GUARD = {
|
|
2556
|
+
typescript: /\bpost\s*\??\.\s*dataType\b|\bPostContentType\b|\bstructureType\b|\bdataType\s*===/,
|
|
2557
|
+
"react-native": /\bpost\s*\??\.\s*dataType\b|\bPostContentType\b|\bstructureType\b|\bdataType\s*===/,
|
|
2558
|
+
flutter: /post\s*\.\s*type\b|\bAmityDataType\b/,
|
|
2559
|
+
ios: /post\s*\.\s*dataType\b|\bAmityPostDataType\b|\bdataType\s*==/,
|
|
2560
|
+
android: /post\s*\.\s*getType\(\)|\bAmityPost\.Data\.IMAGE\b|\bAmityPost\.Data\.VIDEO\b|\bwhen\s*\{[\s\S]*AmityPost\.Data/,
|
|
2561
|
+
};
|
|
2562
|
+
const RENDER_CTX = /\bcommentsCount\b|\breactionsCount\b|\bpostId\b|\bAmityPost\b/;
|
|
2563
|
+
const textPat = TEXT_ACCESS[platform] ?? TEXT_ACCESS.typescript;
|
|
2564
|
+
const guardPat = TYPE_GUARD[platform] ?? TYPE_GUARD.typescript;
|
|
2565
|
+
for (const [file, content] of sourceContent) {
|
|
2566
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2567
|
+
continue;
|
|
2568
|
+
if (/\/\/\s*vise:\s*text-only post intentional/i.test(content))
|
|
2569
|
+
continue;
|
|
2570
|
+
if (!RENDER_CTX.test(content))
|
|
2571
|
+
continue;
|
|
2572
|
+
if (!textPat.test(content))
|
|
2573
|
+
continue;
|
|
2574
|
+
if (guardPat.test(content))
|
|
2575
|
+
continue;
|
|
2576
|
+
findings.push(finding(ruleId, "warning", "Post renderer reads text data without checking post.dataType. Non-text posts (image, video, file, poll) will render as blank.", relativeFile(root, file), "Branch on the post data type before accessing content. For TypeScript/React Native: switch on post.dataType (e.g. 'text', 'image', 'video'). For Android: when (post.getData()) { is AmityPost.Data.TEXT -> … is AmityPost.Data.IMAGE -> … }. For Flutter: check post.type == AmityDataType.TEXT. For iOS: check post.dataType. Add // vise: text-only post intentional if a single datatype is intentional."));
|
|
2577
|
+
break;
|
|
2578
|
+
}
|
|
2579
|
+
return findings;
|
|
2580
|
+
}
|
|
2581
|
+
// ── Avatar / display-identity from SDK ───────────────────────────────────────
|
|
2582
|
+
// Fires when a file renders community or user display identity using raw string
|
|
2583
|
+
// manipulation (charAt / slice on a name or raw userId) instead of the SDK-
|
|
2584
|
+
// provided avatar.fileUrl / avatarImage / getAvatar(). The SDK resolves the
|
|
2585
|
+
// avatar URL on the linked object; ignoring it shows a generated initial where
|
|
2586
|
+
// the user uploaded a real photo.
|
|
2587
|
+
function validateAvatarFromSdk(root, platform, sourceContent) {
|
|
2588
|
+
const findings = [];
|
|
2589
|
+
const ruleId = `${platform}.community.avatar-from-sdk`;
|
|
2590
|
+
const INITIAL_PAT = {
|
|
2591
|
+
// slice(0,1) / slice(0,2) are avatar initials; slice(0,8)+ is a userId/name
|
|
2592
|
+
// truncation used as a display fallback (e.g. userId.slice(0, 8)) and must
|
|
2593
|
+
// NOT be flagged as a missing avatar.
|
|
2594
|
+
typescript: /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
|
|
2595
|
+
"react-native": /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
|
|
2596
|
+
flutter: /(?:displayName|name)\s*\??\.(?:substring\s*\(\s*0\s*,\s*1|characters\.first)/,
|
|
2597
|
+
ios: /(?:displayName|name)\s*\??\s*\.?\s*prefix\s*\(\s*1\s*\)|(?:displayName|name)\s*\[.*\.startIndex/,
|
|
2598
|
+
android: /(?:displayName|name)\s*\??\.\s*(?:substring\s*\(\s*0\s*,\s*1|first\(\)|take\(1\))/,
|
|
2599
|
+
};
|
|
2600
|
+
const AVATAR_PAT = {
|
|
2601
|
+
typescript: /\bavatar\s*\??\.\s*fileUrl\b|\bavatarFileId\b|\bavatarUrl\b/,
|
|
2602
|
+
"react-native": /\bavatar\s*\??\.\s*fileUrl\b|\bavatarFileId\b|\bavatarUrl\b/,
|
|
2603
|
+
flutter: /\bavatarImage\b|\bavatarUrl\b|\bAmityImageSize\b/,
|
|
2604
|
+
ios: /\bavatar\s*\?|\bavatarFileId\b|\bgetAvatarInfo\b/,
|
|
2605
|
+
android: /\bgetAvatar\s*\(\)|\bavatarUrl\b|\bgetAvatarFileId\b/,
|
|
2606
|
+
};
|
|
2607
|
+
const CTX_PAT = /\bdisplayName\b|\bmembersCount\b|\bpostedUserId\b|\bAmityCommunity\b|\bAmityUser\b/;
|
|
2608
|
+
const initPat = INITIAL_PAT[platform] ?? INITIAL_PAT.typescript;
|
|
2609
|
+
const avatarPat = AVATAR_PAT[platform] ?? AVATAR_PAT.typescript;
|
|
2610
|
+
for (const [file, content] of sourceContent) {
|
|
2611
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2612
|
+
continue;
|
|
2613
|
+
if (/\/\/\s*vise:\s*avatar fallback intentional/i.test(content))
|
|
2614
|
+
continue;
|
|
2615
|
+
if (!CTX_PAT.test(content))
|
|
2616
|
+
continue;
|
|
2617
|
+
if (!initPat.test(content))
|
|
2618
|
+
continue;
|
|
2619
|
+
if (avatarPat.test(content))
|
|
2620
|
+
continue;
|
|
2621
|
+
findings.push(finding(ruleId, "warning", "Community or user display uses a string initial instead of the SDK-provided avatar URL.", relativeFile(root, file), "Check the avatar field before falling back to initials. For TypeScript/React Native: community.avatar?.fileUrl or user.avatar?.fileUrl. For Flutter: community.avatarImage?.getUrl(AmityImageSize.MEDIUM). For iOS: community.avatar?.fileURL. For Android: community.getAvatar()?.getUrl(). Add // vise: avatar fallback intentional if no avatar field is available in your SDK version."));
|
|
2622
|
+
break;
|
|
2623
|
+
}
|
|
2624
|
+
return findings;
|
|
2625
|
+
}
|
|
2626
|
+
function validatePollAnswerDataShape(root, platform, sourceContent) {
|
|
2627
|
+
const findings = [];
|
|
2628
|
+
// iOS has no poll-answer data-shape footgun: AmityPollAnswer exposes a typed
|
|
2629
|
+
// `public let text: String` (and `image: AmityImageData?`) — there is no
|
|
2630
|
+
// `.data` to mis-shape, and `answer.text` is the documented, correct accessor
|
|
2631
|
+
// (confirmed against the SDK source; two independent agent builds both wrote
|
|
2632
|
+
// it from the docs). The rule used to flag `answer.text` as wrong — a pure
|
|
2633
|
+
// false positive. The TS/RN concern (`answer.data` is a string, don't read
|
|
2634
|
+
// `.data.text`) doesn't translate to typed Swift. Rule de-registered for iOS.
|
|
2635
|
+
if (platform === "ios")
|
|
2636
|
+
return findings;
|
|
2637
|
+
const ruleId = `${platform}.feed.poll-answer-data-shape`;
|
|
2638
|
+
const WRONG_ACCESS = {
|
|
2639
|
+
typescript: /\banswer\s*\??\.\s*data\s*(?:as\s*\{|\?\.text\b|\.text\b)|\([^)]*answer[^)]*\bas\s*\{[^}]*text/,
|
|
2640
|
+
"react-native": /\banswer\s*\??\.\s*data\s*(?:as\s*\{|\?\.text\b|\.text\b)|\([^)]*answer[^)]*\bas\s*\{[^}]*text/,
|
|
2641
|
+
flutter: /answer\s*\.\s*getData\s*<[^>]*>\s*\(\)\s*\??\.\s*text\b|answer\s*\.\s*data\s*\??\.\s*text\b/,
|
|
2642
|
+
ios: /answer\s*\??\.\s*data\s*as\??\s*[A-Z]\w*\s*\)?\.?\s*text\b|answer\s*\??\.\s*text\b(?!\s*=)/,
|
|
2643
|
+
android: /answer\s*\.\s*getData\s*\(\)\s*\??\.\s*getText\(\)|answer\s*\??\.\s*getText\(\)/,
|
|
2644
|
+
};
|
|
2645
|
+
const CORRECT = {
|
|
2646
|
+
typescript: /\banswer\s*\.\s*data\b(?!\s*as\s*\{)(?!\s*\?\.text)(?!\s*\.text)(?!\s*\?\s*\.)/,
|
|
2647
|
+
"react-native": /\banswer\s*\.\s*data\b(?!\s*as\s*\{)(?!\s*\?\.text)(?!\s*\.text)(?!\s*\?\s*\.)/,
|
|
2648
|
+
flutter: /answer\s*\.\s*data\b(?!\s*\??\.\s*text)/,
|
|
2649
|
+
ios: /answer\s*\.\s*data\b(?!\s*as\?)|\banswer\.data\b/,
|
|
2650
|
+
android: /answer\s*\.\s*getData\s*\(\)\s*(?!\.)\b|answer\s*\.\s*data\b/,
|
|
2651
|
+
};
|
|
2652
|
+
const POLL_CTX = /\banswers\s*[\.\?][\.\?]?\s*map\b|\banswer\.id\b|\bpollId\b|\bPollAnswer\b|\bAmityPollAnswer\b|\bAmityPoll\b/;
|
|
2653
|
+
const wrongPat = WRONG_ACCESS[platform] ?? WRONG_ACCESS.typescript;
|
|
2654
|
+
const correctPat = CORRECT[platform] ?? CORRECT.typescript;
|
|
2655
|
+
for (const [file, content] of sourceContent) {
|
|
2656
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2657
|
+
continue;
|
|
2658
|
+
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
2659
|
+
if (/\/\/\s*vise:\s*poll-answer-shape intentional/i.test(content))
|
|
2660
|
+
continue;
|
|
2661
|
+
const astLang = astLanguageForFile(file, platform);
|
|
2662
|
+
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
2663
|
+
if (!POLL_CTX.test(checkContent))
|
|
2664
|
+
continue;
|
|
2665
|
+
if (!wrongPat.test(checkContent))
|
|
2666
|
+
continue;
|
|
2667
|
+
if (correctPat.test(checkContent))
|
|
2668
|
+
continue;
|
|
2669
|
+
findings.push(finding(ruleId, "warning", "Poll answer text is read as an object property (.data.text or .data?.text), but PollAnswer.data is a plain string.", relativeFile(root, file), "Use answer.data directly as the display text — it is already the string content. For image answers, use answer.image?.fileUrl (TypeScript/React Native) or the linked image object. Add // vise: poll-answer-shape intentional if the data field really is an object in your SDK version."));
|
|
2670
|
+
break;
|
|
2671
|
+
}
|
|
2672
|
+
return findings;
|
|
2673
|
+
}
|
|
2674
|
+
function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
2675
|
+
const findings = [];
|
|
2676
|
+
const ruleId = `${platform}.comments.query-has-limit`;
|
|
2677
|
+
const GETCOMMENTS_PAT = {
|
|
2678
|
+
typescript: /CommentRepository\s*\.\s*getComments\s*\(/,
|
|
2679
|
+
"react-native": /CommentRepository\s*\.\s*getComments\s*\(/,
|
|
2680
|
+
flutter: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
|
|
2681
|
+
ios: /AmityCommentQueryOptions\s*\(|commentRepository\s*\.\s*getComments\s*\(/,
|
|
2682
|
+
android: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
|
|
2683
|
+
};
|
|
2684
|
+
const HAS_LIMIT = {
|
|
2685
|
+
typescript: /\blimit\s*:\s*\d+|\bpageSize\s*:\s*\d+/,
|
|
2686
|
+
"react-native": /\blimit\s*:\s*\d+|\bpageSize\s*:\s*\d+/,
|
|
2687
|
+
flutter: /\.setLimit\s*\(|\blimit\s*:\s*\d+|\bpageSize\s*:\s*\d+/,
|
|
2688
|
+
ios: /\.pageSize\s*=\s*\d+|\bpageSize\s*:\s*\d+|setPageSize/,
|
|
2689
|
+
android: /\.pageSize\s*\(\s*\d+\)|\blimit\s*\(\s*\d+\)/,
|
|
2690
|
+
};
|
|
2691
|
+
const COMMENT_CTX = /referenceType\s*[:=]\s*['"]?post['"]?|\breferenceId\b|\bCommentRepository\b|\bAmityComment\b/;
|
|
2692
|
+
const recommendation = "Pass an explicit pageSize to the getComments params (limit is deprecated). For TypeScript/React Native: CommentRepository.getComments({ referenceId, referenceType: 'post', pageSize: 20 }, cb). For Flutter: .getLiveCollection(pageSize: 20). For iOS: AmityCommentQueryOptions(..., pageSize: 20). For Android: .pageSize(20). Add // vise: comment-limit handled if you have a deliberate reason to omit it.";
|
|
2693
|
+
const message = "Comment live collection called without an explicit limit/pageSize — the SDK default may use skip/limit pagination incompatible with scrollable query types, causing a runtime 500000 error.";
|
|
2694
|
+
// TypeScript / React Native: AST pass — inspect the actual getComments({...})
|
|
2695
|
+
// first argument and fire only when the object literal carries neither
|
|
2696
|
+
// pageSize nor limit. More precise than a text scan: ignores the token
|
|
2697
|
+
// appearing in comments/strings, and won't fire when params are passed as a
|
|
2698
|
+
// variable we can't statically read (conservative — avoids false positives).
|
|
2699
|
+
if (platform === "typescript" || platform === "react-native") {
|
|
2700
|
+
for (const [file, content] of sourceContent) {
|
|
2701
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2702
|
+
continue;
|
|
2703
|
+
if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
|
|
2704
|
+
continue; // escape on RAW content
|
|
2705
|
+
const astLang = astLanguageForFile(file, platform);
|
|
2706
|
+
if (!astLang)
|
|
2707
|
+
continue;
|
|
2708
|
+
let tree;
|
|
2709
|
+
try {
|
|
2710
|
+
tree = parse(astLang, content);
|
|
2711
|
+
}
|
|
2712
|
+
catch {
|
|
2713
|
+
continue;
|
|
2714
|
+
}
|
|
2715
|
+
const calls = findCallExpressions(tree, /(?:^|\.)getComments$/);
|
|
2716
|
+
for (const call of calls) {
|
|
2717
|
+
const objArg = call.args[0];
|
|
2718
|
+
if (!objArg || objArg.type !== "object")
|
|
2719
|
+
continue; // params not a literal → can't analyze, skip
|
|
2720
|
+
if (pickObjectProperty(objArg, "pageSize") || pickObjectProperty(objArg, "limit"))
|
|
2721
|
+
continue;
|
|
2722
|
+
findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
|
|
2723
|
+
return findings;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
return findings;
|
|
2727
|
+
}
|
|
2728
|
+
// Flutter / iOS / Android: regex pass (builder chains and option structs do
|
|
2729
|
+
// not fit call-argument inspection). Kotlin runs against comment-stripped
|
|
2730
|
+
// source; Dart/Swift have no grammar and run against raw content.
|
|
2731
|
+
const getCommentsPat = GETCOMMENTS_PAT[platform] ?? GETCOMMENTS_PAT.typescript;
|
|
2732
|
+
const limitPat = HAS_LIMIT[platform] ?? HAS_LIMIT.typescript;
|
|
2733
|
+
for (const [file, content] of sourceContent) {
|
|
2734
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2735
|
+
continue;
|
|
2736
|
+
if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
|
|
2737
|
+
continue; // escape on RAW content
|
|
2738
|
+
const astLang = astLanguageForFile(file, platform);
|
|
2739
|
+
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
2740
|
+
if (!COMMENT_CTX.test(checkContent))
|
|
2741
|
+
continue;
|
|
2742
|
+
if (!getCommentsPat.test(checkContent))
|
|
2743
|
+
continue;
|
|
2744
|
+
if (limitPat.test(checkContent))
|
|
2745
|
+
continue;
|
|
2746
|
+
findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2749
|
+
return findings;
|
|
2750
|
+
}
|
|
2751
|
+
// ── Comment creation affordance ──────────────────────────────────────────────
|
|
2752
|
+
// Fires when comments are READ (getComments) but never CREATED (createComment)
|
|
2753
|
+
// anywhere in the codebase. A read-only comment list gives users no way to
|
|
2754
|
+
// participate. Mirrors the moderation-affordance-present precedent: the write
|
|
2755
|
+
// path may live in a sibling file, so detection is codebase-wide and an
|
|
2756
|
+
// attestation escape covers deliberately read-only surfaces.
|
|
2757
|
+
function validateCommentCreationAffordance(root, platform, sourceContent) {
|
|
2758
|
+
const findings = [];
|
|
2759
|
+
const ruleId = `${platform}.comments.creation-affordance-present`;
|
|
2760
|
+
const GETCOMMENTS_PAT = {
|
|
2761
|
+
typescript: /CommentRepository\s*\.\s*getComments\s*\(/,
|
|
2762
|
+
"react-native": /CommentRepository\s*\.\s*getComments\s*\(/,
|
|
2763
|
+
flutter: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
|
|
2764
|
+
ios: /AmityCommentQueryOptions\s*\(|commentRepository\s*\.\s*getComments\s*\(/,
|
|
2765
|
+
android: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
|
|
2766
|
+
};
|
|
2767
|
+
const CREATECOMMENT_PAT = {
|
|
2768
|
+
typescript: /CommentRepository\s*\.\s*createComment\s*\(/,
|
|
2769
|
+
"react-native": /CommentRepository\s*\.\s*createComment\s*\(/,
|
|
2770
|
+
flutter: /\.createComment\s*\(/,
|
|
2771
|
+
// Any receiver, not just a var literally named `commentRepository` — agents
|
|
2772
|
+
// idiomatically hold the repo in `repo`/`vm`/etc. (real v8: AmityCommentRepository
|
|
2773
|
+
// .createComment(with: AmityCommentCreateOptions(...))). Mirrors android/flutter.
|
|
2774
|
+
ios: /AmityCommentCreateOptions\s*\(|\.createComment\s*\(/,
|
|
2775
|
+
android: /\.createComment\s*\(/,
|
|
2776
|
+
};
|
|
2777
|
+
const getPat = GETCOMMENTS_PAT[platform] ?? GETCOMMENTS_PAT.typescript;
|
|
2778
|
+
const createPat = CREATECOMMENT_PAT[platform] ?? CREATECOMMENT_PAT.typescript;
|
|
2779
|
+
let readFile;
|
|
2780
|
+
let hasCreate = false;
|
|
2781
|
+
for (const [file, content] of sourceContent) {
|
|
2782
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2783
|
+
continue;
|
|
2784
|
+
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
2785
|
+
if (/\/\/\s*vise:\s*comments read-only intentional/i.test(content))
|
|
2786
|
+
return findings;
|
|
2787
|
+
const astLang = astLanguageForFile(file, platform);
|
|
2788
|
+
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
2789
|
+
if (!readFile && getPat.test(checkContent))
|
|
2790
|
+
readFile = file;
|
|
2791
|
+
if (createPat.test(checkContent))
|
|
2792
|
+
hasCreate = true;
|
|
2793
|
+
}
|
|
2794
|
+
if (readFile && !hasCreate) {
|
|
2795
|
+
findings.push(finding(ruleId, "warning", "Comments are read (getComments) but no comment creation (createComment) was found anywhere in the project. A read-only comment list gives users no way to participate.", relativeFile(root, readFile), "Add a comment composer that calls createComment. For TypeScript/React Native: CommentRepository.createComment({ data: { text }, referenceId, referenceType: 'post' }). For iOS: commentRepository.createComment(with: AmityCommentCreateOptions(...)). For Flutter/Android: AmitySocialClient.newCommentRepository().createComment().post(postId)...send(). Gate it behind the user's ban/permission state. Add // vise: comments read-only intentional if a read-only view is deliberate."));
|
|
2796
|
+
}
|
|
2797
|
+
return findings;
|
|
2798
|
+
}
|
|
2799
|
+
function validateCommunityDisplayNameFromSdk(root, platform, sourceContent) {
|
|
2800
|
+
const findings = [];
|
|
2801
|
+
const ruleId = `${platform}.community.display-name-from-sdk`;
|
|
2802
|
+
const ID_AS_NAME = {
|
|
2803
|
+
typescript: /displayName\s*:\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*\?\?\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*:\s*selected\w*[Ii]d\b|\bdisplayName\s*:\s*\w+[Cc]ommunity[Ii]d\b/,
|
|
2804
|
+
"react-native": /displayName\s*:\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*\?\?\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*:\s*selected\w*[Ii]d\b|\bdisplayName\s*:\s*\w+[Cc]ommunity[Ii]d\b/,
|
|
2805
|
+
flutter: /displayName\s*:\s*\w*[Cc]ommunity[Ii]d\b|displayName\s*\?\?\s*\w*[Cc]ommunityId\b|Text\s*\(\s*\w*[Cc]ommunity\w*\.communityId\b/,
|
|
2806
|
+
ios: /displayName\s*=\s*\w*[Cc]ommunity[Ii]d\b|displayName\s*\?\?\s*\w*communityId\b|\.displayName\s*\?\s*\?\s*community\.communityId\b/,
|
|
2807
|
+
android: /displayName\s*=\s*\w*[Cc]ommunity[Ii]d\b|displayName\s*\?:\s*\w*communityId\b|setText\s*\(\s*\w*communityId\b/,
|
|
2808
|
+
};
|
|
2809
|
+
const COMMUNITY_CTX = /\bcommunityId\b.*\b(Community|AmityCommunity|displayName)\b|\b(Community|AmityCommunity|displayName)\b.*\bcommunityId\b/s;
|
|
2810
|
+
const CORRECT = /CommunityRepository\s*\.\s*getCommunity\b|community\s*\??\.\s*displayName\s*(?!:)\b/;
|
|
2811
|
+
const idPat = ID_AS_NAME[platform] ?? ID_AS_NAME.typescript;
|
|
2812
|
+
for (const [file, content] of sourceContent) {
|
|
2813
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2814
|
+
continue;
|
|
2815
|
+
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
2816
|
+
if (/\/\/\s*vise:\s*community-id display intentional/i.test(content))
|
|
2817
|
+
continue;
|
|
2818
|
+
const astLang = astLanguageForFile(file, platform);
|
|
2819
|
+
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
2820
|
+
if (!COMMUNITY_CTX.test(checkContent))
|
|
2821
|
+
continue;
|
|
2822
|
+
if (!idPat.test(checkContent))
|
|
2823
|
+
continue;
|
|
2824
|
+
if (CORRECT.test(checkContent))
|
|
2825
|
+
continue;
|
|
2826
|
+
findings.push(finding(ruleId, "warning", "A community ID is used as a display name fallback. The raw communityId will be shown to users instead of the community's actual display name.", relativeFile(root, file), "Pass the full Community object (not just the ID) when navigating to a community detail screen, then use community.displayName. If you only have the ID, subscribe to CommunityRepository.getCommunity(communityId, cb) to get the live display name. Add // vise: community-id display intentional only if showing the ID is truly required."));
|
|
2827
|
+
break;
|
|
2828
|
+
}
|
|
2829
|
+
return findings;
|
|
2830
|
+
}
|
|
2831
|
+
function validateRoomPostFetched(root, platform, sourceContent) {
|
|
2832
|
+
const findings = [];
|
|
2833
|
+
const ruleId = `${platform}.feed.room-post-fetched`;
|
|
2834
|
+
const ROOM_ID_DISPLAY = {
|
|
2835
|
+
typescript: /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
|
|
2836
|
+
"react-native": /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
|
|
2837
|
+
flutter: /room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
|
|
2838
|
+
// Negative lookbehind for Swift string interpolation `\(room.roomId)`:
|
|
2839
|
+
// that's a segment/identifier key, not a displayed name, and must not fire.
|
|
2840
|
+
// Real display (`Text(room.roomId)`, `label.text = room.roomId`) still matches.
|
|
2841
|
+
ios: /(?<!\\\()room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\?\.?\s*roomId/,
|
|
2842
|
+
android: /room\s*\??\.\s*getRoomId\s*\(\)|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
|
|
2843
|
+
};
|
|
2844
|
+
const ROOM_FETCH = {
|
|
2845
|
+
typescript: /RoomRepository\s*\.\s*getRoom\s*\(|room\s*\??\.\s*title\b/,
|
|
2846
|
+
"react-native": /RoomRepository\s*\.\s*getRoom\s*\(|room\s*\??\.\s*title\b/,
|
|
2847
|
+
flutter: /AmityRoomRepository\s*\(\s*\)\s*\.\s*getRoom\b|room\s*\??\.\s*title\b/,
|
|
2848
|
+
ios: /AmityRoomRepository\s*\.\s*shared\s*\.\s*getRoom\b|room\s*\??\.\s*title\b/,
|
|
2849
|
+
android: /AmityRoomRepository\s*\(\s*\)\s*\.\s*getRoom\b|room\s*\??\.\s*title\b/,
|
|
2850
|
+
};
|
|
2851
|
+
const POST_CTX = /\bpostId\b|\bAmityPost\b|\bpost\s*\??\.\s*dataType\b|\bpost\.type\b|\bpost_id\b/;
|
|
2852
|
+
const roomIdPat = ROOM_ID_DISPLAY[platform] ?? ROOM_ID_DISPLAY.typescript;
|
|
2853
|
+
const fetchPat = ROOM_FETCH[platform] ?? ROOM_FETCH.typescript;
|
|
2854
|
+
for (const [file, content] of sourceContent) {
|
|
2855
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
2856
|
+
continue;
|
|
2857
|
+
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
2858
|
+
if (/\/\/\s*vise:\s*room-display intentional/i.test(content))
|
|
2859
|
+
continue;
|
|
2860
|
+
const astLang = astLanguageForFile(file, platform);
|
|
2861
|
+
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
2862
|
+
if (!POST_CTX.test(checkContent))
|
|
2863
|
+
continue;
|
|
2864
|
+
if (!roomIdPat.test(checkContent))
|
|
2865
|
+
continue;
|
|
2866
|
+
if (fetchPat.test(checkContent))
|
|
2867
|
+
continue;
|
|
2868
|
+
findings.push(finding(ruleId, "warning", "Room post displays raw roomId instead of fetching room details (title) via RoomRepository.getRoom.", relativeFile(root, file), "Subscribe to RoomRepository.getRoom(roomId, callback) to get the Room object, then use room.title as the display name. The Room has title, status (idle/live/ended), and thumbnailFileId. Add // vise: room-display intentional only if showing the raw ID is intentional."));
|
|
2869
|
+
break;
|
|
2870
|
+
}
|
|
2871
|
+
return findings;
|
|
2872
|
+
}
|
|
2083
2873
|
function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
|
|
2084
2874
|
const findings = [];
|
|
2085
2875
|
const ruleId = `${platform}.feed.target-type-explicit`;
|
|
@@ -2095,7 +2885,22 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
|
|
|
2095
2885
|
if (!hasCreatePost)
|
|
2096
2886
|
continue;
|
|
2097
2887
|
for (const pattern of TARGET_TYPE_PATTERNS) {
|
|
2098
|
-
|
|
2888
|
+
const g = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
|
|
2889
|
+
let ttm;
|
|
2890
|
+
let flagged = false;
|
|
2891
|
+
while ((ttm = g.exec(text)) !== null) {
|
|
2892
|
+
// A literal targetType in a READ query (getPosts/getCommunityFeed params) is correct —
|
|
2893
|
+
// you specify targetType:'community' + targetId to read that feed. Read-query objects
|
|
2894
|
+
// carry feedType/sortBy keys; a createPost payload carries dataType/data. Only the
|
|
2895
|
+
// latter is the reusability concern this rule targets.
|
|
2896
|
+
const window = text.slice(Math.max(0, ttm.index - 180), ttm.index + 180);
|
|
2897
|
+
const isReadQuery = /\b(?:feedType|sortBy)\s*:/.test(window) && !/\bdataType\s*:/.test(window);
|
|
2898
|
+
if (!isReadQuery) {
|
|
2899
|
+
flagged = true;
|
|
2900
|
+
break;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (flagged) {
|
|
2099
2904
|
findings.push(finding(ruleId, "warning", `A createPost call appears to hardcode targetType to a literal community or user.`, rel, "Feed targets should be passed dynamically (e.g. from props or intent extras) so the composer component is reusable. If this is intentional, add comment // vise: target-type rationale — <reason>."));
|
|
2100
2905
|
break;
|
|
2101
2906
|
}
|