@amityco/foundry-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,571 @@
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, Foundry 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
+ 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
+ if (setupFiles.length === 0) {
186
+ 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."));
187
+ }
188
+ else {
189
+ const activitySetup = setupFiles.find((file) => /Activity|Fragment|Composable|onCreateView/.test(sourceContent.get(file) ?? ""));
190
+ if (activitySetup) {
191
+ 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."));
192
+ }
193
+ if (!containsAnyForFiles(sourceContent, setupFiles, [/AmityEndpoint\./, /AmityRegion\./, /\bendpoint\s*=/, /\bregion\s*=/])) {
194
+ 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."));
195
+ }
196
+ }
197
+ if (loginFiles.length === 0) {
198
+ 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."));
199
+ }
200
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
201
+ 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."));
202
+ }
203
+ if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
204
+ 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."));
205
+ }
206
+ return findings;
207
+ }
208
+ async function validateFlutter(root) {
209
+ const findings = [];
210
+ const pubspec = await readIfExists(path.join(root, "pubspec.yaml"));
211
+ const dartFiles = await findFiles(path.join(root, "lib"), [".dart"], 300);
212
+ const dartContent = await readMany(dartFiles);
213
+ const setupFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*setup/]);
214
+ const loginFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*login/]);
215
+ const pushRegistrationFiles = filesMatching(dartContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
216
+ const pushUnregisterFiles = filesMatching(dartContent, [/unregisterPushNotification/, /disablePushNotification/]);
217
+ const liveDataFiles = filesMatching(dartContent, [/LiveCollection/, /LiveObject/, /\.listen\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
218
+ if (!pubspec) {
219
+ findings.push(finding("flutter.pubspec.present", "warning", "No pubspec.yaml file was found.", "pubspec.yaml", "Point repoPath at the Flutter project root."));
220
+ }
221
+ else if (!/\bamity_sdk\s*:/.test(pubspec) && !pubspec.toLowerCase().includes("amity")) {
222
+ 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."));
223
+ }
224
+ if (setupFiles.length === 0) {
225
+ 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."));
226
+ }
227
+ else {
228
+ if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
229
+ 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()."));
230
+ }
231
+ if (!containsAnyForFiles(dartContent, setupFiles, [/AmityEndpoint\./, /\bendpoint\s*:/, /\bregion\s*:/])) {
232
+ 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."));
233
+ }
234
+ }
235
+ if (loginFiles.length === 0) {
236
+ 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."));
237
+ }
238
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
239
+ 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."));
240
+ }
241
+ if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
242
+ 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."));
243
+ }
244
+ return findings;
245
+ }
246
+ async function validateTypeScript(root, platform) {
247
+ const findings = [];
248
+ const packageJson = await readIfExists(path.join(root, "package.json"));
249
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500);
250
+ const sourceContent = await readMany(sourceFiles);
251
+ const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /createClient\s*\(/]);
252
+ const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
253
+ const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
254
+ const pushUnregisterFiles = filesMatching(sourceContent, [/unregisterPushNotification/, /disablePushNotification/]);
255
+ const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/, /onSnapshot/]);
256
+ if (!packageJson) {
257
+ 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."));
258
+ }
259
+ else if (!packageJson.includes("@amityco/ts-sdk") && !packageJson.toLowerCase().includes("amity")) {
260
+ 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."));
261
+ }
262
+ if (setupFiles.length === 0) {
263
+ 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."));
264
+ }
265
+ else {
266
+ if (!containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/])) {
267
+ 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."));
268
+ }
269
+ const renderCycleSetup = setupFiles.find((file) => /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*\s*=/.test(sourceContent.get(file) ?? ""));
270
+ if (renderCycleSetup && platform === "react-native") {
271
+ 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."));
272
+ }
273
+ }
274
+ if (loginFiles.length === 0) {
275
+ 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."));
276
+ }
277
+ if (loginFiles.length > 0 && !containsAnyForFiles(sourceContent, loginFiles, [/sessionWillRenewAccessToken/, /sessionHandler/, /renewal\.renew/])) {
278
+ findings.push(finding("typescript.session.renewal", "info", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "For production authentication, verify the session handler renews tokens according to the authentication docs."));
279
+ }
280
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
281
+ 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."));
282
+ }
283
+ if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /cleanup/i])) {
284
+ 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."));
285
+ }
286
+ return findings;
287
+ }
288
+ async function validateIos(root) {
289
+ const findings = [];
290
+ const manifestFiles = await existingFiles(root, ["Podfile", "Package.swift"]);
291
+ const manifestContent = await readMany(manifestFiles);
292
+ const swiftFiles = await findFiles(root, [".swift"], 400);
293
+ const swiftContent = await readMany(swiftFiles);
294
+ const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
295
+ const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
296
+ const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/, /didRegisterForRemoteNotifications/]);
297
+ const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
298
+ const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
299
+ if (manifestFiles.length === 0) {
300
+ 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."));
301
+ }
302
+ else if (!containsAny(manifestContent, [/Amity/i, /AmitySDK/i])) {
303
+ 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."));
304
+ }
305
+ if (setupFiles.length === 0) {
306
+ 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."));
307
+ }
308
+ else if (!containsAnyForFiles(swiftContent, setupFiles, [/\bregion\s*:/, /\.SG\b|\.EU\b|\.US\b/])) {
309
+ 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."));
310
+ }
311
+ if (loginFiles.length === 0) {
312
+ 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."));
313
+ }
314
+ if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
315
+ 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."));
316
+ }
317
+ if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
318
+ 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."));
319
+ }
320
+ return findings;
321
+ }
322
+ function statusForFindings(findings) {
323
+ if (findings.some((finding) => finding.severity === "error")) {
324
+ return "blocked";
325
+ }
326
+ return findings.length === 0 ? "no-obvious-issues" : "needs-review";
327
+ }
328
+ function finding(ruleId, severity, message, file, recommendation) {
329
+ return { ruleId, severity, message, file, recommendation };
330
+ }
331
+ async function existingFiles(root, relativeFiles) {
332
+ const files = [];
333
+ for (const relativePath of relativeFiles) {
334
+ const absolutePath = path.join(root, relativePath);
335
+ if (await exists(absolutePath)) {
336
+ files.push(absolutePath);
337
+ }
338
+ }
339
+ return files;
340
+ }
341
+ async function detectProjectSurfaces(repoRoot) {
342
+ const candidateDirectories = await findSurfaceCandidateDirectories(repoRoot, 3);
343
+ const surfaces = [];
344
+ for (const directory of candidateDirectories) {
345
+ const { platforms, signals, designSignals } = await inspectRoot(directory);
346
+ if (platforms.length === 0) {
347
+ continue;
348
+ }
349
+ const relativePath = normalizeSurfacePath(path.relative(repoRoot, directory));
350
+ surfaces.push({
351
+ id: relativePath === "." ? "root" : relativePath,
352
+ path: relativePath,
353
+ platforms,
354
+ signals: signals.map((signal) => ({
355
+ ...signal,
356
+ file: relativePath === "." ? signal.file : path.posix.join(relativePath, signal.file),
357
+ })),
358
+ designSignalCount: designSignals.length,
359
+ });
360
+ }
361
+ return surfaces.sort((a, b) => {
362
+ if (a.path === ".") {
363
+ return -1;
364
+ }
365
+ if (b.path === ".") {
366
+ return 1;
367
+ }
368
+ return a.path.localeCompare(b.path);
369
+ });
370
+ }
371
+ async function findSurfaceCandidateDirectories(root, maxDepth) {
372
+ const found = new Set([root]);
373
+ async function walk(directory, depth) {
374
+ if (depth >= maxDepth) {
375
+ return;
376
+ }
377
+ let entries;
378
+ try {
379
+ entries = await readdir(directory, { withFileTypes: true });
380
+ }
381
+ catch {
382
+ return;
383
+ }
384
+ for (const entry of entries) {
385
+ if (!entry.isDirectory() || shouldSkipDirectory(entry.name)) {
386
+ continue;
387
+ }
388
+ const entryPath = path.join(directory, entry.name);
389
+ found.add(entryPath);
390
+ await walk(entryPath, depth + 1);
391
+ }
392
+ }
393
+ await walk(root, 0);
394
+ return Array.from(found);
395
+ }
396
+ async function surfaceForPath(repoRoot, effectiveRoot) {
397
+ const { platforms, signals, designSignals } = await inspectRoot(effectiveRoot);
398
+ const relativePath = normalizeSurfacePath(path.relative(repoRoot, effectiveRoot));
399
+ return {
400
+ id: relativePath === "." ? "root" : relativePath,
401
+ path: relativePath,
402
+ platforms,
403
+ signals,
404
+ designSignalCount: designSignals.length,
405
+ };
406
+ }
407
+ function surfaceMatchingRoot(surfaces, repoRoot) {
408
+ return surfaces.find((surface) => surface.path === normalizeSurfacePath(path.relative(repoRoot, repoRoot)));
409
+ }
410
+ function normalizeSurfacePath(relativePath) {
411
+ if (!relativePath || relativePath === ".") {
412
+ return ".";
413
+ }
414
+ return relativePath.split(path.sep).join(path.posix.sep);
415
+ }
416
+ function shouldSkipDirectory(name) {
417
+ return ["node_modules", ".git", "build", ".dart_tool", "dist", "coverage", ".next"].includes(name);
418
+ }
419
+ async function detectDesignSignals(root) {
420
+ const signals = [];
421
+ const candidateFiles = [
422
+ ["styles", "tailwind.config.js", "Tailwind design configuration"],
423
+ ["styles", "tailwind.config.cjs", "Tailwind design configuration"],
424
+ ["styles", "tailwind.config.mjs", "Tailwind design configuration"],
425
+ ["styles", "tailwind.config.ts", "Tailwind design configuration"],
426
+ ["theme", "src/theme.ts", "TypeScript theme module"],
427
+ ["theme", "src/theme.tsx", "TypeScript theme module"],
428
+ ["theme", "src/theme/index.ts", "TypeScript theme module"],
429
+ ["theme", "src/theme/index.tsx", "TypeScript theme module"],
430
+ ["tokens", "src/tokens.ts", "TypeScript design token module"],
431
+ ["tokens", "src/tokens/index.ts", "TypeScript design token module"],
432
+ ["tokens", "src/design-tokens.ts", "TypeScript design token module"],
433
+ ["tokens", "src/design-system/tokens.ts", "TypeScript design system token module"],
434
+ ["components", "src/design-system/components.ts", "TypeScript design system component registry"],
435
+ ["theme", "src/styles/theme.ts", "TypeScript theme module"],
436
+ ["theme", "src/styles/theme/index.ts", "TypeScript theme module"],
437
+ ["tokens", "src/styles/tokens.ts", "TypeScript design token module"],
438
+ ["styles", "src/styles/globals.css", "Global stylesheet"],
439
+ ["styles", "src/app/globals.css", "Global stylesheet"],
440
+ ["styles", "app/globals.css", "Global stylesheet"],
441
+ ["theme", "theme/index.ts", "TypeScript theme module"],
442
+ ["tokens", "tokens/index.ts", "TypeScript design token module"],
443
+ ["components", "components/ui/button.tsx", "Reusable UI component convention"],
444
+ ["components", "src/components/ui/button.tsx", "Reusable UI component convention"],
445
+ ["theme", "lib/theme.dart", "Flutter theme module"],
446
+ ["theme", "lib/app_theme.dart", "Flutter app theme module"],
447
+ ["theme", "lib/theme/app_theme.dart", "Flutter app theme module"],
448
+ ["theme", "lib/core/theme/app_theme.dart", "Flutter app theme module"],
449
+ ["theme", "lib/presentation/theme/app_theme.dart", "Flutter app theme module"],
450
+ ["tokens", "lib/design_tokens.dart", "Flutter design token module"],
451
+ ["tokens", "lib/theme/design_tokens.dart", "Flutter design token module"],
452
+ ["tokens", "app/src/main/res/values/colors.xml", "Android color resources"],
453
+ ["theme", "app/src/main/res/values/themes.xml", "Android theme resources"],
454
+ ];
455
+ for (const [kind, relativePath, reason] of candidateFiles) {
456
+ if (await exists(path.join(root, relativePath))) {
457
+ signals.push({ kind, file: relativePath, reason });
458
+ }
459
+ }
460
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx", ".dart", ".css"], 400);
461
+ const sourceContent = await readMany(sourceFiles);
462
+ const tokenLikeFiles = filesMatching(sourceContent, [
463
+ /ThemeData\s*\(/,
464
+ /ColorScheme\./,
465
+ /createTheme\s*\(/,
466
+ /extendTheme\s*\(/,
467
+ /createMuiTheme\s*\(/,
468
+ /ThemeProvider\b/,
469
+ /ChakraProvider\b/,
470
+ /TamaguiProvider\b/,
471
+ /NativeWindStyleSheet\b/,
472
+ /designTokens/,
473
+ /designSystem/,
474
+ /tokens\s*=/,
475
+ /\btheme\s*=\s*{/,
476
+ /\btheme\s*:\s*{/,
477
+ /\bcolors\s*:\s*{/,
478
+ /\bspacing\s*:\s*{/,
479
+ /\bradius\s*:\s*{/,
480
+ /--[a-z0-9-]+:\s*[^;]+;/i,
481
+ ]);
482
+ for (const file of tokenLikeFiles) {
483
+ const relativePath = relativeFile(root, file);
484
+ if (relativePath && !signals.some((signal) => signal.file === relativePath)) {
485
+ signals.push({
486
+ kind: inferDesignKind(relativePath),
487
+ file: relativePath,
488
+ reason: "Source content contains theme, token, or CSS custom property markers.",
489
+ });
490
+ }
491
+ }
492
+ return signals.slice(0, 20);
493
+ }
494
+ function inferDesignKind(relativePath) {
495
+ const normalized = relativePath.toLowerCase();
496
+ if (normalized.includes("token")) {
497
+ return "tokens";
498
+ }
499
+ if (normalized.includes("component")) {
500
+ return "components";
501
+ }
502
+ if (normalized.endsWith(".css")) {
503
+ return "styles";
504
+ }
505
+ return "theme";
506
+ }
507
+ async function findFiles(root, extensions, maxFiles) {
508
+ const found = [];
509
+ async function walk(directory) {
510
+ if (found.length >= maxFiles) {
511
+ return;
512
+ }
513
+ let entries;
514
+ try {
515
+ entries = await readdir(directory, { withFileTypes: true });
516
+ }
517
+ catch {
518
+ return;
519
+ }
520
+ for (const entry of entries) {
521
+ if (found.length >= maxFiles) {
522
+ return;
523
+ }
524
+ if (shouldSkipDirectory(entry.name)) {
525
+ continue;
526
+ }
527
+ const entryPath = path.join(directory, entry.name);
528
+ if (entry.isDirectory()) {
529
+ await walk(entryPath);
530
+ }
531
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) {
532
+ found.push(entryPath);
533
+ }
534
+ }
535
+ }
536
+ await walk(root);
537
+ return found;
538
+ }
539
+ async function readMany(files) {
540
+ const contents = new Map();
541
+ for (const file of files) {
542
+ contents.set(file, await readIfExists(file));
543
+ }
544
+ return contents;
545
+ }
546
+ function filesMatching(contents, patterns) {
547
+ const matches = [];
548
+ for (const [file, content] of contents) {
549
+ if (patterns.some((pattern) => pattern.test(content))) {
550
+ matches.push(file);
551
+ }
552
+ }
553
+ return matches;
554
+ }
555
+ function containsAny(contents, patterns) {
556
+ for (const content of contents.values()) {
557
+ if (patterns.some((pattern) => pattern.test(content))) {
558
+ return true;
559
+ }
560
+ }
561
+ return false;
562
+ }
563
+ function containsAnyForFiles(contents, files, patterns) {
564
+ return files.some((file) => {
565
+ const content = contents.get(file) ?? "";
566
+ return patterns.some((pattern) => pattern.test(content));
567
+ });
568
+ }
569
+ function relativeFile(root, file) {
570
+ return file ? path.relative(root, file) : undefined;
571
+ }