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.
- package/.claude/commands/wogi-bulk.md +18 -3
- package/.claude/commands/wogi-pending.md +72 -0
- package/.claude/commands/wogi-start.md +61 -2
- package/.claude/commands/wogi-test.md +12 -0
- package/.workflow/templates/claude-md.hbs +1 -0
- package/package.json +1 -1
- package/scripts/flow +7 -0
- package/scripts/flow-config-defaults.js +28 -1
- package/scripts/flow-constants.js +2 -2
- package/scripts/flow-contract-scan.js +144 -0
- package/scripts/flow-paths.js +2 -0
- package/scripts/flow-pending.js +153 -0
- package/scripts/flow-test-ui.js +1 -1
- package/scripts/flow-version-check.js +14 -11
- package/scripts/hooks/core/session-context.js +12 -0
- package/scripts/hooks/core/session-end.js +32 -0
- package/scripts/hooks/core/task-completed.js +13 -0
- package/scripts/postinstall.js +90 -1
- package/scripts/registries/contract-scanner.js +766 -0
|
@@ -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
|
+
};
|