wogiflow 2.4.3 → 2.5.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,599 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Integration Detection & Contract Management
5
+ *
6
+ * Story 2 (wf-b4f1fec0): Cross-references api-maps across repos, detects
7
+ * orphans and type drift, auto-generates contracts, and tracks versions.
8
+ *
9
+ * Supports: OpenAPI, GraphQL, TypeScript type definitions, JSON Schema
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const crypto = require('node:crypto');
15
+
16
+ // ============================================================
17
+ // Integration Map Generator (Criterion 1)
18
+ // ============================================================
19
+
20
+ /**
21
+ * Build a full integration map from workspace manifest
22
+ * Cross-references all provider endpoints with all consumer endpoints.
23
+ *
24
+ * @param {Object} manifest — workspace-manifest.json content
25
+ * @returns {Object} integrationMap with matches, orphans, stats
26
+ */
27
+ function buildIntegrationMap(manifest) {
28
+ const map = {
29
+ generatedAt: new Date().toISOString(),
30
+ matched: [],
31
+ orphanedConsumers: [],
32
+ orphanedProviders: [],
33
+ stats: { totalProvided: 0, totalConsumed: 0, matchRate: 0 }
34
+ };
35
+
36
+ if (!manifest?.members || typeof manifest.members !== 'object') {
37
+ return map;
38
+ }
39
+
40
+ const providers = new Map(); // normalized endpoint → { raw, members[] }
41
+ const consumers = new Map();
42
+
43
+ for (const [name, member] of Object.entries(manifest.members)) {
44
+ for (const ep of (member.provides || [])) {
45
+ const norm = normalizeForMatching(ep);
46
+ if (!providers.has(norm)) providers.set(norm, { raw: ep, members: [] });
47
+ providers.get(norm).members.push(name);
48
+ }
49
+ for (const ep of (member.consumes || [])) {
50
+ const norm = normalizeForMatching(ep);
51
+ if (!consumers.has(norm)) consumers.set(norm, { raw: ep, members: [] });
52
+ consumers.get(norm).members.push(name);
53
+ }
54
+ }
55
+
56
+ map.stats.totalProvided = providers.size;
57
+ map.stats.totalConsumed = consumers.size;
58
+
59
+ // Match consumers to providers
60
+ const matchedProviderKeys = new Set();
61
+
62
+ for (const [normConsumer, consumerInfo] of consumers) {
63
+ let bestMatch = null;
64
+ let bestScore = 0;
65
+
66
+ for (const [normProvider, providerInfo] of providers) {
67
+ const score = matchScore(normConsumer, normProvider);
68
+ if (score > bestScore && score >= 0.7) {
69
+ bestScore = score;
70
+ bestMatch = { norm: normProvider, info: providerInfo };
71
+ }
72
+ }
73
+
74
+ if (bestMatch) {
75
+ map.matched.push({
76
+ endpoint: bestMatch.info.raw,
77
+ normalizedEndpoint: bestMatch.norm,
78
+ providers: bestMatch.info.members,
79
+ consumers: consumerInfo.members,
80
+ matchScore: bestScore
81
+ });
82
+ matchedProviderKeys.add(bestMatch.norm);
83
+ } else {
84
+ map.orphanedConsumers.push({
85
+ endpoint: consumerInfo.raw,
86
+ normalizedEndpoint: normConsumer,
87
+ consumers: consumerInfo.members
88
+ });
89
+ }
90
+ }
91
+
92
+ // Find unmatched providers
93
+ for (const [normProvider, providerInfo] of providers) {
94
+ if (!matchedProviderKeys.has(normProvider)) {
95
+ map.orphanedProviders.push({
96
+ endpoint: providerInfo.raw,
97
+ normalizedEndpoint: normProvider,
98
+ providers: providerInfo.members
99
+ });
100
+ }
101
+ }
102
+
103
+ map.stats.matchRate = consumers.size > 0
104
+ ? Math.round((map.matched.length / consumers.size) * 100)
105
+ : 100;
106
+
107
+ return map;
108
+ }
109
+
110
+ /**
111
+ * Normalize an endpoint for fuzzy matching
112
+ * "GET /api/v1/users/:id" → "GET /users/:param"
113
+ */
114
+ function normalizeForMatching(ep) {
115
+ const parts = ep.trim().split(/\s+/);
116
+ const method = (parts[0] || 'GET').toUpperCase();
117
+ let urlPath = parts.slice(1).join(' ');
118
+
119
+ // Strip protocol + host
120
+ urlPath = urlPath.replace(/^https?:\/\/[^/]+/, '');
121
+ // Strip query params
122
+ urlPath = urlPath.replace(/\?.*$/, '');
123
+ // Normalize path params: :id, {id}, /123 → :param
124
+ urlPath = urlPath.replace(/\/:\w+/g, '/:param');
125
+ urlPath = urlPath.replace(/\/\{[^}]+\}/g, '/:param');
126
+ urlPath = urlPath.replace(/\/\d+/g, '/:param');
127
+ // Strip /api/v1, /api/v2, etc.
128
+ urlPath = urlPath.replace(/\/api\/v\d+/, '/api');
129
+ // Ensure leading slash
130
+ if (!urlPath.startsWith('/')) urlPath = '/' + urlPath;
131
+ // Remove trailing slash
132
+ urlPath = urlPath.replace(/\/$/, '');
133
+
134
+ return `${method} ${urlPath}`;
135
+ }
136
+
137
+ /**
138
+ * Score how well two normalized endpoints match (0-1)
139
+ */
140
+ function matchScore(norm1, norm2) {
141
+ if (norm1 === norm2) return 1.0;
142
+
143
+ const [method1, path1] = splitMethodPath(norm1);
144
+ const [method2, path2] = splitMethodPath(norm2);
145
+
146
+ // Method must match
147
+ if (method1 !== method2) return 0;
148
+
149
+ // Exact path match
150
+ if (path1 === path2) return 1.0;
151
+
152
+ // Segment-by-segment comparison
153
+ const segs1 = path1.split('/').filter(Boolean);
154
+ const segs2 = path2.split('/').filter(Boolean);
155
+
156
+ if (segs1.length !== segs2.length) return 0.3;
157
+
158
+ let matchedSegments = 0;
159
+ for (let i = 0; i < segs1.length; i++) {
160
+ if (segs1[i] === segs2[i]) matchedSegments++;
161
+ else if (segs1[i] === ':param' || segs2[i] === ':param') matchedSegments += 0.8;
162
+ }
163
+
164
+ return matchedSegments / segs1.length;
165
+ }
166
+
167
+ function splitMethodPath(ep) {
168
+ const parts = ep.split(' ');
169
+ return [parts[0], parts.slice(1).join(' ')];
170
+ }
171
+
172
+ // ============================================================
173
+ // Orphan Detection (Criterion 2) — included in buildIntegrationMap
174
+ // ============================================================
175
+
176
+ // ============================================================
177
+ // Type Drift Detection (Criterion 3)
178
+ // ============================================================
179
+
180
+ /**
181
+ * Detect type drift between repos — same entity name, different fields
182
+ * @param {Object} manifest — workspace-manifest.json
183
+ * @param {Object} memberMetadata — { memberName: metadata } from readMemberMetadata
184
+ * @returns {Array} drift entries
185
+ */
186
+ function detectTypeDrift(manifest, memberMetadata) {
187
+ const drifts = [];
188
+ const typesByName = new Map(); // typeName → [{ member, fields, file }]
189
+
190
+ for (const [memberName, metadata] of Object.entries(memberMetadata)) {
191
+ const schemaIndex = metadata.schemaIndex;
192
+ if (!schemaIndex || !schemaIndex.models) continue;
193
+
194
+ for (const model of schemaIndex.models) {
195
+ const name = (model.name || '').toLowerCase();
196
+ if (!name) continue;
197
+
198
+ if (!typesByName.has(name)) typesByName.set(name, []);
199
+ typesByName.get(name).push({
200
+ member: memberName,
201
+ name: model.name,
202
+ fields: model.fields || model.columns || [],
203
+ fieldCount: model.fieldCount ?? (model.fields || model.columns || []).length,
204
+ file: model.file || model.source || 'unknown'
205
+ });
206
+ }
207
+ }
208
+
209
+ // Find types that appear in 2+ repos with different field counts
210
+ for (const [typeName, entries] of typesByName) {
211
+ if (entries.length < 2) continue;
212
+
213
+ const fieldCounts = new Set(entries.map(e => e.fieldCount));
214
+ if (fieldCounts.size > 1) {
215
+ drifts.push({
216
+ type: typeName,
217
+ entries: entries.map(e => ({
218
+ member: e.member,
219
+ name: e.name,
220
+ fieldCount: e.fieldCount,
221
+ file: e.file
222
+ })),
223
+ severity: fieldCounts.size > 2 ? 'high' : 'medium'
224
+ });
225
+ }
226
+ }
227
+
228
+ return drifts;
229
+ }
230
+
231
+ // ============================================================
232
+ // Contract Auto-Generation (Criterion 4)
233
+ // ============================================================
234
+
235
+ /**
236
+ * Auto-generate an OpenAPI contract from a provider's api-index
237
+ * @param {string} memberName — provider repo name
238
+ * @param {Object} apiIndex — api-index.json content
239
+ * @param {Object} schemaIndex — schema-index.json content (optional)
240
+ * @returns {Object} OpenAPI 3.0 spec
241
+ */
242
+ function generateOpenApiContract(memberName, apiIndex, schemaIndex) {
243
+ const spec = {
244
+ openapi: '3.0.3',
245
+ info: {
246
+ title: `${memberName} API`,
247
+ version: '1.0.0',
248
+ description: `Auto-generated contract from ${memberName} api-map`
249
+ },
250
+ paths: {},
251
+ components: {
252
+ schemas: {}
253
+ }
254
+ };
255
+
256
+ // Build paths from endpoints
257
+ for (const ep of (apiIndex.endpoints || [])) {
258
+ const routePath = (ep.route || ep.path || ep.endpoint || '').replace(/:(\w+)/g, '{$1}');
259
+ const method = (ep.method || 'get').toLowerCase();
260
+
261
+ if (!routePath) continue;
262
+ if (!spec.paths[routePath]) spec.paths[routePath] = {};
263
+
264
+ spec.paths[routePath][method] = {
265
+ summary: ep.description || ep.handler || `${method.toUpperCase()} ${routePath}`,
266
+ operationId: ep.handler || `${method}${routePath.replace(/[/{}-]/g, '_')}`,
267
+ parameters: extractPathParams(routePath),
268
+ responses: {
269
+ '200': { description: 'Success' },
270
+ '400': { description: 'Bad request' },
271
+ '404': { description: 'Not found' },
272
+ '500': { description: 'Server error' }
273
+ }
274
+ };
275
+
276
+ // Add request body for POST/PUT/PATCH
277
+ if (['post', 'put', 'patch'].includes(method)) {
278
+ spec.paths[routePath][method].requestBody = {
279
+ content: {
280
+ 'application/json': {
281
+ schema: { type: 'object' }
282
+ }
283
+ }
284
+ };
285
+ }
286
+ }
287
+
288
+ // Build component schemas from models
289
+ if (schemaIndex && schemaIndex.models) {
290
+ for (const model of schemaIndex.models) {
291
+ if (!model.name) continue;
292
+ const schema = {
293
+ type: 'object',
294
+ properties: {}
295
+ };
296
+
297
+ const fields = model.fields || model.columns || [];
298
+ for (const field of fields) {
299
+ const fieldName = typeof field === 'string' ? field : (field.name || field.column);
300
+ if (fieldName) {
301
+ schema.properties[fieldName] = {
302
+ type: typeof field === 'object' ? mapFieldType(field.type) : 'string'
303
+ };
304
+ }
305
+ }
306
+
307
+ spec.components.schemas[model.name] = schema;
308
+ }
309
+ }
310
+
311
+ return spec;
312
+ }
313
+
314
+ /**
315
+ * Extract path parameters from an OpenAPI path template
316
+ */
317
+ function extractPathParams(routePath) {
318
+ const params = [];
319
+ const matches = routePath.matchAll(/\{(\w+)\}/g);
320
+ for (const match of matches) {
321
+ params.push({
322
+ name: match[1],
323
+ in: 'path',
324
+ required: true,
325
+ schema: { type: 'string' }
326
+ });
327
+ }
328
+ return params;
329
+ }
330
+
331
+ /**
332
+ * Map a database/language field type to OpenAPI type
333
+ */
334
+ function mapFieldType(fieldType) {
335
+ if (!fieldType) return 'string';
336
+ const t = fieldType.toLowerCase();
337
+ if (t.includes('int') || t.includes('number') || t.includes('float') || t.includes('decimal')) return 'number';
338
+ if (t.includes('bool')) return 'boolean';
339
+ if (t.includes('date') || t.includes('time')) return 'string';
340
+ if (t.includes('json') || t.includes('object')) return 'object';
341
+ if (t.includes('array') || t.includes('list')) return 'array';
342
+ return 'string';
343
+ }
344
+
345
+ /**
346
+ * Generate a TypeScript type definitions file from schemas
347
+ * @param {Object} schemaIndex
348
+ * @returns {string} TypeScript content
349
+ */
350
+ function generateTypeScriptContract(schemaIndex) {
351
+ const lines = ['// Auto-generated shared type definitions', '// Do not edit — regenerate with `flow workspace sync`', ''];
352
+
353
+ if (!schemaIndex || !schemaIndex.models) return lines.join('\n');
354
+
355
+ for (const model of schemaIndex.models) {
356
+ if (!model.name) continue;
357
+ lines.push(`export interface ${model.name} {`);
358
+
359
+ const fields = model.fields || model.columns || [];
360
+ for (const field of fields) {
361
+ const name = typeof field === 'string' ? field : (field.name || field.column);
362
+ const type = typeof field === 'object' ? mapToTsType(field.type) : 'string';
363
+ const optional = typeof field === 'object' && field.nullable ? '?' : '';
364
+ if (name) lines.push(` ${name}${optional}: ${type};`);
365
+ }
366
+
367
+ lines.push('}');
368
+ lines.push('');
369
+ }
370
+
371
+ return lines.join('\n');
372
+ }
373
+
374
+ function mapToTsType(fieldType) {
375
+ if (!fieldType) return 'string';
376
+ const t = fieldType.toLowerCase();
377
+ if (t.includes('int') || t.includes('float') || t.includes('decimal') || t.includes('number')) return 'number';
378
+ if (t.includes('bool')) return 'boolean';
379
+ if (t.includes('date') || t.includes('time')) return 'string';
380
+ if (t.includes('json') || t.includes('object')) return 'Record<string, unknown>';
381
+ if (t.includes('array') || t.includes('list')) return 'unknown[]';
382
+ return 'string';
383
+ }
384
+
385
+ // ============================================================
386
+ // Contract Versioning (Criterion 5)
387
+ // ============================================================
388
+
389
+ /**
390
+ * Compute a checksum for a contract file
391
+ * @param {string} content
392
+ * @returns {string} SHA-256 hash (first 12 chars)
393
+ */
394
+ function contractChecksum(content) {
395
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
396
+ }
397
+
398
+ /**
399
+ * Track contract versions
400
+ * @param {string} workspaceRoot
401
+ * @param {string} contractName — e.g., "api-v1"
402
+ * @param {string} content — contract content
403
+ * @param {string} changedBy — which repo triggered the change
404
+ * @param {string} reason — why the contract changed
405
+ * @returns {{ isNew: boolean, changed: boolean, version: Object }}
406
+ */
407
+ function trackContractVersion(workspaceRoot, contractName, content, changedBy, reason) {
408
+ const versionsPath = path.join(workspaceRoot, '.workspace', 'state', 'contract-versions.json');
409
+
410
+ let versions = { contracts: {} };
411
+ try {
412
+ if (fs.existsSync(versionsPath)) {
413
+ versions = JSON.parse(fs.readFileSync(versionsPath, 'utf-8'));
414
+ }
415
+ } catch (_err) {
416
+ versions = { contracts: {} };
417
+ }
418
+
419
+ const checksum = contractChecksum(content);
420
+ const existing = versions.contracts[contractName];
421
+
422
+ if (!existing) {
423
+ // New contract
424
+ versions.contracts[contractName] = {
425
+ currentChecksum: checksum,
426
+ history: [{
427
+ checksum,
428
+ changedBy,
429
+ reason: reason || 'Initial generation',
430
+ timestamp: new Date().toISOString()
431
+ }]
432
+ };
433
+ fs.writeFileSync(versionsPath, JSON.stringify(versions, null, 2));
434
+ return { isNew: true, changed: false, version: versions.contracts[contractName] };
435
+ }
436
+
437
+ if (existing.currentChecksum === checksum) {
438
+ return { isNew: false, changed: false, version: existing };
439
+ }
440
+
441
+ // Contract changed
442
+ existing.currentChecksum = checksum;
443
+ if (!existing.history) existing.history = [];
444
+ existing.history.unshift({
445
+ checksum,
446
+ changedBy,
447
+ reason: reason || 'Updated',
448
+ timestamp: new Date().toISOString()
449
+ });
450
+
451
+ // Keep last 50 versions
452
+ if (existing.history.length > 50) {
453
+ existing.history = existing.history.slice(0, 50);
454
+ }
455
+
456
+ fs.writeFileSync(versionsPath, JSON.stringify(versions, null, 2));
457
+ return { isNew: false, changed: true, version: existing };
458
+ }
459
+
460
+ /**
461
+ * Generate a contract changelog markdown
462
+ * @param {string} workspaceRoot
463
+ * @returns {string} changelog markdown
464
+ */
465
+ function generateContractChangelog(workspaceRoot) {
466
+ const versionsPath = path.join(workspaceRoot, '.workspace', 'state', 'contract-versions.json');
467
+ if (!fs.existsSync(versionsPath)) return '# Contract Changelog\n\nNo contracts tracked yet.\n';
468
+
469
+ let versions;
470
+ try {
471
+ versions = JSON.parse(fs.readFileSync(versionsPath, 'utf-8'));
472
+ } catch (_err) {
473
+ return '# Contract Changelog\n\nError reading contract versions file.\n';
474
+ }
475
+ const lines = ['# Contract Changelog\n'];
476
+
477
+ for (const [name, contract] of Object.entries(versions.contracts || {})) {
478
+ lines.push(`## ${name}\n`);
479
+ lines.push(`Current checksum: \`${contract.currentChecksum}\`\n`);
480
+
481
+ for (const entry of (contract.history || []).slice(0, 20)) {
482
+ lines.push(`- **${entry.timestamp}** by \`${entry.changedBy}\`: ${entry.reason} (\`${entry.checksum}\`)`);
483
+ }
484
+ lines.push('');
485
+ }
486
+
487
+ return lines.join('\n');
488
+ }
489
+
490
+ // ============================================================
491
+ // Multi-Format Support (Criterion 6)
492
+ // ============================================================
493
+
494
+ /**
495
+ * Detect contract format from file extension or content
496
+ * @param {string} filePath
497
+ * @returns {'openapi'|'graphql'|'typescript'|'jsonschema'|'unknown'}
498
+ */
499
+ function detectContractFormat(filePath) {
500
+ const ext = path.extname(filePath).toLowerCase();
501
+
502
+ if (ext === '.yaml' || ext === '.yml') {
503
+ // Could be OpenAPI or other YAML
504
+ try {
505
+ const content = fs.readFileSync(filePath, 'utf-8');
506
+ if (content.includes('openapi:') || content.includes('swagger:')) return 'openapi';
507
+ } catch (_err) {
508
+ // Non-critical
509
+ }
510
+ return 'openapi'; // Default YAML = OpenAPI
511
+ }
512
+
513
+ if (ext === '.graphql' || ext === '.gql') return 'graphql';
514
+ if (ext === '.ts' || ext === '.d.ts') return 'typescript';
515
+ if (ext === '.json') {
516
+ try {
517
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
518
+ if (content.openapi || content.swagger) return 'openapi';
519
+ if (content.$schema?.includes('json-schema')) return 'jsonschema';
520
+ if (content.type || content.properties) return 'jsonschema';
521
+ } catch (_err) {
522
+ // Non-critical
523
+ }
524
+ return 'jsonschema';
525
+ }
526
+
527
+ return 'unknown';
528
+ }
529
+
530
+ /**
531
+ * Write a contract in the specified format
532
+ * @param {string} workspaceRoot
533
+ * @param {string} contractName
534
+ * @param {string} format — 'openapi'|'typescript'|'jsonschema'
535
+ * @param {Object|string} content
536
+ * @param {string} changedBy
537
+ * @param {string} reason
538
+ */
539
+ function writeContract(workspaceRoot, contractName, format, content, changedBy, reason) {
540
+ if (contractName.includes('/') || contractName.includes('\\') || contractName.includes('..')) {
541
+ throw new Error('Invalid contract name');
542
+ }
543
+ const contractsDir = path.join(workspaceRoot, '.workspace', 'contracts');
544
+ fs.mkdirSync(contractsDir, { recursive: true });
545
+
546
+ let filePath;
547
+ let serialized;
548
+
549
+ switch (format) {
550
+ case 'openapi':
551
+ filePath = path.join(contractsDir, `${contractName}.json`);
552
+ serialized = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
553
+ break;
554
+ case 'typescript':
555
+ filePath = path.join(contractsDir, `${contractName}.d.ts`);
556
+ if (typeof content !== 'string') throw new Error('TypeScript contract content must be a pre-generated string. Use generateTypeScriptContract() first.');
557
+ serialized = content;
558
+ break;
559
+ case 'jsonschema':
560
+ filePath = path.join(contractsDir, `${contractName}.schema.json`);
561
+ serialized = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
562
+ break;
563
+ default:
564
+ filePath = path.join(contractsDir, `${contractName}.json`);
565
+ serialized = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
566
+ }
567
+
568
+ fs.writeFileSync(filePath, serialized);
569
+
570
+ // Track version
571
+ return trackContractVersion(workspaceRoot, contractName, serialized, changedBy, reason);
572
+ }
573
+
574
+ // ============================================================
575
+ // Exports
576
+ // ============================================================
577
+
578
+ module.exports = {
579
+ // Integration map
580
+ buildIntegrationMap,
581
+ normalizeForMatching,
582
+ matchScore,
583
+
584
+ // Type drift
585
+ detectTypeDrift,
586
+
587
+ // Contract generation
588
+ generateOpenApiContract,
589
+ generateTypeScriptContract,
590
+
591
+ // Contract versioning
592
+ contractChecksum,
593
+ trackContractVersion,
594
+ generateContractChangelog,
595
+
596
+ // Multi-format
597
+ detectContractFormat,
598
+ writeContract
599
+ };