@amityco/social-plus-vise 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,908 @@
1
+ import { access, readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
4
+ async function exists(filePath) {
5
+ try {
6
+ await access(filePath);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ async function readIfExists(filePath) {
14
+ try {
15
+ return await readFile(filePath, "utf8");
16
+ }
17
+ catch {
18
+ return "";
19
+ }
20
+ }
21
+ export async function inspectProject(repoPath, surfacePath) {
22
+ const repoRoot = path.resolve(repoPath);
23
+ const surfaces = await detectProjectSurfaces(repoRoot);
24
+ const effectiveRoot = resolveSurfaceRoot(repoRoot, surfacePath);
25
+ const selectedSurface = surfacePath ? await surfaceForPath(repoRoot, effectiveRoot) : surfaceMatchingRoot(surfaces, repoRoot);
26
+ const { platforms, signals, designSignals } = await inspectRoot(effectiveRoot);
27
+ return {
28
+ repoRoot,
29
+ effectiveRoot,
30
+ selectedSurface,
31
+ platforms,
32
+ signals,
33
+ designSignals,
34
+ surfaces,
35
+ };
36
+ }
37
+ function resolveSurfaceRoot(repoRoot, surfacePath) {
38
+ if (!surfacePath) {
39
+ return repoRoot;
40
+ }
41
+ const resolved = path.resolve(repoRoot, surfacePath);
42
+ const relative = path.relative(repoRoot, resolved);
43
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
44
+ throw new Error("surfacePath must stay inside repoPath.");
45
+ }
46
+ return resolved;
47
+ }
48
+ async function inspectRoot(root) {
49
+ const signals = [];
50
+ const candidates = [
51
+ ["android", "settings.gradle", "Gradle project settings"],
52
+ ["android", "settings.gradle.kts", "Gradle Kotlin project settings"],
53
+ ["android", "app/build.gradle", "Android app Gradle file"],
54
+ ["android", "app/build.gradle.kts", "Android app Gradle Kotlin file"],
55
+ ["flutter", "pubspec.yaml", "Flutter/Dart package manifest"],
56
+ ["ios", "Podfile", "CocoaPods project manifest"],
57
+ ["ios", "Package.swift", "Swift package manifest"],
58
+ ["typescript", "package.json", "JavaScript or TypeScript package manifest"],
59
+ ["typescript", "tsconfig.json", "TypeScript project config"],
60
+ ];
61
+ for (const [platform, relativeFile, reason] of candidates) {
62
+ if (await exists(path.join(root, relativeFile))) {
63
+ signals.push({ platform, file: relativeFile, reason });
64
+ }
65
+ }
66
+ const packageJson = await readIfExists(path.join(root, "package.json"));
67
+ if (packageJson.includes("react-native")) {
68
+ signals.push({
69
+ platform: "react-native",
70
+ file: "package.json",
71
+ reason: "react-native dependency or script signal",
72
+ });
73
+ }
74
+ const platforms = Array.from(new Set(signals.map((signal) => signal.platform)));
75
+ return { platforms, signals, designSignals: await detectDesignSignals(root) };
76
+ }
77
+ export const inspectProjectTool = {
78
+ name: "inspect_project",
79
+ description: "Inspect a customer repository and detect likely platform/framework signals.",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: {
83
+ repoPath: {
84
+ type: "string",
85
+ description: "Absolute or relative path to the customer repository root.",
86
+ },
87
+ surfacePath: {
88
+ type: "string",
89
+ description: "Optional app/workspace path inside repoPath, such as apps/web or apps/mobile.",
90
+ },
91
+ },
92
+ required: ["repoPath"],
93
+ additionalProperties: false,
94
+ },
95
+ async call(input) {
96
+ const args = objectInput(input);
97
+ return textResult(await inspectProject(stringField(args, "repoPath"), optionalStringField(args, "surfacePath")));
98
+ },
99
+ };
100
+ export const validateSetupTool = {
101
+ name: "validate_setup",
102
+ description: "Check common social.plus setup risks for a detected platform.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ repoPath: { type: "string" },
107
+ platform: {
108
+ type: "string",
109
+ description: "Optional platform override. If omitted, Vise uses project inspection.",
110
+ },
111
+ surfacePath: {
112
+ type: "string",
113
+ description: "Optional app/workspace path inside repoPath, such as apps/web or apps/mobile.",
114
+ },
115
+ },
116
+ required: ["repoPath"],
117
+ additionalProperties: false,
118
+ },
119
+ async call(input) {
120
+ const args = objectInput(input);
121
+ const repoPath = stringField(args, "repoPath");
122
+ const inspection = await inspectProject(repoPath, optionalStringField(args, "surfacePath"));
123
+ const platform = optionalStringField(args, "platform") ?? inspection.platforms[0] ?? "unknown";
124
+ const findings = await validateSetup(inspection.effectiveRoot, platform);
125
+ return textResult({
126
+ platform,
127
+ surfacePath: inspection.selectedSurface?.path,
128
+ status: statusForFindings(findings),
129
+ findings,
130
+ });
131
+ },
132
+ };
133
+ export async function validateSetup(repoPath, platform) {
134
+ const root = path.resolve(repoPath);
135
+ const findings = [];
136
+ if (platform === "android") {
137
+ findings.push(...(await validateAndroid(root)));
138
+ }
139
+ if (platform === "flutter") {
140
+ findings.push(...(await validateFlutter(root)));
141
+ }
142
+ if (platform === "typescript" || platform === "react-native") {
143
+ findings.push(...(await validateTypeScript(root, platform)));
144
+ }
145
+ if (platform === "ios") {
146
+ findings.push(...(await validateIos(root)));
147
+ }
148
+ if (platform === "unknown") {
149
+ findings.push({
150
+ ruleId: "project.platform.detected",
151
+ severity: "warning",
152
+ message: "Could not detect a supported project platform.",
153
+ recommendation: "Point repoPath at the app root or provide a platform override.",
154
+ });
155
+ }
156
+ return findings;
157
+ }
158
+ async function validateAndroid(root) {
159
+ const findings = [];
160
+ const manifestPath = "app/src/main/AndroidManifest.xml";
161
+ const manifest = await readIfExists(path.join(root, manifestPath));
162
+ const buildFiles = await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]);
163
+ const buildContent = await readMany(buildFiles);
164
+ const sourceFiles = await findFiles(root, [".kt", ".java"], 300);
165
+ const sourceContent = await readMany(sourceFiles);
166
+ const setupFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*setup/, /AmityClient\s*\.\s*setup/]);
167
+ const loginFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*login/, /AmityClient\s*\.\s*login/]);
168
+ const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
169
+ const pushUnregisterFiles = filesMatching(sourceContent, [/unregisterPushNotification/, /disablePushNotification/, /unregister.*DeviceToken/i]);
170
+ const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.observe\s*\(/, /\.subscribe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
171
+ if (!manifest) {
172
+ findings.push(finding("android.manifest.present", "warning", "No AndroidManifest.xml found at the default app path.", manifestPath, "Confirm the Android app module path, then validate permissions and Application wiring."));
173
+ }
174
+ else {
175
+ if (!manifest.includes("android.permission.INTERNET")) {
176
+ findings.push(finding("android.permission.internet", "error", "Android INTERNET permission is missing.", manifestPath, "Add `<uses-permission android:name=\"android.permission.INTERNET\" />` to the app manifest."));
177
+ }
178
+ if (!/<application\b[^>]*android:name=/.test(manifest)) {
179
+ findings.push(finding("android.application.class", "warning", "No custom Application class is declared in AndroidManifest.xml.", manifestPath, "Initialize social.plus once from an Application class instead of an Activity or composable lifecycle."));
180
+ }
181
+ }
182
+ if (!containsAny(buildContent, [/co\.amity\.android:amity-sdk/, /Amity-Social-Cloud-Android-SDK/, /com\.github\.AmityCo/])) {
183
+ findings.push(finding("android.dependency.sdk", "warning", "No obvious social.plus Android SDK dependency was found in Gradle files.", relativeFile(root, buildFiles[0]), "Add the SDK dependency from the Android quick-start docs and keep all Amity dependencies on compatible versions."));
184
+ }
185
+ else if (containsAny(buildContent, [/co\.amity\.android:amity-sdk:\+/, /co\.amity\.android:amity-sdk:latest/i, /version\s*=\s*["']\+["']/])) {
186
+ findings.push(finding("android.sdk.version.pinned", "warning", "The Android SDK dependency appears to use a floating version.", relativeFile(root, buildFiles[0]), "Pin the social.plus Android SDK to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
187
+ }
188
+ if (setupFiles.length === 0) {
189
+ findings.push(finding("android.setup.present", "warning", "No obvious AmityCoreClient.setup call was found in Kotlin/Java files.", undefined, "Call SDK setup once during application startup before any social.plus API usage."));
190
+ }
191
+ else {
192
+ const activitySetup = setupFiles.find((file) => /Activity|Fragment|Composable|onCreateView/.test(sourceContent.get(file) ?? ""));
193
+ if (activitySetup) {
194
+ findings.push(finding("android.setup.lifecycle", "warning", "SDK setup appears near Activity/Fragment/UI lifecycle code.", relativeFile(root, activitySetup), "Prefer Application.onCreate so the SDK is initialized once and not reinitialized on screen recreation."));
195
+ }
196
+ if (!containsAnyForFiles(sourceContent, setupFiles, [/AmityEndpoint\./, /AmityRegion\./, /\bendpoint\s*=/, /\bregion\s*=/])) {
197
+ findings.push(finding("android.setup.region", "warning", "SDK setup was found but no explicit region/endpoint marker was detected.", relativeFile(root, setupFiles[0]), "Set the region/endpoint explicitly to match the customer's social.plus console project."));
198
+ }
199
+ }
200
+ if (loginFiles.length === 0) {
201
+ 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."));
202
+ }
203
+ else if (!containsAny(sourceContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
204
+ findings.push(finding("android.session.renewal", "warning", "Login was found but no AmitySessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass an AmitySessionHandler to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
205
+ }
206
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
207
+ findings.push(finding("android.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
208
+ }
209
+ if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
210
+ findings.push(finding("android.live.cleanup", "warning", "Live Object/Collection observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Dispose or unsubscribe observers when the Activity, Fragment, ViewModel, or composable lifecycle ends."));
211
+ }
212
+ findings.push(...validateLiteralGuardrails(root, "android", sourceContent));
213
+ findings.push(...validateLoggingHygiene(root, "android", sourceContent));
214
+ findings.push(...validateFeedUiStates(root, "android", sourceContent));
215
+ findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
216
+ return findings;
217
+ }
218
+ async function validateFlutter(root) {
219
+ const findings = [];
220
+ const pubspec = await readIfExists(path.join(root, "pubspec.yaml"));
221
+ const dartFiles = await findFiles(path.join(root, "lib"), [".dart"], 300);
222
+ const dartContent = await readMany(dartFiles);
223
+ const setupFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*setup/]);
224
+ const loginFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*login/]);
225
+ const pushRegistrationFiles = filesMatching(dartContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
226
+ const pushUnregisterFiles = filesMatching(dartContent, [/unregisterPushNotification/, /disablePushNotification/]);
227
+ const liveDataFiles = filesMatching(dartContent, [/LiveCollection/, /LiveObject/, /\.listen\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
228
+ if (!pubspec) {
229
+ findings.push(finding("flutter.pubspec.present", "warning", "No pubspec.yaml file was found.", "pubspec.yaml", "Point repoPath at the Flutter project root."));
230
+ }
231
+ else if (!/\bamity_sdk\s*:/.test(pubspec) && !pubspec.toLowerCase().includes("amity")) {
232
+ 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."));
233
+ }
234
+ else if (/\bamity_sdk\s*:\s*(?:any|\*|latest)\b/i.test(pubspec)) {
235
+ findings.push(finding("flutter.sdk.version.pinned", "warning", "The Flutter SDK dependency appears to use a floating version.", "pubspec.yaml", "Pin amity_sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
236
+ }
237
+ if (setupFiles.length === 0) {
238
+ 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."));
239
+ }
240
+ else {
241
+ if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
242
+ findings.push(finding("flutter.binding.initialized", "warning", "SDK setup was found but WidgetsFlutterBinding.ensureInitialized was not detected.", relativeFile(root, setupFiles[0]), "Call WidgetsFlutterBinding.ensureInitialized before async SDK setup in main()."));
243
+ }
244
+ if (!containsAnyForFiles(dartContent, setupFiles, [/AmityEndpoint\./, /\bendpoint\s*:/, /\bregion\s*:/])) {
245
+ 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."));
246
+ }
247
+ }
248
+ if (loginFiles.length === 0) {
249
+ 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."));
250
+ }
251
+ else if (!containsAny(dartContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /SessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
252
+ findings.push(finding("flutter.session.renewal", "warning", "Login was found but no AmitySessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Implement an AmitySessionHandler and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
253
+ }
254
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
255
+ findings.push(finding("flutter.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
256
+ }
257
+ if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
258
+ findings.push(finding("flutter.live.cleanup", "warning", "Live Object/Collection stream observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Store the subscription and cancel it from dispose or the owning lifecycle cleanup."));
259
+ }
260
+ findings.push(...validateLiteralGuardrails(root, "flutter", dartContent));
261
+ findings.push(...validateLoggingHygiene(root, "flutter", dartContent));
262
+ findings.push(...validateFeedUiStates(root, "flutter", dartContent));
263
+ findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
264
+ return findings;
265
+ }
266
+ async function validateTypeScript(root, platform) {
267
+ const findings = [];
268
+ const packageJson = await readIfExists(path.join(root, "package.json"));
269
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500);
270
+ const sourceContent = await readMany(sourceFiles);
271
+ const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /createClient\s*\(/]);
272
+ const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
273
+ const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
274
+ const pushUnregisterFiles = filesMatching(sourceContent, [/unregisterPushNotification/, /disablePushNotification/]);
275
+ const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/, /onSnapshot/]);
276
+ if (!packageJson) {
277
+ findings.push(finding("typescript.package.present", "warning", "No package.json file was found.", "package.json", "Point repoPath at the TypeScript or React Native project root."));
278
+ }
279
+ else if (!packageJson.includes("@amityco/ts-sdk") && !packageJson.toLowerCase().includes("amity")) {
280
+ 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."));
281
+ }
282
+ else if (isFloatingPackageDependency(packageJson, "@amityco/ts-sdk")) {
283
+ findings.push(finding(`${platform}.sdk.version.pinned`, "warning", "The TypeScript SDK dependency appears to use a floating version.", "package.json", "Pin @amityco/ts-sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
284
+ }
285
+ if (setupFiles.length === 0) {
286
+ findings.push(finding("typescript.client.create", "warning", "No obvious TypeScript client initialization pattern was found.", undefined, "Create the social.plus client before login and before API usage."));
287
+ }
288
+ else {
289
+ if (!containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/])) {
290
+ findings.push(finding("typescript.client.region", "warning", "Client initialization was found but no explicit region or endpoint marker was detected.", relativeFile(root, setupFiles[0]), "Pass the region or endpoint that matches the customer's social.plus console project."));
291
+ }
292
+ const renderCycleSetup = setupFiles.find((file) => /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*\s*=/.test(sourceContent.get(file) ?? ""));
293
+ if (renderCycleSetup && platform === "react-native") {
294
+ 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."));
295
+ }
296
+ }
297
+ if (loginFiles.length === 0) {
298
+ 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."));
299
+ }
300
+ if (loginFiles.length > 0 && !containsAny(sourceContent, [/sessionWillRenewAccessToken/, /sessionHandler/, /renewal\.renew/])) {
301
+ findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) so sessions refresh in production."));
302
+ }
303
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
304
+ 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 on logout or user switch so notifications are not sent to the wrong user."));
305
+ }
306
+ if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /cleanup/i])) {
307
+ findings.push(finding(`${platform}.live.cleanup`, "warning", "Live Object/Collection subscription was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Return or call the unsubscribe cleanup from the owning component, route, or store lifecycle."));
308
+ }
309
+ findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
310
+ findings.push(...validateLoggingHygiene(root, platform, sourceContent));
311
+ findings.push(...validateFeedUiStates(root, platform, sourceContent));
312
+ findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
313
+ if (platform === "typescript") {
314
+ findings.push(...(await validateTypeScriptEnvSecretHygiene(root, sourceContent)));
315
+ }
316
+ return findings;
317
+ }
318
+ async function validateTypeScriptEnvSecretHygiene(root, sourceContent) {
319
+ const findings = [];
320
+ const envSecretFiles = await committedEnvSecretFiles(root);
321
+ for (const file of envSecretFiles) {
322
+ findings.push(finding("typescript.secret.committed-env", "warning", "A local env file appears to contain a social.plus secret.", file, "Do not commit .env files with real API keys. Commit a placeholder .env.example and keep local env files ignored."));
323
+ }
324
+ if (usesEnvSecretConfig(sourceContent)) {
325
+ const gitignore = await readIfExists(path.join(root, ".gitignore"));
326
+ if (!gitignoreIgnoresEnvFiles(gitignore)) {
327
+ findings.push(finding("typescript.secret.env-gitignore", "warning", "Source uses env-based social.plus secret config but .gitignore does not ignore local env files.", ".gitignore", "Add .env, .env.local, and environment-specific local env files to .gitignore."));
328
+ }
329
+ const hasExample = await exists(path.join(root, ".env.example")) || await exists(path.join(root, ".env.sample"));
330
+ if (!hasExample) {
331
+ findings.push(finding("typescript.secret.env-example", "warning", "Source uses env-based social.plus secret config but no env example file was found.", ".env.example", "Commit .env.example with placeholder keys so agents and developers know which values to provide locally."));
332
+ }
333
+ }
334
+ return findings;
335
+ }
336
+ // Logging hygiene: customers shipping debug logs of secret-shaped values is a
337
+ // real failure mode (console.log("client", apiKey) escapes to production).
338
+ // Per platform we match the relevant log function calls; the secret-shaped
339
+ // regex is shared. Returns at most one finding per scan to keep noise low.
340
+ function validateLoggingHygiene(root, platform, sourceContent) {
341
+ const logCallPattern = LOGGING_CALL_PATTERNS_BY_PLATFORM[platform];
342
+ if (!logCallPattern) {
343
+ return [];
344
+ }
345
+ const secretShape = /\b(api[_-]?key|api[_-]?secret|access[_-]?token|refresh[_-]?token|bearer[_-]?token|client[_-]?secret|secret[_-]?key)\b/i;
346
+ for (const [file, content] of sourceContent) {
347
+ for (const line of content.split(/\r?\n/)) {
348
+ if (!logCallPattern.test(line)) {
349
+ continue;
350
+ }
351
+ if (!secretShape.test(line)) {
352
+ continue;
353
+ }
354
+ // Allow commented-out lines (//, #, /*); rough but kills the obvious
355
+ // false positives like documentation snippets.
356
+ const trimmed = line.trimStart();
357
+ if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
358
+ continue;
359
+ }
360
+ return [
361
+ finding(`${platform}.logging.no-secret-in-log`, "warning", "A logging call appears to reference a secret-shaped identifier (apiKey, accessToken, etc.).", relativeFile(root, file), "Do not log secret-shaped values. Drop the log line, redact the value, or attest that the value being logged is a placeholder."),
362
+ ];
363
+ }
364
+ }
365
+ return [];
366
+ }
367
+ // Design system reuse: when the project has detected theme / token / design-
368
+ // system files (via detectDesignSignals), at least one other source file
369
+ // should reference one of those files by basename or class identifier.
370
+ // Tolerant heuristic — false negatives are accepted (the agent might use a
371
+ // theme provider context implicitly); the goal is to catch the obvious
372
+ // "agent ignored the detected design system and wrote inline styles."
373
+ async function validateDesignReuse(root, platform, sourceContent) {
374
+ const designSignals = await detectDesignSignals(root);
375
+ if (designSignals.length === 0) {
376
+ return [];
377
+ }
378
+ // Build reference markers per design file. For each design file we look for
379
+ // its basename (without extension) AND its PascalCase identifier guess. A
380
+ // source file that imports / mentions any marker counts as a reference.
381
+ const markersByFile = [];
382
+ for (const signal of designSignals) {
383
+ const base = path.basename(signal.file).replace(/\.(ts|tsx|js|jsx|dart|kt|java|swift|xml|css)$/i, "");
384
+ const pascal = toPascalCase(base);
385
+ const markers = new Set([base]);
386
+ if (pascal && pascal !== base) {
387
+ markers.add(pascal);
388
+ }
389
+ markersByFile.push({ file: signal.file, markers: [...markers] });
390
+ }
391
+ // Resolve design file absolute paths so we can skip them when scanning.
392
+ const designAbsolutePaths = new Set(designSignals.map((signal) => path.resolve(root, signal.file)));
393
+ for (const [file, content] of sourceContent) {
394
+ if (designAbsolutePaths.has(file)) {
395
+ continue;
396
+ }
397
+ for (const { markers } of markersByFile) {
398
+ for (const marker of markers) {
399
+ if (!marker || marker.length < 3) {
400
+ continue;
401
+ }
402
+ if (content.includes(marker)) {
403
+ return [];
404
+ }
405
+ }
406
+ }
407
+ }
408
+ return [
409
+ finding(`${platform}.design.reuse-detected-tokens`, "warning", `Detected design source(s) (${designSignals.map((s) => s.file).slice(0, 3).join(", ")}) are not referenced by any other source file.`, designSignals[0].file, "Import or reference the detected design tokens / theme module from your new UI code instead of inlining colors, spacing, or typography. Attest with evidence (the import path) if you use an implicit theme provider context."),
410
+ ];
411
+ }
412
+ function toPascalCase(snakeOrKebab) {
413
+ return snakeOrKebab
414
+ .split(/[_\-\s]+/)
415
+ .filter(Boolean)
416
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
417
+ .join("");
418
+ }
419
+ // Feed UI states: when a source file observes a live data stream/collection,
420
+ // it should also render loading / empty / error states. The heuristic is
421
+ // per-platform: detect a live-data observation pattern in a file, then check
422
+ // that file for at least one state marker. Returns at most one finding to
423
+ // keep noise low.
424
+ function validateFeedUiStates(root, platform, sourceContent) {
425
+ const observationPatterns = LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM[platform];
426
+ const statePatterns = UI_STATE_PATTERNS_BY_PLATFORM[platform];
427
+ if (!observationPatterns || !statePatterns) {
428
+ return [];
429
+ }
430
+ for (const [file, content] of sourceContent) {
431
+ const observes = observationPatterns.some((pattern) => pattern.test(content));
432
+ if (!observes) {
433
+ continue;
434
+ }
435
+ const hasStates = statePatterns.some((pattern) => pattern.test(content));
436
+ if (hasStates) {
437
+ continue;
438
+ }
439
+ return [
440
+ 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)."),
441
+ ];
442
+ }
443
+ return [];
444
+ }
445
+ const LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM = {
446
+ typescript: [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
447
+ "react-native": [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
448
+ flutter: [/\.listen\s*\(/, /StreamSubscription/, /getStreamController\s*\(/, /AmityCollection/, /AmityObject/],
449
+ // Kotlin uses trailing lambdas — `.observe { ... }` is more common than
450
+ // `.observe(...)`. Match either form.
451
+ android: [/LiveCollection/, /LiveObject/, /\.observe\s*[({]/, /\.subscribe\s*[({]/, /\.collectAsState\b/, /AmitySocialClient\.newPostRepository/, /AmityCollection/],
452
+ // Swift trailing-closure form: `.observe { snapshot in ... }`.
453
+ ios: [/AmityCollection/, /AmityObject/, /\.observe\s*[({]/, /getPosts\s*\(/, /queryPosts\s*\(/],
454
+ };
455
+ const UI_STATE_PATTERNS_BY_PLATFORM = {
456
+ typescript: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /\.length\s*===?\s*0/, /\busestate\b/i],
457
+ "react-native": [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /\.length\s*===?\s*0/, /ActivityIndicator/],
458
+ flutter: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bisEmpty\b/, /CircularProgressIndicator/, /AsyncSnapshot/, /ConnectionState/],
459
+ android: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /CircularProgressIndicator/, /LoadingState/, /\.collectAsState\b/],
460
+ ios: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /ProgressView/, /LoadingState/, /AsyncImage/],
461
+ };
462
+ const LOGGING_CALL_PATTERNS_BY_PLATFORM = {
463
+ android: /\b(?:Log\.[a-z]\s*\(|println\s*\(|print\s*\()/,
464
+ flutter: /\b(?:print\s*\(|debugPrint\s*\(|log\s*\()/,
465
+ ios: /\b(?:print\s*\(|NSLog\s*\(|os_log\s*\(|Logger\b[^)]*\.\s*(?:log|info|debug|notice|error|fault)\s*\()/,
466
+ typescript: /\bconsole\s*\.\s*(?:log|info|warn|error|debug|trace)\s*\(/,
467
+ "react-native": /\bconsole\s*\.\s*(?:log|info|warn|error|debug|trace)\s*\(/,
468
+ };
469
+ function validateLiteralGuardrails(root, platform, sourceContent) {
470
+ const findings = [];
471
+ const feedTarget = firstLiteralAssignment(sourceContent, [
472
+ /\bcommunityId\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
473
+ /\btargetId\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
474
+ /\bfeedId\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
475
+ /\bchannelId\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
476
+ ]);
477
+ if (feedTarget && !isAllowedPlaceholder(feedTarget.value)) {
478
+ findings.push(finding(`${platform}.feed.target.literal`, "warning", `A hardcoded feed target literal was found: ${feedTarget.name}.`, relativeFile(root, feedTarget.file), "Do not invent or hardcode communityId, targetId, feedId, or channelId. Ask the user for the target or use an existing app-owned selection/create flow."));
479
+ }
480
+ const inlineApiKey = firstLiteralAssignment(sourceContent, [
481
+ /\bapiKey\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
482
+ /\bapi_key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
483
+ /\bapi-key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
484
+ ]);
485
+ if (inlineApiKey && !isAllowedPlaceholder(inlineApiKey.value)) {
486
+ 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."));
487
+ }
488
+ // Hardcoded user identity — every benchmark Pure MCP failure mode included
489
+ // `userId: "current-user"` or similar. The user identity should come from
490
+ // the host app's auth state, not a literal in source.
491
+ const literalUserId = firstLiteralAssignment(sourceContent, [
492
+ /\buserId\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
493
+ /\buser_id\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
494
+ /\.login\s*\(\s*["'`]([^"'`]+)["'`]/i,
495
+ /\.login\s*\(\s*userId\s*:\s*["'`]([^"'`]+)["'`]/i,
496
+ ]);
497
+ if (literalUserId && !isAllowedPlaceholder(literalUserId.value)) {
498
+ findings.push(finding(`${platform}.auth.no-literal-user-id`, "warning", `A hardcoded user identity literal was found: ${literalUserId.name}.`, relativeFile(root, literalUserId.file), "Do not hardcode a userId in source. Read the authenticated user from the host app's auth state (current session, route param, user-store hook, etc.)."));
499
+ }
500
+ return findings;
501
+ }
502
+ function firstLiteralAssignment(contents, patterns) {
503
+ for (const [file, content] of contents) {
504
+ for (const pattern of patterns) {
505
+ pattern.lastIndex = 0;
506
+ const match = pattern.exec(content);
507
+ if (match?.[1]) {
508
+ const name = match[0].split(/[=:]/)[0]?.trim() ?? "literal";
509
+ return { file, name, value: match[1].trim() };
510
+ }
511
+ }
512
+ }
513
+ return undefined;
514
+ }
515
+ function isAllowedPlaceholder(value) {
516
+ const normalized = value.toLowerCase().trim();
517
+ return [
518
+ "",
519
+ "api-key",
520
+ "your-api-key",
521
+ "your_api_key",
522
+ "provided-by-customer",
523
+ "customer-provided",
524
+ "replace-me",
525
+ "todo",
526
+ "community-id",
527
+ "community_id",
528
+ "target-id",
529
+ "target_id",
530
+ "feed-id",
531
+ "feed_id",
532
+ "channel-id",
533
+ "channel_id",
534
+ "user-id",
535
+ "user_id",
536
+ "your-user-id",
537
+ "your_user_id",
538
+ "test-user",
539
+ "demo-user",
540
+ "<userid>",
541
+ "<user_id>",
542
+ ].includes(normalized);
543
+ }
544
+ function isFloatingPackageDependency(packageJson, dependencyName) {
545
+ let parsed;
546
+ try {
547
+ parsed = JSON.parse(packageJson);
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ const version = parsed.dependencies?.[dependencyName] ??
553
+ parsed.devDependencies?.[dependencyName] ??
554
+ parsed.peerDependencies?.[dependencyName] ??
555
+ parsed.optionalDependencies?.[dependencyName];
556
+ if (!version) {
557
+ return false;
558
+ }
559
+ return /^(?:latest|\*|x|X)$/i.test(version.trim());
560
+ }
561
+ function usesEnvSecretConfig(sourceContent) {
562
+ return containsAny(sourceContent, [
563
+ /import\.meta\.env\.[A-Z0-9_]*(?:AMITY|SOCIAL_PLUS|SOCIALPLUS)[A-Z0-9_]*(?:API[_-]?KEY|KEY)/i,
564
+ /process\.env\.[A-Z0-9_]*(?:AMITY|SOCIAL_PLUS|SOCIALPLUS)[A-Z0-9_]*(?:API[_-]?KEY|KEY)/i,
565
+ ]);
566
+ }
567
+ async function committedEnvSecretFiles(root) {
568
+ const candidates = [".env", ".env.local", ".env.production", ".env.development"];
569
+ const found = [];
570
+ for (const candidate of candidates) {
571
+ const content = await readIfExists(path.join(root, candidate));
572
+ if (!content) {
573
+ continue;
574
+ }
575
+ const hasSecret = content
576
+ .split(/\r?\n/)
577
+ .some((line) => {
578
+ const trimmed = line.trim();
579
+ if (!trimmed || trimmed.startsWith("#")) {
580
+ return false;
581
+ }
582
+ const [key, ...rest] = trimmed.split("=");
583
+ const value = rest.join("=").trim().replace(/^["']|["']$/g, "");
584
+ return /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(key ?? "") && !isAllowedPlaceholder(value);
585
+ });
586
+ if (hasSecret) {
587
+ found.push(candidate);
588
+ }
589
+ }
590
+ return found;
591
+ }
592
+ function gitignoreIgnoresEnvFiles(gitignore) {
593
+ return gitignore
594
+ .split(/\r?\n/)
595
+ .map((line) => line.trim())
596
+ .some((line) => line === ".env" || line === ".env*" || line === ".env.local" || line === "*.env" || line === ".env.*");
597
+ }
598
+ async function validateIos(root) {
599
+ const findings = [];
600
+ const manifestFiles = await existingFiles(root, ["Podfile", "Package.swift"]);
601
+ const manifestContent = await readMany(manifestFiles);
602
+ const swiftFiles = await findFiles(root, [".swift"], 400);
603
+ const swiftContent = await readMany(swiftFiles);
604
+ const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
605
+ const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
606
+ const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/, /didRegisterForRemoteNotifications/]);
607
+ const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
608
+ const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
609
+ if (manifestFiles.length === 0) {
610
+ findings.push(finding("ios.dependency.manifest", "warning", "No Podfile or Package.swift was found.", undefined, "Point repoPath at the iOS project root or verify the dependency manager manually."));
611
+ }
612
+ else if (!containsAny(manifestContent, [/Amity/i, /AmitySDK/i])) {
613
+ findings.push(finding("ios.dependency.sdk", "warning", "No obvious social.plus/Amity dependency was found in Podfile or Package.swift.", relativeFile(root, manifestFiles[0]), "Add the iOS SDK dependency from the iOS quick-start docs."));
614
+ }
615
+ else if (containsAny(manifestContent, [/\.package\s*\([^)]*\bbranch\s*:/s, /pod\s+['"][^'"]*Amity[^'"]*['"]\s*$/m])) {
616
+ findings.push(finding("ios.sdk.version.pinned", "warning", "The iOS SDK dependency appears to use a floating branch or unspecified CocoaPods version.", 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."));
617
+ }
618
+ if (setupFiles.length === 0) {
619
+ 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."));
620
+ }
621
+ else {
622
+ if (!containsAnyForFiles(swiftContent, setupFiles, [/\bregion\s*:/, /\.SG\b|\.EU\b|\.US\b/])) {
623
+ findings.push(finding("ios.setup.region", "warning", "AmityClient initialization was found but no explicit region marker was detected.", relativeFile(root, setupFiles[0]), "Set the region explicitly to match the customer's social.plus console project."));
624
+ }
625
+ // Setup must happen during app startup (AppDelegate.application(_:didFinishLaunchingWithOptions:),
626
+ // @main App.init, or a UIApplicationDelegate willFinishLaunching), not in
627
+ // a view/scene lifecycle method that re-runs across screens.
628
+ const lifecycleSetup = setupFiles.find((file) => /(viewDidLoad|viewWillAppear|viewWillDisappear|viewDidAppear|viewDidDisappear|scene\s*\(_:willConnectTo)/.test(swiftContent.get(file) ?? ""));
629
+ if (lifecycleSetup) {
630
+ findings.push(finding("ios.setup.lifecycle", "warning", "AmityClient initialization appears inside a view or scene lifecycle method.", relativeFile(root, lifecycleSetup), "Run AmityClient setup during app process startup (AppDelegate.application(_:didFinishLaunchingWithOptions:) or @main App.init) so it does not re-initialize on screen transitions."));
631
+ }
632
+ }
633
+ if (loginFiles.length === 0) {
634
+ 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."));
635
+ }
636
+ else if (!containsAny(swiftContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\.renew\s*\(/])) {
637
+ findings.push(finding("ios.session.renewal", "warning", "Login was found but no AmitySessionHandler with sessionWillRenewAccessToken / renewal.renew() was detected.", relativeFile(root, loginFiles[0]), "Implement AmitySessionHandler and call renewal.renew() in sessionWillRenewAccessToken so the session can refresh in production."));
638
+ }
639
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
640
+ findings.push(finding("ios.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
641
+ }
642
+ if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
643
+ findings.push(finding("ios.live.cleanup", "warning", "Live Object/Collection observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Keep the notification token and invalidate or release it when the view/controller lifecycle ends."));
644
+ }
645
+ findings.push(...validateLiteralGuardrails(root, "ios", swiftContent));
646
+ findings.push(...validateLoggingHygiene(root, "ios", swiftContent));
647
+ findings.push(...validateFeedUiStates(root, "ios", swiftContent));
648
+ findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
649
+ return findings;
650
+ }
651
+ function statusForFindings(findings) {
652
+ if (findings.some((finding) => finding.severity === "error")) {
653
+ return "blocked";
654
+ }
655
+ return findings.length === 0 ? "no-obvious-issues" : "needs-review";
656
+ }
657
+ function finding(ruleId, severity, message, file, recommendation) {
658
+ return { ruleId, severity, message, file, recommendation };
659
+ }
660
+ async function existingFiles(root, relativeFiles) {
661
+ const files = [];
662
+ for (const relativePath of relativeFiles) {
663
+ const absolutePath = path.join(root, relativePath);
664
+ if (await exists(absolutePath)) {
665
+ files.push(absolutePath);
666
+ }
667
+ }
668
+ return files;
669
+ }
670
+ async function detectProjectSurfaces(repoRoot) {
671
+ const candidateDirectories = await findSurfaceCandidateDirectories(repoRoot, 3);
672
+ const surfaces = [];
673
+ for (const directory of candidateDirectories) {
674
+ const { platforms, signals, designSignals } = await inspectRoot(directory);
675
+ if (platforms.length === 0) {
676
+ continue;
677
+ }
678
+ const relativePath = normalizeSurfacePath(path.relative(repoRoot, directory));
679
+ surfaces.push({
680
+ id: relativePath === "." ? "root" : relativePath,
681
+ path: relativePath,
682
+ platforms,
683
+ signals: signals.map((signal) => ({
684
+ ...signal,
685
+ file: relativePath === "." ? signal.file : path.posix.join(relativePath, signal.file),
686
+ })),
687
+ designSignalCount: designSignals.length,
688
+ });
689
+ }
690
+ return surfaces.sort((a, b) => {
691
+ if (a.path === ".") {
692
+ return -1;
693
+ }
694
+ if (b.path === ".") {
695
+ return 1;
696
+ }
697
+ return a.path.localeCompare(b.path);
698
+ });
699
+ }
700
+ async function findSurfaceCandidateDirectories(root, maxDepth) {
701
+ const found = new Set([root]);
702
+ async function walk(directory, depth) {
703
+ if (depth >= maxDepth) {
704
+ return;
705
+ }
706
+ let entries;
707
+ try {
708
+ entries = await readdir(directory, { withFileTypes: true });
709
+ }
710
+ catch {
711
+ return;
712
+ }
713
+ for (const entry of entries) {
714
+ if (!entry.isDirectory() || shouldSkipDirectory(entry.name)) {
715
+ continue;
716
+ }
717
+ const entryPath = path.join(directory, entry.name);
718
+ found.add(entryPath);
719
+ await walk(entryPath, depth + 1);
720
+ }
721
+ }
722
+ await walk(root, 0);
723
+ return Array.from(found);
724
+ }
725
+ async function surfaceForPath(repoRoot, effectiveRoot) {
726
+ const { platforms, signals, designSignals } = await inspectRoot(effectiveRoot);
727
+ const relativePath = normalizeSurfacePath(path.relative(repoRoot, effectiveRoot));
728
+ return {
729
+ id: relativePath === "." ? "root" : relativePath,
730
+ path: relativePath,
731
+ platforms,
732
+ signals,
733
+ designSignalCount: designSignals.length,
734
+ };
735
+ }
736
+ function surfaceMatchingRoot(surfaces, repoRoot) {
737
+ return surfaces.find((surface) => surface.path === normalizeSurfacePath(path.relative(repoRoot, repoRoot)));
738
+ }
739
+ function normalizeSurfacePath(relativePath) {
740
+ if (!relativePath || relativePath === ".") {
741
+ return ".";
742
+ }
743
+ return relativePath.split(path.sep).join(path.posix.sep);
744
+ }
745
+ function shouldSkipDirectory(name) {
746
+ return ["node_modules", ".git", "build", ".dart_tool", "dist", "coverage", ".next"].includes(name);
747
+ }
748
+ async function detectDesignSignals(root) {
749
+ const signals = [];
750
+ const candidateFiles = [
751
+ ["styles", "tailwind.config.js", "Tailwind design configuration"],
752
+ ["styles", "tailwind.config.cjs", "Tailwind design configuration"],
753
+ ["styles", "tailwind.config.mjs", "Tailwind design configuration"],
754
+ ["styles", "tailwind.config.ts", "Tailwind design configuration"],
755
+ ["theme", "src/theme.ts", "TypeScript theme module"],
756
+ ["theme", "src/theme.tsx", "TypeScript theme module"],
757
+ ["theme", "src/theme/index.ts", "TypeScript theme module"],
758
+ ["theme", "src/theme/index.tsx", "TypeScript theme module"],
759
+ ["tokens", "src/tokens.ts", "TypeScript design token module"],
760
+ ["tokens", "src/tokens/index.ts", "TypeScript design token module"],
761
+ ["tokens", "src/design-tokens.ts", "TypeScript design token module"],
762
+ ["tokens", "src/design-system/tokens.ts", "TypeScript design system token module"],
763
+ ["components", "src/design-system/components.ts", "TypeScript design system component registry"],
764
+ ["theme", "src/styles/theme.ts", "TypeScript theme module"],
765
+ ["theme", "src/styles/theme/index.ts", "TypeScript theme module"],
766
+ ["tokens", "src/styles/tokens.ts", "TypeScript design token module"],
767
+ ["styles", "src/styles/globals.css", "Global stylesheet"],
768
+ ["styles", "src/app/globals.css", "Global stylesheet"],
769
+ ["styles", "app/globals.css", "Global stylesheet"],
770
+ ["theme", "theme/index.ts", "TypeScript theme module"],
771
+ ["tokens", "tokens/index.ts", "TypeScript design token module"],
772
+ ["components", "components/ui/button.tsx", "Reusable UI component convention"],
773
+ ["components", "src/components/ui/button.tsx", "Reusable UI component convention"],
774
+ ["theme", "lib/theme.dart", "Flutter theme module"],
775
+ ["theme", "lib/app_theme.dart", "Flutter app theme module"],
776
+ ["theme", "lib/theme/app_theme.dart", "Flutter app theme module"],
777
+ ["theme", "lib/core/theme/app_theme.dart", "Flutter app theme module"],
778
+ ["theme", "lib/presentation/theme/app_theme.dart", "Flutter app theme module"],
779
+ ["tokens", "lib/design_tokens.dart", "Flutter design token module"],
780
+ ["tokens", "lib/theme/design_tokens.dart", "Flutter design token module"],
781
+ ["tokens", "app/src/main/res/values/colors.xml", "Android color resources"],
782
+ ["theme", "app/src/main/res/values/themes.xml", "Android theme resources"],
783
+ ];
784
+ for (const [kind, relativePath, reason] of candidateFiles) {
785
+ if (await exists(path.join(root, relativePath))) {
786
+ signals.push({ kind, file: relativePath, reason });
787
+ }
788
+ }
789
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx", ".dart", ".css", ".swift", ".kt", ".java"], 400);
790
+ const sourceContent = await readMany(sourceFiles);
791
+ const tokenLikeFiles = filesMatching(sourceContent, [
792
+ /ThemeData\s*\(/,
793
+ /ColorScheme\./,
794
+ /createTheme\s*\(/,
795
+ /extendTheme\s*\(/,
796
+ /createMuiTheme\s*\(/,
797
+ /ThemeProvider\b/,
798
+ /ChakraProvider\b/,
799
+ /TamaguiProvider\b/,
800
+ /NativeWindStyleSheet\b/,
801
+ /designTokens/,
802
+ /designSystem/,
803
+ /tokens\s*=/,
804
+ /\btheme\s*=\s*{/,
805
+ /\btheme\s*:\s*{/,
806
+ /\bcolors\s*:\s*{/,
807
+ /\bspacing\s*:\s*{/,
808
+ /\bradius\s*:\s*{/,
809
+ /--[a-z0-9-]+:\s*[^;]+;/i,
810
+ /\b(?:enum|struct)\s+(?:AppTheme|Theme|Colors|Spacing|Tokens|DesignSystem|DesignTokens|Typography|Radius)\b/,
811
+ /\bextension\s+(?:UIColor|Color)\b/,
812
+ /\bstatic\s+let\s+\w+\s*(?::\s*\w+)?\s*=\s*Color\s*\(/,
813
+ /\b(?:fun|val|object)\s+(?:lightColorScheme|darkColorScheme|MaterialTheme)\b/,
814
+ /\bMaterialTheme\s*\(/,
815
+ /\b(?:lightColorScheme|darkColorScheme)\s*\(/,
816
+ /\b(?:val|var)\s+\w+\s*=\s*Color\s*\(\s*0x[0-9A-Fa-f]{6,8}\s*\)/,
817
+ /\b(?:object|class)\s+(?:AppTheme|AppColors|AppTypography|AppSpacing|Tokens|DesignTokens)\b/,
818
+ ]);
819
+ for (const file of tokenLikeFiles) {
820
+ const relativePath = relativeFile(root, file);
821
+ if (relativePath && !signals.some((signal) => signal.file === relativePath)) {
822
+ signals.push({
823
+ kind: inferDesignKind(relativePath),
824
+ file: relativePath,
825
+ reason: "Source content contains theme, token, or CSS custom property markers.",
826
+ });
827
+ }
828
+ }
829
+ return signals.slice(0, 20);
830
+ }
831
+ function inferDesignKind(relativePath) {
832
+ const normalized = relativePath.toLowerCase();
833
+ if (normalized.includes("token")) {
834
+ return "tokens";
835
+ }
836
+ if (normalized.includes("component")) {
837
+ return "components";
838
+ }
839
+ if (normalized.endsWith(".css")) {
840
+ return "styles";
841
+ }
842
+ return "theme";
843
+ }
844
+ async function findFiles(root, extensions, maxFiles) {
845
+ const found = [];
846
+ async function walk(directory) {
847
+ if (found.length >= maxFiles) {
848
+ return;
849
+ }
850
+ let entries;
851
+ try {
852
+ entries = await readdir(directory, { withFileTypes: true });
853
+ }
854
+ catch {
855
+ return;
856
+ }
857
+ for (const entry of entries) {
858
+ if (found.length >= maxFiles) {
859
+ return;
860
+ }
861
+ if (shouldSkipDirectory(entry.name)) {
862
+ continue;
863
+ }
864
+ const entryPath = path.join(directory, entry.name);
865
+ if (entry.isDirectory()) {
866
+ await walk(entryPath);
867
+ }
868
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) {
869
+ found.push(entryPath);
870
+ }
871
+ }
872
+ }
873
+ await walk(root);
874
+ return found;
875
+ }
876
+ async function readMany(files) {
877
+ const contents = new Map();
878
+ for (const file of files) {
879
+ contents.set(file, await readIfExists(file));
880
+ }
881
+ return contents;
882
+ }
883
+ function filesMatching(contents, patterns) {
884
+ const matches = [];
885
+ for (const [file, content] of contents) {
886
+ if (patterns.some((pattern) => pattern.test(content))) {
887
+ matches.push(file);
888
+ }
889
+ }
890
+ return matches;
891
+ }
892
+ function containsAny(contents, patterns) {
893
+ for (const content of contents.values()) {
894
+ if (patterns.some((pattern) => pattern.test(content))) {
895
+ return true;
896
+ }
897
+ }
898
+ return false;
899
+ }
900
+ function containsAnyForFiles(contents, files, patterns) {
901
+ return files.some((file) => {
902
+ const content = contents.get(file) ?? "";
903
+ return patterns.some((pattern) => pattern.test(content));
904
+ });
905
+ }
906
+ function relativeFile(root, file) {
907
+ return file ? path.relative(root, file) : undefined;
908
+ }