@amityco/social-plus-vise 1.3.0 → 1.4.1
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 +51 -2
- package/README.md +36 -19
- package/dist/capabilities.js +29 -1
- package/dist/entryState.js +71 -0
- package/dist/experience.js +70 -0
- package/dist/explore.js +1 -1
- package/dist/flow.js +382 -0
- package/dist/humanFormat.js +25 -0
- package/dist/intake.js +117 -0
- package/dist/intelligence/placement.js +2 -1
- package/dist/outcomes.js +126 -28
- package/dist/productExpectations.js +15 -0
- package/dist/requestReadiness.js +99 -0
- package/dist/server.js +566 -19
- package/dist/sidecar.js +5 -0
- package/dist/solutionPath.js +1 -1
- package/dist/tools/compliance.js +752 -125
- package/dist/tools/creative.js +14 -12
- package/dist/tools/design.js +321 -11
- 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 +963 -66
- package/dist/tools/smoke.js +134 -0
- package/dist/tools/uxHarness.js +54 -6
- package/dist/uikitCustomization.js +3 -1
- 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*\(/]);
|
|
@@ -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 uses an uncontrolled version (
|
|
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."));
|
|
331
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
|
+
}
|
|
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."));
|
|
428
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."));
|
|
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,7 +1413,7 @@ 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
1419
|
const isObserverContent = (c) => {
|
|
@@ -1067,7 +1543,7 @@ function validateChat(root, platform, sourceContent) {
|
|
|
1067
1543
|
const hasConversationType = /createChannel[\s\S]{0,400}type\s*:\s*['"]conversation['"]/.test(content);
|
|
1068
1544
|
if ((platform === "react-native" || platform === "typescript") && hasCommunityType && hasUserIds && !hasConversationType) {
|
|
1069
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."));
|
|
1070
|
-
|
|
1546
|
+
continue;
|
|
1071
1547
|
}
|
|
1072
1548
|
}
|
|
1073
1549
|
}
|
|
@@ -1126,7 +1602,7 @@ function validateChatDeprecations(root, platform, sourceContent) {
|
|
|
1126
1602
|
if (DEPRECATED_MARKER_SYNC_ESCAPE.test(raw))
|
|
1127
1603
|
continue;
|
|
1128
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."));
|
|
1129
|
-
|
|
1605
|
+
continue;
|
|
1130
1606
|
}
|
|
1131
1607
|
}
|
|
1132
1608
|
}
|
|
@@ -1136,7 +1612,7 @@ function validateChatDeprecations(root, platform, sourceContent) {
|
|
|
1136
1612
|
if (DEPRECATED_SUBCHANNEL_ESCAPE.test(raw))
|
|
1137
1613
|
continue;
|
|
1138
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."));
|
|
1139
|
-
|
|
1615
|
+
continue;
|
|
1140
1616
|
}
|
|
1141
1617
|
}
|
|
1142
1618
|
return findings;
|
|
@@ -1160,7 +1636,7 @@ function validateLivestreamDeprecation(root, platform, sourceContent) {
|
|
|
1160
1636
|
if (DEPRECATED_LEGACY_STREAM_ESCAPE.test(raw))
|
|
1161
1637
|
continue;
|
|
1162
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."));
|
|
1163
|
-
|
|
1639
|
+
continue;
|
|
1164
1640
|
}
|
|
1165
1641
|
}
|
|
1166
1642
|
return findings;
|
|
@@ -1542,7 +2018,7 @@ function validateFollowStatusSubscription(root, platform, sourceContent) {
|
|
|
1542
2018
|
/\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
|
|
1543
2019
|
continue;
|
|
1544
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(); };"));
|
|
1545
|
-
|
|
2021
|
+
continue;
|
|
1546
2022
|
}
|
|
1547
2023
|
return findings;
|
|
1548
2024
|
}
|
|
@@ -1945,6 +2421,7 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
|
|
|
1945
2421
|
const handlerFiles = filesMatching(sourceContent, presenceMarkers);
|
|
1946
2422
|
if (handlerFiles.length === 0)
|
|
1947
2423
|
return [];
|
|
2424
|
+
const findings = [];
|
|
1948
2425
|
const badPattern = SESSION_HANDLER_BAD_PATTERNS[platform];
|
|
1949
2426
|
const goodPatterns = SESSION_HANDLER_GOOD_PATTERNS[platform] ?? SESSION_HANDLER_GOOD_PATTERNS.typescript;
|
|
1950
2427
|
for (const file of handlerFiles) {
|
|
@@ -1954,7 +2431,7 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
|
|
|
1954
2431
|
if (tree) {
|
|
1955
2432
|
const locals = findSwiftFunctionLocalDeclarations(tree, /^\w*SessionHandler\b/);
|
|
1956
2433
|
if (locals.length > 0 && !containsAny(new Map([[file, content]]), goodPatterns)) {
|
|
1957
|
-
|
|
2434
|
+
findings.push(sessionHandlerRetainedFinding(platform, relativeFile(root, file)));
|
|
1958
2435
|
}
|
|
1959
2436
|
continue;
|
|
1960
2437
|
}
|
|
@@ -1966,10 +2443,10 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
|
|
|
1966
2443
|
if (hasGood) {
|
|
1967
2444
|
continue;
|
|
1968
2445
|
}
|
|
1969
|
-
|
|
2446
|
+
findings.push(sessionHandlerRetainedFinding(platform, relativeFile(root, file)));
|
|
1970
2447
|
}
|
|
1971
2448
|
}
|
|
1972
|
-
return
|
|
2449
|
+
return findings;
|
|
1973
2450
|
}
|
|
1974
2451
|
function androidSessionHandlerRetained(file, content) {
|
|
1975
2452
|
if (/\/\/\s*vise:\s*handler retained/.test(content))
|
|
@@ -2017,9 +2494,16 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
2017
2494
|
if (inlineApiKey && !isAllowedPlaceholder(inlineApiKey.value)) {
|
|
2018
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)."));
|
|
2019
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
|
+
}
|
|
2020
2503
|
const literalUserId = firstLiteralAssignment(sourceContent, [
|
|
2021
2504
|
/\buserId\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
2022
2505
|
/\buser_id\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
2506
|
+
/\b(?:currentUser|current_user|userIdentifier)\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
2023
2507
|
/\.login\s*\(\s*["'`]([^"'`]+)["'`]/i,
|
|
2024
2508
|
/\.login\s*\(\s*userId\s*:\s*["'`]([^"'`]+)["'`]/i,
|
|
2025
2509
|
], platform);
|
|
@@ -2265,6 +2749,206 @@ function isFloatingPackageDependency(packageJson, dependencyName) {
|
|
|
2265
2749
|
}
|
|
2266
2750
|
return /^(?:latest|\*|x|X)$/i.test(version.trim());
|
|
2267
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
|
+
}
|
|
2268
2952
|
function usesEnvSecretConfig(sourceContent) {
|
|
2269
2953
|
return containsAny(sourceContent, [
|
|
2270
2954
|
/import\.meta\.env\.[A-Z0-9_]*(?:AMITY|SOCIAL_PLUS|SOCIALPLUS)[A-Z0-9_]*(?:API[_-]?KEY|KEY)/i,
|
|
@@ -2302,29 +2986,122 @@ function gitignoreIgnoresEnvFiles(gitignore) {
|
|
|
2302
2986
|
.map((line) => line.trim())
|
|
2303
2987
|
.some((line) => line === ".env" || line === ".env*" || line === ".env.local" || line === "*.env" || line === ".env.*");
|
|
2304
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
|
+
}
|
|
2305
3049
|
async function validateIos(root) {
|
|
2306
3050
|
const findings = [];
|
|
2307
|
-
const
|
|
2308
|
-
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;
|
|
2309
3074
|
const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400);
|
|
2310
3075
|
const swiftContent = await readMany(swiftFiles);
|
|
2311
|
-
const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
|
|
2312
|
-
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])];
|
|
2313
3080
|
const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/]);
|
|
2314
3081
|
const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
|
|
2315
3082
|
const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
|
|
2316
|
-
if (
|
|
2317
|
-
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."));
|
|
2318
3085
|
}
|
|
2319
|
-
else if (
|
|
2320
|
-
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."));
|
|
2321
3088
|
}
|
|
2322
|
-
else if (
|
|
3089
|
+
else if (/Amity-Social-Cloud-SDK-iOS-IPA/i.test(manifestCombined)) {
|
|
2323
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."));
|
|
2324
3091
|
}
|
|
2325
|
-
else if (
|
|
3092
|
+
else if (iosAmitySdkIsFloating({ pod: podText, pkg: pkgSwiftText, yml: projectYmlText, pbx: pbxprojText })) {
|
|
2326
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."));
|
|
2327
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."));
|
|
3104
|
+
}
|
|
2328
3105
|
if (setupFiles.length === 0) {
|
|
2329
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."));
|
|
2330
3107
|
}
|
|
@@ -2340,8 +3117,8 @@ async function validateIos(root) {
|
|
|
2340
3117
|
if (loginFiles.length === 0) {
|
|
2341
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."));
|
|
2342
3119
|
}
|
|
2343
|
-
else if (
|
|
2344
|
-
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."));
|
|
2345
3122
|
}
|
|
2346
3123
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
2347
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."));
|
|
@@ -2353,8 +3130,20 @@ async function validateIos(root) {
|
|
|
2353
3130
|
if (iosPayloadHandlerFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
2354
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."));
|
|
2355
3132
|
}
|
|
2356
|
-
|
|
2357
|
-
|
|
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."));
|
|
2358
3147
|
}
|
|
2359
3148
|
findings.push(...validateLiteralGuardrails(root, "ios", swiftContent));
|
|
2360
3149
|
findings.push(...validateLoggingHygiene(root, "ios", swiftContent));
|
|
@@ -2377,6 +3166,7 @@ async function validateIos(root) {
|
|
|
2377
3166
|
findings.push(...validatePollVoteStatusGuard(root, "ios", swiftContent));
|
|
2378
3167
|
findings.push(...validateFollowStatusSubscription(root, "ios", swiftContent));
|
|
2379
3168
|
findings.push(...validatePostDataTypeHandled(root, "ios", swiftContent));
|
|
3169
|
+
findings.push(...validatePostBodyRendered(root, "ios", swiftContent));
|
|
2380
3170
|
findings.push(...validateAvatarFromSdk(root, "ios", swiftContent));
|
|
2381
3171
|
findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
|
|
2382
3172
|
findings.push(...validateCommentsQueryHasLimit(root, "ios", swiftContent));
|
|
@@ -2881,8 +3671,14 @@ function validateImagePostChildResolutionAwaited(root, platform, sourceContent)
|
|
|
2881
3671
|
const ruleId = platform + '.image-post.child-resolution-awaited';
|
|
2882
3672
|
for (const [filename, text] of sourceContent) {
|
|
2883
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);
|
|
2884
3679
|
if (/createPost\s*\(/.test(text) &&
|
|
2885
|
-
/(?:render|setState|display)\s*\(/.test(text)
|
|
3680
|
+
/(?:render|setState|display)\s*\(/.test(text) &&
|
|
3681
|
+
hasImageAttachment) {
|
|
2886
3682
|
const hasResolution = /whenComplete/.test(text) ||
|
|
2887
3683
|
/whenReady/.test(text) ||
|
|
2888
3684
|
/childrenPosts.*await/.test(text) ||
|
|
@@ -3165,7 +3961,51 @@ function validatePostDataTypeHandled(root, platform, sourceContent) {
|
|
|
3165
3961
|
if (guardPat.test(checkContent))
|
|
3166
3962
|
continue;
|
|
3167
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."));
|
|
3168
|
-
|
|
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."));
|
|
3169
4009
|
}
|
|
3170
4010
|
return findings;
|
|
3171
4011
|
}
|
|
@@ -3250,7 +4090,64 @@ function validateAvatarFromSdk(root, platform, sourceContent) {
|
|
|
3250
4090
|
if (avatarPat.test(checkContent))
|
|
3251
4091
|
continue;
|
|
3252
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."));
|
|
3253
|
-
|
|
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
|
+
}
|
|
3254
4151
|
}
|
|
3255
4152
|
return findings;
|
|
3256
4153
|
}
|
|
@@ -3290,7 +4187,7 @@ function validatePollAnswerDataShape(root, platform, sourceContent) {
|
|
|
3290
4187
|
if (correctPat.test(checkContent))
|
|
3291
4188
|
continue;
|
|
3292
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."));
|
|
3293
|
-
|
|
4190
|
+
continue;
|
|
3294
4191
|
}
|
|
3295
4192
|
return findings;
|
|
3296
4193
|
}
|
|
@@ -3338,7 +4235,7 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
|
3338
4235
|
if (pickObjectProperty(objArg, "pageSize") || pickObjectProperty(objArg, "limit"))
|
|
3339
4236
|
continue;
|
|
3340
4237
|
findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
|
|
3341
|
-
|
|
4238
|
+
break;
|
|
3342
4239
|
}
|
|
3343
4240
|
}
|
|
3344
4241
|
return findings;
|
|
@@ -3359,7 +4256,7 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
|
3359
4256
|
if (limitPat.test(checkContent))
|
|
3360
4257
|
continue;
|
|
3361
4258
|
findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
|
|
3362
|
-
|
|
4259
|
+
continue;
|
|
3363
4260
|
}
|
|
3364
4261
|
return findings;
|
|
3365
4262
|
}
|
|
@@ -3432,7 +4329,7 @@ function validateCommentThreadUiStates(root, platform, sourceContent) {
|
|
|
3432
4329
|
const hasEmpty = /itemCount\s*==\s*0|itemCount\s*<=\s*0|No comments|EmptyState|emptyState|\bisEmpty\b/.test(checkContent);
|
|
3433
4330
|
if (!(hasLoading && hasError && hasEmpty)) {
|
|
3434
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."));
|
|
3435
|
-
|
|
4332
|
+
continue;
|
|
3436
4333
|
}
|
|
3437
4334
|
}
|
|
3438
4335
|
return findings;
|
|
@@ -3511,7 +4408,7 @@ function validateChatMessageOrderExplicit(root, platform, sourceContent) {
|
|
|
3511
4408
|
if (sortPat.test(checkContent))
|
|
3512
4409
|
continue;
|
|
3513
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."));
|
|
3514
|
-
|
|
4411
|
+
continue;
|
|
3515
4412
|
}
|
|
3516
4413
|
return findings;
|
|
3517
4414
|
}
|
|
@@ -3546,7 +4443,7 @@ function validateProfileSocialCounts(root, platform, sourceContent) {
|
|
|
3546
4443
|
const hasPlaceholderCounts = placeholderPat.test(checkContent);
|
|
3547
4444
|
if (hasPlaceholderCounts && !hasSdkCounts) {
|
|
3548
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."));
|
|
3549
|
-
|
|
4446
|
+
continue;
|
|
3550
4447
|
}
|
|
3551
4448
|
}
|
|
3552
4449
|
return findings;
|
|
@@ -3578,7 +4475,7 @@ function validateCommunityDisplayNameFromSdk(root, platform, sourceContent) {
|
|
|
3578
4475
|
if (CORRECT.test(checkContent))
|
|
3579
4476
|
continue;
|
|
3580
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."));
|
|
3581
|
-
|
|
4478
|
+
continue;
|
|
3582
4479
|
}
|
|
3583
4480
|
return findings;
|
|
3584
4481
|
}
|
|
@@ -3616,7 +4513,7 @@ function validateRoomPostFetched(root, platform, sourceContent) {
|
|
|
3616
4513
|
if (fetchPat.test(checkContent))
|
|
3617
4514
|
continue;
|
|
3618
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."));
|
|
3619
|
-
|
|
4516
|
+
continue;
|
|
3620
4517
|
}
|
|
3621
4518
|
return findings;
|
|
3622
4519
|
}
|