wogiflow 2.4.3 → 2.4.4
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-start.md +124 -0
- package/.claude/docs/claude-code-compatibility.md +24 -0
- package/.claude/docs/explore-agents.md +11 -0
- package/.claude/settings.json +11 -0
- package/bin/flow +11 -1
- package/lib/workspace-contracts.js +599 -0
- package/lib/workspace-intelligence.js +600 -0
- package/lib/workspace-messages.js +441 -0
- package/lib/workspace-routing.js +485 -0
- package/lib/workspace-sync.js +339 -0
- package/lib/workspace.js +1073 -0
- package/package.json +1 -1
- package/scripts/flow-config-defaults.js +28 -0
- package/scripts/flow-eval-calibration.js +257 -0
- package/scripts/flow-eval-judge.js +10 -1
- package/scripts/flow-eval.js +9 -0
- package/scripts/hooks/adapters/claude-code.js +29 -0
- package/scripts/hooks/core/task-created.js +83 -0
- package/scripts/hooks/entry/claude-code/task-created.js +15 -0
- package/scripts/postinstall.js +2 -0
|
@@ -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
|
+
};
|