wogiflow 2.0.0 → 2.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,766 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Contract Surface Scanner
5
+ *
6
+ * Detects and catalogs a project's integration surface:
7
+ * - HTTP client calls (what endpoints the project CONSUMES)
8
+ * - Route definitions (what endpoints the project EXPOSES)
9
+ * - Event emitters/listeners (pub/sub surface)
10
+ * - Shared type imports (cross-package type contracts)
11
+ * - Environment variables (runtime configuration surface)
12
+ *
13
+ * TEAMS-ONLY feature: generates .workflow/state/contract-surface.json
14
+ * which the wogiflow-cloud orchestration agent consumes.
15
+ * Only activates when a user is logged into a team.
16
+ *
17
+ * Output: contract-surface.json (machine-readable)
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { execSync } = require('node:child_process');
23
+
24
+ const CONTRACT_SURFACE_VERSION = '1.0.0';
25
+
26
+ // Directories to always skip
27
+ const SKIP_DIRS = new Set([
28
+ 'node_modules', 'dist', 'build', '.git', '.workflow',
29
+ 'coverage', '__tests__', '__mocks__', '.next', '.nuxt',
30
+ '.svelte-kit', '.output', 'vendor', '.cache', 'tmp'
31
+ ]);
32
+
33
+ // File extensions to scan
34
+ const SOURCE_EXTENSIONS = new Set([
35
+ '.js', '.ts', '.tsx', '.jsx', '.vue', '.svelte', '.mjs'
36
+ ]);
37
+
38
+ // ============================================================
39
+ // File Walking
40
+ // ============================================================
41
+
42
+ /**
43
+ * Walk a directory tree, yielding source files.
44
+ * @param {string} dir - Directory to walk
45
+ * @param {Object} options
46
+ * @param {number} options.maxDepth - Maximum recursion depth (default 6)
47
+ * @param {number} options.maxFiles - Maximum files to return (default 500)
48
+ * @returns {string[]} Array of absolute file paths
49
+ */
50
+ function walkSourceFiles(dir, options = {}) {
51
+ const maxDepth = options.maxDepth || 6;
52
+ const maxFiles = options.maxFiles || 500;
53
+ const files = [];
54
+
55
+ function walk(currentDir, depth) {
56
+ if (depth > maxDepth || files.length >= maxFiles) return;
57
+
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
61
+ } catch (err) {
62
+ return; // Skip unreadable directories
63
+ }
64
+
65
+ for (const entry of entries) {
66
+ if (files.length >= maxFiles) return;
67
+
68
+ if (entry.isDirectory()) {
69
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
70
+ walk(path.join(currentDir, entry.name), depth + 1);
71
+ } else if (entry.isFile()) {
72
+ const ext = path.extname(entry.name);
73
+ if (SOURCE_EXTENSIONS.has(ext)) {
74
+ files.push(path.join(currentDir, entry.name));
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ walk(dir, 0);
81
+ return files;
82
+ }
83
+
84
+ // ============================================================
85
+ // HTTP Client Scanner
86
+ // ============================================================
87
+
88
+ /**
89
+ * Scan for HTTP client calls (endpoints this project CONSUMES).
90
+ * Detects: axios, fetch, $fetch, ky, got, custom http clients.
91
+ * @param {string} projectRoot
92
+ * @param {Object} options
93
+ * @returns {Object[]} Array of { method, path, source, client }
94
+ */
95
+ function scanHttpClients(projectRoot, options = {}) {
96
+ const files = walkSourceFiles(projectRoot, options);
97
+ const results = [];
98
+
99
+ // Patterns for HTTP client calls
100
+ const patterns = [
101
+ // axios.get('/api/...'), axios.post('/api/...')
102
+ { regex: /\baxios\s*\.\s*(get|post|put|patch|delete|head|options)\s*\(\s*['"`]([^'"`\n]+)['"`]/gi, client: 'axios' },
103
+ // axios({ method: 'GET', url: '/api/...' })
104
+ { regex: /\baxios\s*\(\s*\{[^}]*url\s*:\s*['"`]([^'"`\n]+)['"`][^}]*method\s*:\s*['"`](\w+)['"`]/gi, client: 'axios', methodSwap: true },
105
+ { regex: /\baxios\s*\(\s*\{[^}]*method\s*:\s*['"`](\w+)['"`][^}]*url\s*:\s*['"`]([^'"`\n]+)['"`]/gi, client: 'axios' },
106
+ // fetch('/api/...'), fetch(`${BASE}/api/...`) — negative lookbehind to avoid matching $fetch
107
+ { regex: /(?<!\$)\bfetch\s*\(\s*['"`]([^'"`\n]+)['"`](?:\s*,\s*\{[^}]*method\s*:\s*['"`](\w+)['"`])?/gi, client: 'fetch', pathFirst: true },
108
+ // $fetch('/api/...') (Nuxt)
109
+ { regex: /\$fetch\s*\(\s*['"`]([^'"`\n]+)['"`](?:\s*,\s*\{[^}]*method\s*:\s*['"`](\w+)['"`])?/gi, client: '$fetch', pathFirst: true },
110
+ // ky.get('/api/...'), ky.post('/api/...')
111
+ { regex: /\bky\s*\.\s*(get|post|put|patch|delete|head)\s*\(\s*['"`]([^'"`\n]+)['"`]/gi, client: 'ky' },
112
+ // got.get('/api/...'), got.post('/api/...')
113
+ { regex: /\bgot\s*\.\s*(get|post|put|patch|delete|head)\s*\(\s*['"`]([^'"`\n]+)['"`]/gi, client: 'got' },
114
+ // http.get('/api/...'), http.post('/api/...')
115
+ { regex: /\bhttp\s*\.\s*(get|post|put|patch|delete|head|options)\s*\(\s*['"`]([^'"`\n]+)['"`]/gi, client: 'http' },
116
+ // api.get('/api/...'), apiClient.post('/api/...')
117
+ { regex: /\b(?:api|apiClient|httpClient|client)\s*\.\s*(get|post|put|patch|delete|head)\s*\(\s*['"`]([^'"`\n]+)['"`]/gi, client: 'custom' },
118
+ ];
119
+
120
+ for (const filePath of files) {
121
+ let content;
122
+ try {
123
+ content = fs.readFileSync(filePath, 'utf-8');
124
+ } catch (err) {
125
+ continue;
126
+ }
127
+
128
+ const relPath = path.relative(projectRoot, filePath);
129
+ const lines = content.split('\n');
130
+
131
+ for (const pattern of patterns) {
132
+ // Reset lastIndex for global regex
133
+ pattern.regex.lastIndex = 0;
134
+ let match;
135
+ while ((match = pattern.regex.exec(content)) !== null) {
136
+ let method, urlPath;
137
+
138
+ if (pattern.pathFirst) {
139
+ urlPath = match[1];
140
+ method = (match[2] || 'GET').toUpperCase();
141
+ } else if (pattern.methodSwap) {
142
+ urlPath = match[1];
143
+ method = match[2].toUpperCase();
144
+ } else {
145
+ method = match[1].toUpperCase();
146
+ urlPath = match[2];
147
+ }
148
+
149
+ // Calculate line number
150
+ const lineNum = content.substring(0, match.index).split('\n').length;
151
+
152
+ results.push({
153
+ method,
154
+ path: urlPath,
155
+ source: `${relPath}:${lineNum}`,
156
+ client: pattern.client
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ return results;
163
+ }
164
+
165
+ // ============================================================
166
+ // Route Definition Scanner
167
+ // ============================================================
168
+
169
+ /**
170
+ * Scan for route/endpoint definitions (what this project EXPOSES).
171
+ * Detects: Express, Fastify, Hono, NestJS decorators, Next.js file routes.
172
+ * @param {string} projectRoot
173
+ * @param {Object} options
174
+ * @returns {Object[]} Array of { method, path, source, handler, framework }
175
+ */
176
+ function scanRouteDefinitions(projectRoot, options = {}) {
177
+ const files = walkSourceFiles(projectRoot, options);
178
+ const results = [];
179
+
180
+ // Express/Fastify/Hono route patterns
181
+ const routePatterns = [
182
+ // app.get('/api/...', handler), router.post('/api/...')
183
+ { regex: /\b(?:app|router|server|fastify)\s*\.\s*(get|post|put|patch|delete|all|head|options)\s*\(\s*['"`]([^'"`\n]+)['"`]\s*(?:,\s*(\w+))?/gi, framework: 'express' },
184
+ // fastify.route({ method: 'GET', url: '/api/...' })
185
+ { regex: /\bfastify\s*\.\s*route\s*\(\s*\{[^}]*method\s*:\s*['"`](\w+)['"`][^}]*url\s*:\s*['"`]([^'"`\n]+)['"`]/gi, framework: 'fastify' },
186
+ // Hono: app.get('/api/...')
187
+ { regex: /\bnew\s+Hono[\s\S]{0,200}?\.(?:get|post|put|patch|delete)\s*\(\s*['"`]([^'"`\n]+)['"`]/gi, framework: 'hono', honoStyle: true },
188
+ // NestJS decorators: @Get('/api/...'), @Post('/api/...')
189
+ { regex: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"`]([^'"`\n]+)['"`]\s*\)/g, framework: 'nestjs' },
190
+ ];
191
+
192
+ for (const filePath of files) {
193
+ let content;
194
+ try {
195
+ content = fs.readFileSync(filePath, 'utf-8');
196
+ } catch (err) {
197
+ continue;
198
+ }
199
+
200
+ const relPath = path.relative(projectRoot, filePath);
201
+
202
+ for (const pattern of routePatterns) {
203
+ pattern.regex.lastIndex = 0;
204
+ let match;
205
+ while ((match = pattern.regex.exec(content)) !== null) {
206
+ let method, routePath, handler;
207
+
208
+ if (pattern.honoStyle) {
209
+ routePath = match[1];
210
+ method = 'GET'; // Simplified; actual method is in the chained call
211
+ handler = '';
212
+ } else if (pattern.framework === 'nestjs') {
213
+ method = match[1].toUpperCase();
214
+ routePath = match[2];
215
+ // Try to find the method name after the decorator
216
+ const afterMatch = content.substring(match.index + match[0].length, match.index + match[0].length + 200);
217
+ const handlerMatch = afterMatch.match(/(?:async\s+)?(\w+)\s*\(/);
218
+ handler = handlerMatch ? handlerMatch[1] : '';
219
+ } else {
220
+ method = match[1].toUpperCase();
221
+ routePath = match[2];
222
+ handler = match[3] || '';
223
+ }
224
+
225
+ const lineNum = content.substring(0, match.index).split('\n').length;
226
+
227
+ results.push({
228
+ method,
229
+ path: routePath,
230
+ source: `${relPath}:${lineNum}`,
231
+ handler,
232
+ framework: pattern.framework
233
+ });
234
+ }
235
+ }
236
+ }
237
+
238
+ // Scan for Next.js file-based API routes
239
+ const nextjsRoutes = scanNextjsApiRoutes(projectRoot);
240
+ results.push(...nextjsRoutes);
241
+
242
+ return results;
243
+ }
244
+
245
+ /**
246
+ * Detect Next.js file-based API routes.
247
+ * @param {string} projectRoot
248
+ * @returns {Object[]}
249
+ */
250
+ function scanNextjsApiRoutes(projectRoot) {
251
+ const results = [];
252
+
253
+ // pages/api/**/*.ts (Pages Router)
254
+ const pagesApiDir = path.join(projectRoot, 'pages', 'api');
255
+ if (fs.existsSync(pagesApiDir)) {
256
+ const files = walkSourceFiles(pagesApiDir, { maxDepth: 4, maxFiles: 100 });
257
+ for (const filePath of files) {
258
+ const relPath = path.relative(projectRoot, filePath);
259
+ const routePath = '/api/' + path.relative(pagesApiDir, filePath)
260
+ .replace(/\\/g, '/')
261
+ .replace(/\.(ts|js|tsx|jsx)$/, '')
262
+ .replace(/\/index$/, '');
263
+
264
+ results.push({
265
+ method: 'ALL',
266
+ path: routePath,
267
+ source: relPath,
268
+ handler: 'default',
269
+ framework: 'nextjs-pages'
270
+ });
271
+ }
272
+ }
273
+
274
+ // app/api/**/route.ts (App Router)
275
+ const appApiDir = path.join(projectRoot, 'app', 'api');
276
+ if (fs.existsSync(appApiDir)) {
277
+ const files = walkSourceFiles(appApiDir, { maxDepth: 4, maxFiles: 100 });
278
+ for (const filePath of files) {
279
+ const basename = path.basename(filePath);
280
+ if (!basename.startsWith('route.')) continue;
281
+
282
+ const relPath = path.relative(projectRoot, filePath);
283
+ const routePath = '/api/' + path.relative(appApiDir, path.dirname(filePath))
284
+ .replace(/\\/g, '/');
285
+
286
+ // Read file to detect exported methods (GET, POST, etc.)
287
+ let content;
288
+ try {
289
+ content = fs.readFileSync(filePath, 'utf-8');
290
+ } catch (err) {
291
+ continue;
292
+ }
293
+
294
+ const methods = [];
295
+ const methodPattern = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
296
+ let match;
297
+ while ((match = methodPattern.exec(content)) !== null) {
298
+ methods.push(match[1]);
299
+ }
300
+
301
+ if (methods.length === 0) methods.push('ALL');
302
+
303
+ for (const method of methods) {
304
+ results.push({
305
+ method,
306
+ path: routePath === '/api/.' ? '/api' : routePath,
307
+ source: relPath,
308
+ handler: method.toLowerCase(),
309
+ framework: 'nextjs-app'
310
+ });
311
+ }
312
+ }
313
+ }
314
+
315
+ return results;
316
+ }
317
+
318
+ // ============================================================
319
+ // Event Bus Scanner
320
+ // ============================================================
321
+
322
+ /**
323
+ * Scan for event emitters and listeners.
324
+ * Detects: EventEmitter, pubsub, custom event buses.
325
+ * @param {string} projectRoot
326
+ * @param {Object} options
327
+ * @returns {Object} { emits: [], listensTo: [] }
328
+ */
329
+ function scanEventBus(projectRoot, options = {}) {
330
+ const files = walkSourceFiles(projectRoot, options);
331
+ const emits = [];
332
+ const listensTo = [];
333
+
334
+ const emitPatterns = [
335
+ // eventEmitter.emit('event-name', ...), this.emit('event-name')
336
+ /\b(?:emit|dispatch)\s*\(\s*['"`]([^'"`\n]+)['"`]/g,
337
+ // pubsub.publish('topic', ...)
338
+ /\b(?:publish|trigger|fire|broadcast)\s*\(\s*['"`]([^'"`\n]+)['"`]/g,
339
+ // socket.emit('event', ...)
340
+ /\bsocket\s*\.\s*emit\s*\(\s*['"`]([^'"`\n]+)['"`]/g,
341
+ ];
342
+
343
+ const listenPatterns = [
344
+ // eventEmitter.on('event-name', ...), this.on('event-name')
345
+ /\b(?:on|addEventListener|addListener)\s*\(\s*['"`]([^'"`\n]+)['"`]/g,
346
+ // pubsub.subscribe('topic', ...)
347
+ /\b(?:subscribe|listen)\s*\(\s*['"`]([^'"`\n]+)['"`]/g,
348
+ // socket.on('event', ...)
349
+ /\bsocket\s*\.\s*on\s*\(\s*['"`]([^'"`\n]+)['"`]/g,
350
+ ];
351
+
352
+ for (const filePath of files) {
353
+ let content;
354
+ try {
355
+ content = fs.readFileSync(filePath, 'utf-8');
356
+ } catch (err) {
357
+ continue;
358
+ }
359
+
360
+ const relPath = path.relative(projectRoot, filePath);
361
+
362
+ for (const pattern of emitPatterns) {
363
+ pattern.lastIndex = 0;
364
+ let match;
365
+ while ((match = pattern.exec(content)) !== null) {
366
+ const lineNum = content.substring(0, match.index).split('\n').length;
367
+ emits.push({
368
+ event: match[1],
369
+ source: `${relPath}:${lineNum}`
370
+ });
371
+ }
372
+ }
373
+
374
+ for (const pattern of listenPatterns) {
375
+ pattern.lastIndex = 0;
376
+ let match;
377
+ while ((match = pattern.exec(content)) !== null) {
378
+ const lineNum = content.substring(0, match.index).split('\n').length;
379
+ listensTo.push({
380
+ event: match[1],
381
+ source: `${relPath}:${lineNum}`
382
+ });
383
+ }
384
+ }
385
+ }
386
+
387
+ return { emits, listensTo };
388
+ }
389
+
390
+ // ============================================================
391
+ // Shared Types Scanner
392
+ // ============================================================
393
+
394
+ /**
395
+ * Scan for shared type imports (cross-package type contracts).
396
+ * Detects: @shared/*, @org/*, shared package imports.
397
+ * @param {string} projectRoot
398
+ * @param {Object} options
399
+ * @returns {Object} { imports: [], exports: [] }
400
+ */
401
+ function scanSharedTypes(projectRoot, options = {}) {
402
+ const files = walkSourceFiles(projectRoot, options);
403
+ const imports = [];
404
+ const exports = [];
405
+
406
+ // Patterns for shared/org-scoped imports
407
+ const importPatterns = [
408
+ // import { X } from '@shared/types' or '@org/common'
409
+ /import\s+(?:type\s+)?(?:\{[^}]+\}|\w+)\s+from\s+['"`](@[^'"`\n/]+\/[^'"`\n]+|@shared\/[^'"`\n]+)['"`]/g,
410
+ // require('@shared/types')
411
+ /require\s*\(\s*['"`](@[^'"`\n/]+\/[^'"`\n]+|@shared\/[^'"`\n]+)['"`]\s*\)/g,
412
+ ];
413
+
414
+ // Patterns for type exports (in shared packages)
415
+ const exportPatterns = [
416
+ // export type { X }, export interface X
417
+ /export\s+(?:type|interface)\s+(\w+)/g,
418
+ // export { X } (type re-exports)
419
+ /export\s+\{([^}]+)\}/g,
420
+ ];
421
+
422
+ for (const filePath of files) {
423
+ let content;
424
+ try {
425
+ content = fs.readFileSync(filePath, 'utf-8');
426
+ } catch (err) {
427
+ continue;
428
+ }
429
+
430
+ const relPath = path.relative(projectRoot, filePath);
431
+
432
+ // Shared type imports
433
+ for (const pattern of importPatterns) {
434
+ pattern.lastIndex = 0;
435
+ let match;
436
+ while ((match = pattern.exec(content)) !== null) {
437
+ const lineNum = content.substring(0, match.index).split('\n').length;
438
+ imports.push({
439
+ package: match[1],
440
+ source: `${relPath}:${lineNum}`
441
+ });
442
+ }
443
+ }
444
+
445
+ // Type exports (only in files that look like shared/common)
446
+ if (relPath.includes('shared') || relPath.includes('common') || relPath.includes('types')) {
447
+ for (const pattern of exportPatterns) {
448
+ pattern.lastIndex = 0;
449
+ let match;
450
+ while ((match = pattern.exec(content)) !== null) {
451
+ const lineNum = content.substring(0, match.index).split('\n').length;
452
+ const names = match[1]
453
+ ? match[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean)
454
+ : [match[0].split(/\s+/).pop()];
455
+
456
+ for (const name of names) {
457
+ if (name && name !== '{' && name !== '}') {
458
+ exports.push({
459
+ name,
460
+ source: `${relPath}:${lineNum}`
461
+ });
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ return { imports, exports };
470
+ }
471
+
472
+ // ============================================================
473
+ // Environment Variable Scanner
474
+ // ============================================================
475
+
476
+ /**
477
+ * Scan for environment variable usage and .env definitions.
478
+ * @param {string} projectRoot
479
+ * @param {Object} options
480
+ * @returns {Object} { requires: [], exposes: [] }
481
+ */
482
+ function scanEnvVars(projectRoot, options = {}) {
483
+ const files = walkSourceFiles(projectRoot, options);
484
+ const requires = [];
485
+ const exposesMap = new Map(); // Deduplicate .env entries
486
+
487
+ // Scan source files for process.env usage
488
+ const envPattern = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
489
+ const envBracketPattern = /process\.env\[['"`]([A-Z_][A-Z0-9_]*)['"`]\]/g;
490
+ // import.meta.env.VITE_* (Vite)
491
+ const viteEnvPattern = /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g;
492
+
493
+ const seenRequires = new Set();
494
+
495
+ for (const filePath of files) {
496
+ let content;
497
+ try {
498
+ content = fs.readFileSync(filePath, 'utf-8');
499
+ } catch (err) {
500
+ continue;
501
+ }
502
+
503
+ const relPath = path.relative(projectRoot, filePath);
504
+
505
+ for (const pattern of [envPattern, envBracketPattern, viteEnvPattern]) {
506
+ pattern.lastIndex = 0;
507
+ let match;
508
+ while ((match = pattern.exec(content)) !== null) {
509
+ const varName = match[1];
510
+ // Skip common built-in env vars
511
+ if (['NODE_ENV', 'HOME', 'PATH', 'USER', 'SHELL', 'PWD', 'LANG', 'TERM'].includes(varName)) continue;
512
+
513
+ const lineNum = content.substring(0, match.index).split('\n').length;
514
+ const key = `${varName}::${relPath}`;
515
+ if (!seenRequires.has(key)) {
516
+ seenRequires.add(key);
517
+ requires.push({
518
+ name: varName,
519
+ source: `${relPath}:${lineNum}`
520
+ });
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ // Scan .env files for defined variables
527
+ const envFiles = ['.env', '.env.example', '.env.local', '.env.development', '.env.production', '.env.test'];
528
+ for (const envFile of envFiles) {
529
+ const envPath = path.join(projectRoot, envFile);
530
+ if (!fs.existsSync(envPath)) continue;
531
+
532
+ let content;
533
+ try {
534
+ content = fs.readFileSync(envPath, 'utf-8');
535
+ } catch (err) {
536
+ continue;
537
+ }
538
+
539
+ const linePattern = /^([A-Z_][A-Z0-9_]*)\s*=/gm;
540
+ let match;
541
+ while ((match = linePattern.exec(content)) !== null) {
542
+ const varName = match[1];
543
+ if (!exposesMap.has(varName)) {
544
+ exposesMap.set(varName, {
545
+ name: varName,
546
+ source: envFile,
547
+ definedIn: [envFile]
548
+ });
549
+ } else {
550
+ const existing = exposesMap.get(varName);
551
+ if (!existing.definedIn.includes(envFile)) {
552
+ existing.definedIn.push(envFile);
553
+ }
554
+ }
555
+ }
556
+ }
557
+
558
+ return {
559
+ requires,
560
+ exposes: Array.from(exposesMap.values())
561
+ };
562
+ }
563
+
564
+ // ============================================================
565
+ // Project Type Detection
566
+ // ============================================================
567
+
568
+ /**
569
+ * Detect project type based on file structure and dependencies.
570
+ * @param {string} projectRoot
571
+ * @returns {'frontend'|'backend'|'fullstack'|'library'|'monorepo'|'unknown'}
572
+ */
573
+ function detectProjectType(projectRoot) {
574
+ // Check for monorepo indicators
575
+ const hasWorkspaces = fs.existsSync(path.join(projectRoot, 'packages'))
576
+ || fs.existsSync(path.join(projectRoot, 'apps'));
577
+ const hasLerna = fs.existsSync(path.join(projectRoot, 'lerna.json'));
578
+ const hasTurborepo = fs.existsSync(path.join(projectRoot, 'turbo.json'));
579
+ const hasNxJson = fs.existsSync(path.join(projectRoot, 'nx.json'));
580
+
581
+ if (hasLerna || hasTurborepo || hasNxJson || hasWorkspaces) {
582
+ // Check if it's truly a monorepo (multiple packages)
583
+ const packagesDir = path.join(projectRoot, 'packages');
584
+ const appsDir = path.join(projectRoot, 'apps');
585
+ let packageCount = 0;
586
+
587
+ for (const dir of [packagesDir, appsDir]) {
588
+ if (fs.existsSync(dir)) {
589
+ try {
590
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
591
+ packageCount += entries.filter(e => e.isDirectory()).length;
592
+ } catch (err) {
593
+ // skip
594
+ }
595
+ }
596
+ }
597
+ if (packageCount > 1) return 'monorepo';
598
+ }
599
+
600
+ // Read package.json for dependency analysis
601
+ let pkg = {};
602
+ try {
603
+ const pkgContent = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8');
604
+ pkg = JSON.parse(pkgContent);
605
+ } catch (err) {
606
+ // No package.json or invalid JSON
607
+ }
608
+
609
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
610
+
611
+ // Frontend indicators
612
+ const frontendPkgs = ['react', 'vue', 'svelte', '@angular/core', 'next', 'nuxt', 'vite', 'gatsby'];
613
+ const hasFrontend = frontendPkgs.some(p => allDeps[p]);
614
+
615
+ // Backend indicators
616
+ const backendPkgs = ['express', 'fastify', '@nestjs/core', 'koa', '@hapi/hapi', 'hono'];
617
+ const hasBackend = backendPkgs.some(p => allDeps[p]);
618
+
619
+ // Library indicators
620
+ if (pkg.main || pkg.module || pkg.exports) {
621
+ const hasNoServer = !hasBackend;
622
+ const hasNoUI = !hasFrontend;
623
+ if (hasNoServer && hasNoUI) return 'library';
624
+ }
625
+
626
+ if (hasFrontend && hasBackend) return 'fullstack';
627
+ if (hasFrontend) return 'frontend';
628
+ if (hasBackend) return 'backend';
629
+
630
+ // Check for server files
631
+ if (fs.existsSync(path.join(projectRoot, 'server')) ||
632
+ fs.existsSync(path.join(projectRoot, 'manage.py')) ||
633
+ fs.existsSync(path.join(projectRoot, 'go.mod'))) {
634
+ return 'backend';
635
+ }
636
+
637
+ // Check for src/components (frontend hint)
638
+ if (fs.existsSync(path.join(projectRoot, 'src', 'components'))) {
639
+ return 'frontend';
640
+ }
641
+
642
+ return 'unknown';
643
+ }
644
+
645
+ // ============================================================
646
+ // Git Utilities
647
+ // ============================================================
648
+
649
+ /**
650
+ * Get current commit SHA.
651
+ * @param {string} projectRoot
652
+ * @returns {string|null}
653
+ */
654
+ function getCommitSha(projectRoot) {
655
+ try {
656
+ return execSync('git rev-parse HEAD', {
657
+ cwd: projectRoot,
658
+ encoding: 'utf-8',
659
+ stdio: ['pipe', 'pipe', 'pipe']
660
+ }).trim();
661
+ } catch (err) {
662
+ return null;
663
+ }
664
+ }
665
+
666
+ // ============================================================
667
+ // Main Scanner
668
+ // ============================================================
669
+
670
+ /**
671
+ * Run all scanners and compile the contract surface.
672
+ * @param {string} projectRoot - Project root directory
673
+ * @param {Object} options
674
+ * @param {string} options.projectName - Override project name
675
+ * @param {string} options.projectType - Override project type
676
+ * @param {number} options.maxFiles - Max files to scan per scanner (default 500)
677
+ * @param {number} options.maxDepth - Max directory depth (default 6)
678
+ * @param {boolean} options.verbose - Log progress
679
+ * @returns {Object} Contract surface JSON
680
+ */
681
+ function scanContracts(projectRoot, options = {}) {
682
+ const verbose = options.verbose || false;
683
+
684
+ if (verbose) console.log('Detecting project type...');
685
+ const projectType = options.projectType || detectProjectType(projectRoot);
686
+
687
+ if (verbose) console.log(`Project type: ${projectType}`);
688
+
689
+ const surface = {
690
+ version: CONTRACT_SURFACE_VERSION,
691
+ projectName: options.projectName || path.basename(projectRoot),
692
+ projectType,
693
+ generatedAt: new Date().toISOString(),
694
+ commitSha: getCommitSha(projectRoot),
695
+ endpoints: {
696
+ consumes: [],
697
+ exposes: []
698
+ },
699
+ events: {
700
+ emits: [],
701
+ listensTo: []
702
+ },
703
+ sharedTypes: {
704
+ imports: [],
705
+ exports: []
706
+ },
707
+ environment: {
708
+ requires: [],
709
+ exposes: []
710
+ }
711
+ };
712
+
713
+ const scanOptions = {
714
+ maxFiles: options.maxFiles || 500,
715
+ maxDepth: options.maxDepth || 6
716
+ };
717
+
718
+ // HTTP client calls
719
+ if (verbose) console.log('Scanning HTTP client calls...');
720
+ surface.endpoints.consumes = scanHttpClients(projectRoot, scanOptions);
721
+ if (verbose) console.log(` Found ${surface.endpoints.consumes.length} consumed endpoints`);
722
+
723
+ // Route definitions
724
+ if (verbose) console.log('Scanning route definitions...');
725
+ surface.endpoints.exposes = scanRouteDefinitions(projectRoot, scanOptions);
726
+ if (verbose) console.log(` Found ${surface.endpoints.exposes.length} exposed endpoints`);
727
+
728
+ // Event bus
729
+ if (verbose) console.log('Scanning event emitters/listeners...');
730
+ const events = scanEventBus(projectRoot, scanOptions);
731
+ surface.events.emits = events.emits;
732
+ surface.events.listensTo = events.listensTo;
733
+ if (verbose) console.log(` Found ${events.emits.length} emits, ${events.listensTo.length} listeners`);
734
+
735
+ // Shared types
736
+ if (verbose) console.log('Scanning shared type imports...');
737
+ const types = scanSharedTypes(projectRoot, scanOptions);
738
+ surface.sharedTypes.imports = types.imports;
739
+ surface.sharedTypes.exports = types.exports;
740
+ if (verbose) console.log(` Found ${types.imports.length} imports, ${types.exports.length} exports`);
741
+
742
+ // Environment variables
743
+ if (verbose) console.log('Scanning environment variables...');
744
+ const env = scanEnvVars(projectRoot, scanOptions);
745
+ surface.environment.requires = env.requires;
746
+ surface.environment.exposes = env.exposes;
747
+ if (verbose) console.log(` Found ${env.requires.length} required vars, ${env.exposes.length} defined vars`);
748
+
749
+ return surface;
750
+ }
751
+
752
+ // ============================================================
753
+ // Exports
754
+ // ============================================================
755
+
756
+ module.exports = {
757
+ scanContracts,
758
+ scanHttpClients,
759
+ scanRouteDefinitions,
760
+ scanEventBus,
761
+ scanSharedTypes,
762
+ scanEnvVars,
763
+ detectProjectType,
764
+ walkSourceFiles,
765
+ CONTRACT_SURFACE_VERSION
766
+ };