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