docguard-cli 0.10.0 → 0.11.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,438 @@
1
+ /**
2
+ * Frontend Scanner — captures the UI surface of a project so the generated
3
+ * "memory" covers screens/pages/components, not just the backend API.
4
+ *
5
+ * Detects:
6
+ * - Screens/routes:
7
+ * • React Router — <Route path="/x" element={<XPage/>} /> and route-object configs
8
+ * • Next.js App — app/**​/page.{tsx,jsx}
9
+ * • Next.js Pages — pages/**​/*.{tsx,jsx} (excluding api/ and _files)
10
+ * - Component inventory (files under components/ dirs)
11
+ * - State + data libraries (from dependencies)
12
+ *
13
+ * Monorepo-aware via resolveSourceRoots(). Pure read-only scanning.
14
+ * Zero NPM dependencies — Node.js built-ins only.
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
18
+ import { resolve, join, relative, basename, extname } from 'node:path';
19
+ import { resolveSourceRoots, collectPackageJsons } from '../shared-source.mjs';
20
+
21
+ const IGNORE_DIRS = new Set([
22
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
23
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
24
+ ]);
25
+ const UI_EXT = new Set(['.tsx', '.jsx']);
26
+
27
+ function walk(dir, onFile, depth = 0) {
28
+ if (depth > 12 || !existsSync(dir)) return;
29
+ let entries;
30
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
31
+ for (const e of entries) {
32
+ if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
33
+ const full = join(dir, e.name);
34
+ if (e.isDirectory()) walk(full, onFile, depth + 1);
35
+ else if (e.isFile()) onFile(full);
36
+ }
37
+ }
38
+
39
+ function readSafe(p) { try { return readFileSync(p, 'utf-8'); } catch { return ''; } }
40
+
41
+ /** Normalize a route path param syntax to {param} and strip trailing slash. */
42
+ function normRoute(p) {
43
+ let s = String(p).trim();
44
+ if (!s.startsWith('/')) s = '/' + s;
45
+ s = s.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\[(?:\.\.\.)?(\w+)\]/g, '{$1}');
46
+ if (s.length > 1) s = s.replace(/\/+$/, '');
47
+ return s;
48
+ }
49
+
50
+ /** Detect the frontend stack from merged dependencies. */
51
+ function detectFrontendStack(projectDir, config) {
52
+ const deps = {};
53
+ for (const { pkg } of collectPackageJsons(projectDir, config)) {
54
+ Object.assign(deps, pkg.dependencies || {}, pkg.devDependencies || {});
55
+ }
56
+ let framework = '';
57
+ if (deps.next) framework = 'Next.js';
58
+ else if (deps['react-router-dom'] || deps['react-router']) framework = 'React Router';
59
+ else if (deps['@tanstack/react-router']) framework = 'TanStack Router';
60
+ else if (deps.react) framework = 'React';
61
+ else if (deps.vue) framework = 'Vue';
62
+ else if (deps.svelte || deps['@sveltejs/kit']) framework = 'Svelte';
63
+
64
+ const stateLib = deps.zustand ? 'Zustand'
65
+ : deps['@reduxjs/toolkit'] || deps.redux ? 'Redux'
66
+ : deps.jotai ? 'Jotai' : deps.mobx ? 'MobX' : null;
67
+ const dataLib = deps['@tanstack/react-query'] ? 'TanStack Query'
68
+ : deps.swr ? 'SWR' : deps['@apollo/client'] ? 'Apollo' : null;
69
+ const buildTool = deps.vite ? 'Vite' : deps.next ? 'Next.js' : deps.webpack ? 'Webpack' : null;
70
+
71
+ return { framework, stateLib, dataLib, buildTool };
72
+ }
73
+
74
+ // Components that WRAP a route's real screen — skip them when identifying the page.
75
+ const ROUTE_WRAPPERS = new Set([
76
+ 'RequireAuth', 'ProtectedRoute', 'PrivateRoute', 'PublicRoute', 'AuthGuard',
77
+ 'Guard', 'Suspense', 'Layout', 'AppLayout', 'MainLayout', 'RootLayout',
78
+ 'Fragment', 'Outlet', 'Navigate', 'ErrorBoundary', 'Route', 'Routes',
79
+ 'Provider', 'Wrapper',
80
+ ]);
81
+
82
+ /** Pick the most meaningful screen component from candidates in a route element. */
83
+ function pickScreenComponent(candidates) {
84
+ const real = candidates.filter(c => !ROUTE_WRAPPERS.has(c));
85
+ if (real.length === 0) return candidates[candidates.length - 1] || null;
86
+ // Prefer a *Page / *Screen / *View name, else the innermost (last) real one.
87
+ const named = real.find(c => /(Page|Screen|View)$/.test(c));
88
+ return named || real[real.length - 1];
89
+ }
90
+
91
+ /** Extract React Router screens: <Route path=".." element={<Wrapper><Comp/></Wrapper>} /> + route objects. */
92
+ function scanReactRouterScreens(roots, projectDir) {
93
+ const screens = [];
94
+ const seen = new Set();
95
+ const add = (path, component, file) => {
96
+ const p = normRoute(path);
97
+ if (seen.has(p)) return; // one screen per path
98
+ seen.add(p);
99
+ screens.push({ path: p, component: component || null, file: relative(projectDir, file) });
100
+ };
101
+
102
+ // Find each `path="..."`, then look in a window AFTER it for the element's
103
+ // component(s) — the element JSX can nest wrappers, so collect all and pick.
104
+ const pathRe = /\bpath\s*[=:]\s*["'`]([^"'`]+)["'`]/g;
105
+ const compRe = /<\s*([A-Z][A-Za-z0-9_]*)/g;
106
+
107
+ for (const root of roots) {
108
+ walk(root, (file) => {
109
+ if (!UI_EXT.has(extname(file)) && !/\.(ts|js|mjs)$/.test(file)) return;
110
+ const content = readSafe(file);
111
+ if (!content.includes('<Route') && !content.includes('createBrowserRouter') &&
112
+ !content.includes('useRoutes') && !content.includes('createRoutesFrom')) return;
113
+
114
+ let m;
115
+ const re = new RegExp(pathRe.source, 'g');
116
+ while ((m = re.exec(content)) !== null) {
117
+ // Window = from this path up to the START of the next route entry
118
+ // (next `path=`/`path:`), capped — so nested `<Spinner/>` fallbacks don't
119
+ // truncate the window before the real screen component.
120
+ const start = m.index + m[0].length;
121
+ const nextPath = new RegExp(pathRe.source, 'g');
122
+ nextPath.lastIndex = start;
123
+ const nm = nextPath.exec(content);
124
+ const end = Math.min(nm ? nm.index : content.length, start + 400);
125
+ const windowStr = content.slice(start, end);
126
+ const comps = [];
127
+ let cm;
128
+ const cre = new RegExp(compRe.source, 'g');
129
+ while ((cm = cre.exec(windowStr)) !== null) comps.push(cm[1]);
130
+ add(m[1], pickScreenComponent(comps), file);
131
+ }
132
+ });
133
+ }
134
+ return screens;
135
+ }
136
+
137
+ /** Extract Next.js screens from app/ and pages/ file conventions. */
138
+ function scanNextScreens(projectDir, config) {
139
+ const screens = [];
140
+ const seen = new Set();
141
+ const add = (path, file) => {
142
+ const p = normRoute(path || '/');
143
+ if (seen.has(p)) return;
144
+ seen.add(p);
145
+ screens.push({ path: p, component: basename(relative(projectDir, file)), file: relative(projectDir, file) });
146
+ };
147
+
148
+ // Next conventions live at a PACKAGE root (app/, pages/, src/app, src/pages),
149
+ // so search package/project bases — not the granular source roots (which
150
+ // already include `app/` and would double-append).
151
+ const bases = new Set([resolve(projectDir)]);
152
+ for (const { dir } of collectPackageJsons(projectDir, config)) bases.add(dir);
153
+
154
+ for (const root of bases) {
155
+ // App Router: app/**/page.{tsx,jsx}
156
+ for (const appBase of ['app', 'src/app']) {
157
+ const appDir = resolve(root, appBase);
158
+ if (!existsSync(appDir)) continue;
159
+ walk(appDir, (file) => {
160
+ if (!/^page\.(tsx|jsx|ts|js)$/.test(basename(file))) return;
161
+ const rel = relative(appDir, file).replace(/\/page\.\w+$/, '').replace(/^page\.\w+$/, '');
162
+ // strip route groups (group) segments
163
+ const routePath = '/' + rel.split('/').filter(seg => seg && !/^\(.*\)$/.test(seg)).join('/');
164
+ add(routePath, file);
165
+ });
166
+ }
167
+ // Pages Router: pages/**/*.{tsx,jsx} excluding api and _files
168
+ for (const pagesBase of ['pages', 'src/pages']) {
169
+ const pagesDir = resolve(root, pagesBase);
170
+ if (!existsSync(pagesDir)) continue;
171
+ walk(pagesDir, (file) => {
172
+ if (!UI_EXT.has(extname(file))) return;
173
+ const rel = relative(pagesDir, file);
174
+ if (rel.startsWith('api/') || basename(file).startsWith('_')) return;
175
+ const routePath = '/' + rel.replace(extname(rel), '').replace(/\/index$/, '').replace(/^index$/, '');
176
+ add(routePath, file);
177
+ });
178
+ }
179
+ }
180
+ return screens;
181
+ }
182
+
183
+ /** Inventory component files under components/ directories. */
184
+ function scanComponents(roots, projectDir) {
185
+ const components = [];
186
+ const seen = new Set();
187
+ for (const root of roots) {
188
+ walk(root, (file) => {
189
+ if (!UI_EXT.has(extname(file))) return;
190
+ const rel = relative(projectDir, file);
191
+ // Heuristic: under a components/ dir, PascalCase filename, not a test/story.
192
+ if (!/(^|\/)components\//.test(rel)) return;
193
+ if (/\.(test|spec|stories)\./.test(rel)) return;
194
+ const name = basename(file, extname(file));
195
+ if (!/^[A-Z]/.test(name)) return;
196
+ if (seen.has(rel)) return;
197
+ seen.add(rel);
198
+ components.push({ name, file: rel });
199
+ });
200
+ }
201
+ return components;
202
+ }
203
+
204
+ /** Scan frontend state stores (Zustand, Redux Toolkit slices, Jotai atoms, MobX). */
205
+ function scanStores(roots, projectDir) {
206
+ const out = [];
207
+ const seen = new Set();
208
+ const add = (name, library, file) => {
209
+ const key = `${name}::${file}`;
210
+ if (seen.has(key)) return;
211
+ seen.add(key);
212
+ out.push({ name, library, file: relative(projectDir, file) });
213
+ };
214
+ // Zustand: const useThing = create(...) / create<T>(...)
215
+ const zustand = /\b(?:export\s+)?const\s+(use[A-Z]\w*)\s*=\s*create\b/g;
216
+ // Redux Toolkit slice
217
+ const rtkSlice = /\bcreateSlice\s*\(\s*\{[^}]*?\bname\s*:\s*["']([^"']+)["']/g;
218
+ // Jotai atoms: const xAtom = atom(...) / atomWithStorage(...)
219
+ const jotai = /\b(?:export\s+)?const\s+(\w+Atom)\s*=\s*atom(?:WithStorage)?\b/g;
220
+ // MobX: class XStore { makeObservable / makeAutoObservable }
221
+ const mobx = /\bclass\s+(\w+Store)\b[\s\S]{0,400}?(?:makeObservable|makeAutoObservable)\b/g;
222
+
223
+ for (const root of roots) {
224
+ walk(root, (file) => {
225
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
226
+ const content = readSafe(file);
227
+ if (!content) return;
228
+ let m;
229
+ if (content.includes('create(') || content.includes('create<')) {
230
+ const re = new RegExp(zustand.source, 'g');
231
+ while ((m = re.exec(content)) !== null) add(m[1], 'Zustand', file);
232
+ }
233
+ if (content.includes('createSlice')) {
234
+ const re = new RegExp(rtkSlice.source, 'g');
235
+ while ((m = re.exec(content)) !== null) add(m[1], 'Redux Toolkit', file);
236
+ }
237
+ if (content.includes('atom(') || content.includes('atomWithStorage')) {
238
+ const re = new RegExp(jotai.source, 'g');
239
+ while ((m = re.exec(content)) !== null) add(m[1], 'Jotai', file);
240
+ }
241
+ if (content.includes('makeObservable') || content.includes('makeAutoObservable')) {
242
+ const re = new RegExp(mobx.source, 'g');
243
+ while ((m = re.exec(content)) !== null) add(m[1], 'MobX', file);
244
+ }
245
+ });
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /** Inventory custom React hooks: exported `useXxx` declarations. */
251
+ function scanHooks(roots, projectDir) {
252
+ const out = [];
253
+ const seen = new Set();
254
+ // export function useThing / export const useThing = / export { useThing }
255
+ const decl = /\bexport\s+(?:function|const|let)\s+(use[A-Z]\w*)\b/g;
256
+ const reexport = /\bexport\s*\{\s*([^}]+)\}/g;
257
+ for (const root of roots) {
258
+ walk(root, (file) => {
259
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
260
+ if (/\.(test|spec|stories)\./.test(file)) return;
261
+ const content = readSafe(file);
262
+ if (!content || !content.includes('use')) return;
263
+ const rel = relative(projectDir, file);
264
+ let m;
265
+ const re1 = new RegExp(decl.source, 'g');
266
+ while ((m = re1.exec(content)) !== null) {
267
+ if (seen.has(m[1])) continue;
268
+ seen.add(m[1]);
269
+ out.push({ name: m[1], file: rel });
270
+ }
271
+ const re2 = new RegExp(reexport.source, 'g');
272
+ while ((m = re2.exec(content)) !== null) {
273
+ // For `X as Y`, the EXPORTED name is the alias (Y) — that's what consumers see.
274
+ for (const id of m[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop()).filter(Boolean)) {
275
+ if (/^use[A-Z]/.test(id) && !seen.has(id)) {
276
+ seen.add(id);
277
+ out.push({ name: id, file: rel });
278
+ }
279
+ }
280
+ }
281
+ });
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /** React Context inventory: `const XContext = createContext(...)`. */
287
+ function scanContexts(roots, projectDir) {
288
+ const out = [];
289
+ const seen = new Set();
290
+ const re = /\b(?:export\s+)?const\s+(\w+Context)\s*=\s*(?:React\.)?createContext\b/g;
291
+ for (const root of roots) {
292
+ walk(root, (file) => {
293
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
294
+ const content = readSafe(file);
295
+ if (!content || !content.includes('createContext')) return;
296
+ let m;
297
+ const r = new RegExp(re.source, 'g');
298
+ while ((m = r.exec(content)) !== null) {
299
+ if (seen.has(m[1])) continue;
300
+ seen.add(m[1]);
301
+ out.push({ name: m[1], file: relative(projectDir, file) });
302
+ }
303
+ });
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * i18n: detect translation keys used in code (`t('a.b')`, `i18n.t('a.b')`, `i18nKey="a.b"`)
310
+ * and the locale files that define them. Reports keys used in code but missing
311
+ * from locales as a small drift signal the agent can call out.
312
+ * @returns {{ usedKeys, locales, missing }}
313
+ */
314
+ function scanI18n(projectDir, roots) {
315
+ const usedKeys = new Set();
316
+ const codeRe = /\b(?:i18n\.)?t\(\s*[`'"]([a-zA-Z][\w.-]*\.[\w.-]+)[`'"]/g;
317
+ const propRe = /\bi18nKey\s*=\s*[`'"]([a-zA-Z][\w.-]*\.[\w.-]+)[`'"]/g;
318
+
319
+ for (const root of roots) {
320
+ walk(root, (file) => {
321
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
322
+ const content = readSafe(file);
323
+ if (!content || (!content.includes('t(') && !content.includes('i18nKey'))) return;
324
+ let m;
325
+ const r1 = new RegExp(codeRe.source, 'g');
326
+ while ((m = r1.exec(content)) !== null) usedKeys.add(m[1]);
327
+ const r2 = new RegExp(propRe.source, 'g');
328
+ while ((m = r2.exec(content)) !== null) usedKeys.add(m[1]);
329
+ });
330
+ }
331
+
332
+ // Locale files: src/i18n/locales/<lang>.json, src/locales/<lang>.json, public/locales/<lang>/*.json
333
+ const locales = [];
334
+ const localeKeys = new Set();
335
+ const seenLocaleDirs = new Set();
336
+ const seenLocaleFiles = new Set();
337
+ const candidates = ['i18n', 'locales', 'src/i18n', 'src/locales', 'public/locales'];
338
+ for (const base of [resolve(projectDir), ...roots]) {
339
+ for (const sub of candidates) {
340
+ const localesDir = resolve(base, sub);
341
+ if (seenLocaleDirs.has(localesDir) || !existsSync(localesDir)) continue;
342
+ seenLocaleDirs.add(localesDir);
343
+ walk(localesDir, (file) => {
344
+ if (seenLocaleFiles.has(file)) return;
345
+ seenLocaleFiles.add(file);
346
+ if (!file.endsWith('.json')) return;
347
+ let json;
348
+ try { json = JSON.parse(readSafe(file) || '{}'); } catch { return; }
349
+ const rel = relative(projectDir, file);
350
+ const collected = [];
351
+ const walkObj = (obj, prefix) => {
352
+ for (const [k, v] of Object.entries(obj || {})) {
353
+ const path = prefix ? `${prefix}.${k}` : k;
354
+ if (v && typeof v === 'object' && !Array.isArray(v)) walkObj(v, path);
355
+ else { localeKeys.add(path); collected.push(path); }
356
+ }
357
+ };
358
+ walkObj(json, '');
359
+ if (collected.length) locales.push({ file: rel, keys: collected.length });
360
+ });
361
+ }
362
+ }
363
+
364
+ const missing = [...usedKeys].filter(k => !localeKeys.has(k)).sort();
365
+ return { usedKeys: [...usedKeys].sort(), locales, missing };
366
+ }
367
+
368
+ /**
369
+ * Frontend → backend wiring: extract API calls (`axios.get('/api/...')`,
370
+ * `fetch('/api/...')`, generic client methods) so the agent can map screens
371
+ * to the endpoints they hit.
372
+ */
373
+ function scanApiCalls(roots, projectDir) {
374
+ const out = [];
375
+ const seen = new Set();
376
+ const add = (method, path, file) => {
377
+ const key = `${method} ${path}::${file}`;
378
+ if (seen.has(key)) return;
379
+ seen.add(key);
380
+ out.push({ method, path, file: relative(projectDir, file) });
381
+ };
382
+ // method-call style: <obj>.get('/api/...'), axios.post('/api/...'), apiClient.users.get('/api/...')
383
+ const methodCall = /\b(?:axios|api|client|apiClient|http|fetcher)\b[\w.]*\.(get|post|put|delete|patch)\s*\(\s*[`'"](\/[^`'")\s]+)/gi;
384
+ // bare fetch
385
+ const fetchCall = /\bfetch\s*\(\s*[`'"](\/[^`'")\s]+)[`'"]\s*(?:,\s*\{[^}]*?method\s*:\s*[`'"](GET|POST|PUT|DELETE|PATCH))?/gi;
386
+
387
+ for (const root of roots) {
388
+ walk(root, (file) => {
389
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
390
+ const content = readSafe(file);
391
+ if (!content) return;
392
+ let m;
393
+ const a = new RegExp(methodCall.source, 'gi');
394
+ while ((m = a.exec(content)) !== null) add(m[1].toUpperCase(), m[2], file);
395
+ const f = new RegExp(fetchCall.source, 'gi');
396
+ while ((m = f.exec(content)) !== null) add((m[2] || 'GET').toUpperCase(), m[1], file);
397
+ });
398
+ }
399
+ return out;
400
+ }
401
+
402
+ /**
403
+ * Scan the frontend surface of a project.
404
+ * @returns {{ framework, buildTool, stateLib, dataLib, routerType,
405
+ * screens: object[], components: object[],
406
+ * stores: object[], hooks: object[], contexts: object[], apiCalls: object[] }}
407
+ */
408
+ export function scanFrontend(projectDir, config = {}) {
409
+ const stack = detectFrontendStack(projectDir, config);
410
+ const roots = resolveSourceRoots(projectDir, config);
411
+
412
+ let screens = [];
413
+ let routerType = null;
414
+ if (stack.framework === 'Next.js') {
415
+ screens = scanNextScreens(projectDir, config);
416
+ routerType = 'next';
417
+ } else {
418
+ // React Router (and TanStack/React fallbacks use the same JSX/route-object forms)
419
+ screens = scanReactRouterScreens(roots, projectDir);
420
+ if (screens.length) routerType = 'react-router';
421
+ // If nothing matched but a Next layout exists, try Next as a fallback.
422
+ if (!screens.length) {
423
+ const nx = scanNextScreens(projectDir, config);
424
+ if (nx.length) { screens = nx; routerType = 'next'; }
425
+ }
426
+ }
427
+
428
+ screens.sort((a, b) => a.path.localeCompare(b.path));
429
+ const components = scanComponents(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
430
+ const stores = scanStores(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
431
+ const hooks = scanHooks(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
432
+ const contexts = scanContexts(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
433
+ const apiCalls = scanApiCalls(roots, projectDir).sort((a, b) =>
434
+ a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
435
+ const i18n = scanI18n(projectDir, roots);
436
+
437
+ return { ...stack, routerType, screens, components, stores, hooks, contexts, apiCalls, i18n };
438
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * External Integrations Scanner — what third-party services does this project talk to?
3
+ *
4
+ * Recognizes common SDKs/clients across all detected ecosystems (JS/TS, Python,
5
+ * Rust, Go, Java, Ruby, PHP, .NET) by name-matching dependencies against a
6
+ * curated registry. Output is the project's external-system surface — the kind
7
+ * of "this integrates with AWS S3, Stripe, OpenAI, Sentry" facts the AI agent
8
+ * uses to write INTEGRATIONS.md.
9
+ *
10
+ * Deterministic facts; the agent narrates. Zero NPM dependencies.
11
+ */
12
+
13
+ import { detectEcosystems } from './project-type.mjs';
14
+
15
+ /**
16
+ * Registry of integrations. Each entry: a category label + name patterns to
17
+ * match against dependency keys (substring, lowercased). Add new SDKs here.
18
+ */
19
+ const REGISTRY = [
20
+ // ── Cloud ──
21
+ { name: 'AWS', category: 'Cloud', patterns: ['@aws-sdk/', 'aws-sdk', 'boto3', 'aws-sdk-go', 'aws-sdk-rust', 'aws.sdk', 'amazon-aws'] },
22
+ { name: 'Google Cloud', category: 'Cloud', patterns: ['@google-cloud/', 'google-cloud-', 'gcloud'] },
23
+ { name: 'Azure', category: 'Cloud', patterns: ['@azure/', 'azure-', 'azure.sdk'] },
24
+ { name: 'Cloudflare', category: 'Cloud', patterns: ['cloudflare', 'wrangler', '@cloudflare/'] },
25
+ { name: 'Vercel', category: 'Cloud', patterns: ['@vercel/', 'vercel/'] },
26
+ // ── Databases / storage ──
27
+ { name: 'PostgreSQL', category: 'Database', patterns: ['pg', '@neondatabase/serverless', 'postgres', 'psycopg', 'sqlx', 'lib/pq'] },
28
+ { name: 'MySQL', category: 'Database', patterns: ['mysql2', 'mysql-connector', 'pymysql'] },
29
+ { name: 'MongoDB', category: 'Database', patterns: ['mongoose', 'mongodb', 'pymongo'] },
30
+ { name: 'DynamoDB', category: 'Database', patterns: ['@aws-sdk/client-dynamodb', 'aws-sdk/dynamodb', 'boto3.dynamodb'] },
31
+ { name: 'Redis', category: 'Database', patterns: ['redis', 'ioredis', 'redis-rs', 'go-redis'] },
32
+ { name: 'Supabase', category: 'Database', patterns: ['@supabase/', 'supabase-py', 'supabase-go'] },
33
+ { name: 'Firebase', category: 'Database', patterns: ['firebase', '@firebase/', 'firebase-admin', 'pyrebase'] },
34
+ // ── Payments ──
35
+ { name: 'Stripe', category: 'Payments', patterns: ['stripe', '@stripe/', 'stripe-go', 'stripe-java'] },
36
+ { name: 'Braintree', category: 'Payments', patterns: ['braintree'] },
37
+ { name: 'PayPal', category: 'Payments', patterns: ['@paypal/', 'paypal-checkout'] },
38
+ // ── Auth ──
39
+ { name: 'Auth0', category: 'Auth', patterns: ['@auth0/', 'auth0-'] },
40
+ { name: 'Clerk', category: 'Auth', patterns: ['@clerk/'] },
41
+ { name: 'NextAuth', category: 'Auth', patterns: ['next-auth', '@auth/'] },
42
+ { name: 'Passport', category: 'Auth', patterns: ['passport', 'passport-'] },
43
+ { name: 'Cognito', category: 'Auth', patterns: ['@aws-sdk/client-cognito-identity', 'amazon-cognito-identity-js', 'aws-amplify'] },
44
+ // ── AI ──
45
+ { name: 'OpenAI', category: 'AI', patterns: ['openai'] },
46
+ { name: 'Anthropic', category: 'AI', patterns: ['@anthropic-ai/sdk', 'anthropic'] },
47
+ { name: 'LangChain', category: 'AI', patterns: ['langchain', '@langchain/'] },
48
+ { name: 'Hugging Face', category: 'AI', patterns: ['huggingface', '@huggingface/', 'transformers'] },
49
+ // ── Messaging / email ──
50
+ { name: 'Twilio', category: 'Messaging', patterns: ['twilio'] },
51
+ { name: 'SendGrid', category: 'Messaging', patterns: ['@sendgrid/', 'sendgrid'] },
52
+ { name: 'Mailgun', category: 'Messaging', patterns: ['mailgun', 'mailgun.js'] },
53
+ { name: 'Resend', category: 'Messaging', patterns: ['resend'] },
54
+ { name: 'Slack', category: 'Messaging', patterns: ['@slack/', 'slack-sdk', 'slack_sdk'] },
55
+ { name: 'Bird (MessageBird)', category: 'Messaging', patterns: ['messagebird', 'bird-sdk', '@birdapp/'] },
56
+ // ── Observability ──
57
+ { name: 'Sentry', category: 'Observability', patterns: ['@sentry/', 'sentry-sdk', 'sentry-go'] },
58
+ { name: 'Datadog', category: 'Observability', patterns: ['dd-trace', '@datadog/', 'datadog'] },
59
+ { name: 'OpenTelemetry', category: 'Observability', patterns: ['@opentelemetry/', 'opentelemetry-'] },
60
+ { name: 'Pino', category: 'Observability', patterns: ['pino'] },
61
+ // ── Search ──
62
+ { name: 'Algolia', category: 'Search', patterns: ['algoliasearch', '@algolia/', 'algolia'] },
63
+ { name: 'Elasticsearch', category: 'Search', patterns: ['@elastic/elasticsearch', 'elasticsearch'] },
64
+ { name: 'Meilisearch',category: 'Search', patterns: ['meilisearch'] },
65
+ { name: 'Typesense', category: 'Search', patterns: ['typesense'] },
66
+ // ── Queues ──
67
+ { name: 'SQS', category: 'Queue', patterns: ['@aws-sdk/client-sqs'] },
68
+ { name: 'RabbitMQ', category: 'Queue', patterns: ['amqplib', 'pika'] },
69
+ { name: 'Kafka', category: 'Queue', patterns: ['kafkajs', 'sarama', 'confluent-kafka'] },
70
+ // ── Storage ──
71
+ { name: 'S3', category: 'Storage', patterns: ['@aws-sdk/client-s3', 'multer-s3', 'boto3.s3'] },
72
+ ];
73
+
74
+ function depKeys(deps) {
75
+ return Object.keys(deps).map(k => k.toLowerCase());
76
+ }
77
+
78
+ function matches(keys, patterns) {
79
+ const evidence = [];
80
+ for (const p of patterns) {
81
+ const needle = p.toLowerCase();
82
+ for (const k of keys) {
83
+ if (k.includes(needle)) evidence.push(k);
84
+ }
85
+ }
86
+ return evidence;
87
+ }
88
+
89
+ /**
90
+ * Detect external integrations across all ecosystems in the project.
91
+ * @returns {Array<{ name, category, ecosystems: string[], evidence: string[] }>}
92
+ */
93
+ export function detectIntegrations(projectDir, config = {}) {
94
+ const ecosystems = detectEcosystems(projectDir, config);
95
+ const found = new Map(); // name -> { name, category, ecosystems:Set, evidence:Set }
96
+
97
+ for (const eco of ecosystems) {
98
+ const keys = depKeys(eco.deps);
99
+ if (keys.length === 0) continue;
100
+ for (const entry of REGISTRY) {
101
+ const ev = matches(keys, entry.patterns);
102
+ if (ev.length === 0) continue;
103
+ let row = found.get(entry.name);
104
+ if (!row) {
105
+ row = { name: entry.name, category: entry.category, ecosystems: new Set(), evidence: new Set() };
106
+ found.set(entry.name, row);
107
+ }
108
+ row.ecosystems.add(eco.language);
109
+ for (const e of ev) row.evidence.add(e);
110
+ }
111
+ }
112
+
113
+ return [...found.values()]
114
+ .map(r => ({ name: r.name, category: r.category, ecosystems: [...r.ecosystems], evidence: [...r.evidence] }))
115
+ .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
116
+ }