@hustle-together/api-dev-tools 3.0.0 → 3.2.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/README.md +71 -0
- package/bin/cli.js +184 -14
- package/demo/audio/generate-all-narrations.js +124 -59
- package/demo/audio/generate-narration.js +120 -56
- package/demo/audio/narration-adam-timing.json +3086 -2077
- package/demo/audio/narration-adam.mp3 +0 -0
- package/demo/audio/narration-creature-timing.json +3094 -2085
- package/demo/audio/narration-creature.mp3 +0 -0
- package/demo/audio/narration-gaming-timing.json +3091 -2082
- package/demo/audio/narration-gaming.mp3 +0 -0
- package/demo/audio/narration-hope-timing.json +3072 -2063
- package/demo/audio/narration-hope.mp3 +0 -0
- package/demo/audio/narration-mark-timing.json +3090 -2081
- package/demo/audio/narration-mark.mp3 +0 -0
- package/demo/audio/voices-manifest.json +16 -16
- package/demo/workflow-demo.html +1528 -411
- package/hooks/api-workflow-check.py +2 -0
- package/hooks/enforce-deep-research.py +180 -0
- package/hooks/enforce-disambiguation.py +149 -0
- package/hooks/enforce-documentation.py +187 -0
- package/hooks/enforce-environment.py +249 -0
- package/hooks/enforce-interview.py +64 -1
- package/hooks/enforce-refactor.py +187 -0
- package/hooks/enforce-research.py +93 -46
- package/hooks/enforce-schema.py +186 -0
- package/hooks/enforce-scope.py +156 -0
- package/hooks/enforce-tdd-red.py +246 -0
- package/hooks/enforce-verify.py +186 -0
- package/hooks/verify-after-green.py +136 -6
- package/package.json +2 -1
- package/scripts/collect-test-results.ts +404 -0
- package/scripts/extract-parameters.ts +483 -0
- package/scripts/generate-test-manifest.ts +520 -0
- package/templates/CLAUDE-SECTION.md +84 -0
- package/templates/api-dev-state.json +45 -5
- package/templates/api-test/page.tsx +315 -0
- package/templates/api-test/test-structure/route.ts +269 -0
- package/templates/settings.json +36 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Generate Test Manifest Script
|
|
4
|
+
*
|
|
5
|
+
* Programmatically generates api-tests-manifest.json by parsing:
|
|
6
|
+
* 1. Vitest test files (*.test.ts, *.spec.ts)
|
|
7
|
+
* 2. Zod schemas (for parameter extraction)
|
|
8
|
+
* 3. API route files (for endpoint metadata)
|
|
9
|
+
* 4. Interview state file (.claude/api-dev-state.json)
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT: This is 100% programmatic - NO LLM involvement.
|
|
12
|
+
* Tests are the SOURCE OF TRUTH.
|
|
13
|
+
*
|
|
14
|
+
* @generated by @hustle-together/api-dev-tools v3.0
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
// ============================================
|
|
21
|
+
// Types
|
|
22
|
+
// ============================================
|
|
23
|
+
|
|
24
|
+
interface TestCase {
|
|
25
|
+
name: string;
|
|
26
|
+
line: number;
|
|
27
|
+
description?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TestGroup {
|
|
31
|
+
name: string;
|
|
32
|
+
tests: TestCase[];
|
|
33
|
+
groups: TestGroup[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ParameterInfo {
|
|
37
|
+
name: string;
|
|
38
|
+
type: string;
|
|
39
|
+
required: boolean;
|
|
40
|
+
description?: string;
|
|
41
|
+
default?: unknown;
|
|
42
|
+
enum?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface EndpointManifest {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
endpoint: string;
|
|
49
|
+
method: string;
|
|
50
|
+
description: string;
|
|
51
|
+
category: string;
|
|
52
|
+
parameters: {
|
|
53
|
+
query?: ParameterInfo[];
|
|
54
|
+
body?: ParameterInfo[];
|
|
55
|
+
headers?: ParameterInfo[];
|
|
56
|
+
};
|
|
57
|
+
responses: {
|
|
58
|
+
success: { status: number; description: string };
|
|
59
|
+
error: { status: number; description: string }[];
|
|
60
|
+
};
|
|
61
|
+
testFile: string;
|
|
62
|
+
testCount: number;
|
|
63
|
+
testCases: string[];
|
|
64
|
+
interviewDecisions?: Record<string, string>;
|
|
65
|
+
generatedAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ManifestOutput {
|
|
69
|
+
version: string;
|
|
70
|
+
generatedAt: string;
|
|
71
|
+
endpoints: EndpointManifest[];
|
|
72
|
+
summary: {
|
|
73
|
+
totalEndpoints: number;
|
|
74
|
+
totalTests: number;
|
|
75
|
+
categories: string[];
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================
|
|
80
|
+
// File Discovery
|
|
81
|
+
// ============================================
|
|
82
|
+
|
|
83
|
+
function findFiles(baseDir: string, pattern: RegExp, exclude: string[] = ['node_modules', '.git', 'dist']): string[] {
|
|
84
|
+
const files: string[] = [];
|
|
85
|
+
|
|
86
|
+
function walk(dir: string) {
|
|
87
|
+
try {
|
|
88
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (exclude.includes(entry.name)) continue;
|
|
91
|
+
|
|
92
|
+
const fullPath = path.join(dir, entry.name);
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
walk(fullPath);
|
|
95
|
+
} else if (entry.isFile() && pattern.test(entry.name)) {
|
|
96
|
+
files.push(fullPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Skip directories we can't read
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
walk(baseDir);
|
|
105
|
+
return files;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================
|
|
109
|
+
// Test File Parser
|
|
110
|
+
// ============================================
|
|
111
|
+
|
|
112
|
+
function parseTestFile(filePath: string): { groups: TestGroup[]; endpoint?: string; method?: string } {
|
|
113
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
114
|
+
const lines = content.split('\n');
|
|
115
|
+
|
|
116
|
+
const rootGroups: TestGroup[] = [];
|
|
117
|
+
const groupStack: TestGroup[] = [];
|
|
118
|
+
|
|
119
|
+
let braceCount = 0;
|
|
120
|
+
let describeStartBrace = 0;
|
|
121
|
+
|
|
122
|
+
// Extract endpoint from test file comments or describe blocks
|
|
123
|
+
let endpoint: string | undefined;
|
|
124
|
+
let method: string | undefined;
|
|
125
|
+
|
|
126
|
+
// Look for endpoint pattern in describe or comments
|
|
127
|
+
const endpointMatch = content.match(/(?:\/api\/[\w\/-]+)/);
|
|
128
|
+
if (endpointMatch) {
|
|
129
|
+
endpoint = endpointMatch[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Look for HTTP method
|
|
133
|
+
const methodMatch = content.match(/(?:GET|POST|PUT|DELETE|PATCH)\s*(?:request|endpoint)?/i);
|
|
134
|
+
if (methodMatch) {
|
|
135
|
+
method = methodMatch[0].split(/\s/)[0].toUpperCase();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
const line = lines[i];
|
|
140
|
+
const lineNum = i + 1;
|
|
141
|
+
|
|
142
|
+
// Match describe blocks
|
|
143
|
+
const describeMatch = line.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
|
|
144
|
+
if (describeMatch) {
|
|
145
|
+
const group: TestGroup = {
|
|
146
|
+
name: describeMatch[1],
|
|
147
|
+
tests: [],
|
|
148
|
+
groups: []
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (groupStack.length > 0) {
|
|
152
|
+
groupStack[groupStack.length - 1].groups.push(group);
|
|
153
|
+
} else {
|
|
154
|
+
rootGroups.push(group);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
groupStack.push(group);
|
|
158
|
+
describeStartBrace = braceCount;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Match it/test blocks
|
|
162
|
+
const testMatch = line.match(/(?:it|test)\s*\(\s*['"`]([^'"`]+)['"`]/);
|
|
163
|
+
if (testMatch && groupStack.length > 0) {
|
|
164
|
+
const testCase: TestCase = {
|
|
165
|
+
name: testMatch[1],
|
|
166
|
+
line: lineNum
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Extract description from test name
|
|
170
|
+
if (testCase.name.includes('should')) {
|
|
171
|
+
testCase.description = testCase.name;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
groupStack[groupStack.length - 1].tests.push(testCase);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Track braces for scope
|
|
178
|
+
const openBraces = (line.match(/{/g) || []).length;
|
|
179
|
+
const closeBraces = (line.match(/}/g) || []).length;
|
|
180
|
+
braceCount += openBraces - closeBraces;
|
|
181
|
+
|
|
182
|
+
// Pop stack when scope closes
|
|
183
|
+
if (braceCount <= describeStartBrace && groupStack.length > 0) {
|
|
184
|
+
groupStack.pop();
|
|
185
|
+
if (groupStack.length > 0) {
|
|
186
|
+
describeStartBrace = braceCount;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { groups: rootGroups, endpoint, method };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================
|
|
195
|
+
// Zod Schema Parser
|
|
196
|
+
// ============================================
|
|
197
|
+
|
|
198
|
+
function parseZodSchema(filePath: string): ParameterInfo[] {
|
|
199
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
200
|
+
const parameters: ParameterInfo[] = [];
|
|
201
|
+
|
|
202
|
+
// Match z.object({ ... }) blocks
|
|
203
|
+
const objectMatch = content.match(/z\.object\s*\(\s*\{([^}]+)\}/gs);
|
|
204
|
+
if (!objectMatch) return parameters;
|
|
205
|
+
|
|
206
|
+
for (const block of objectMatch) {
|
|
207
|
+
// Extract individual field definitions
|
|
208
|
+
// Pattern: fieldName: z.type().modifier()
|
|
209
|
+
const fieldRegex = /(\w+)\s*:\s*z\.(\w+)\s*\(\s*([^)]*)\s*\)([^,\n]*)/g;
|
|
210
|
+
let match;
|
|
211
|
+
|
|
212
|
+
while ((match = fieldRegex.exec(block)) !== null) {
|
|
213
|
+
const [, name, type, typeArgs, modifiers] = match;
|
|
214
|
+
|
|
215
|
+
const param: ParameterInfo = {
|
|
216
|
+
name,
|
|
217
|
+
type: mapZodType(type),
|
|
218
|
+
required: !modifiers.includes('.optional()') && !modifiers.includes('.nullable()')
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Extract description from .describe()
|
|
222
|
+
const descMatch = modifiers.match(/\.describe\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
|
|
223
|
+
if (descMatch) {
|
|
224
|
+
param.description = descMatch[1];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Extract default value
|
|
228
|
+
const defaultMatch = modifiers.match(/\.default\s*\(\s*([^)]+)\s*\)/);
|
|
229
|
+
if (defaultMatch) {
|
|
230
|
+
try {
|
|
231
|
+
param.default = JSON.parse(defaultMatch[1]);
|
|
232
|
+
} catch {
|
|
233
|
+
param.default = defaultMatch[1];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Extract enum values for z.enum()
|
|
238
|
+
if (type === 'enum' && typeArgs) {
|
|
239
|
+
const enumMatch = typeArgs.match(/\[([^\]]+)\]/);
|
|
240
|
+
if (enumMatch) {
|
|
241
|
+
param.enum = enumMatch[1]
|
|
242
|
+
.split(',')
|
|
243
|
+
.map(s => s.trim().replace(/['"`]/g, ''));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
parameters.push(param);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return parameters;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function mapZodType(zodType: string): string {
|
|
255
|
+
const typeMap: Record<string, string> = {
|
|
256
|
+
'string': 'string',
|
|
257
|
+
'number': 'number',
|
|
258
|
+
'boolean': 'boolean',
|
|
259
|
+
'array': 'array',
|
|
260
|
+
'object': 'object',
|
|
261
|
+
'enum': 'enum',
|
|
262
|
+
'literal': 'literal',
|
|
263
|
+
'union': 'union',
|
|
264
|
+
'date': 'date',
|
|
265
|
+
'any': 'any',
|
|
266
|
+
'unknown': 'unknown',
|
|
267
|
+
'null': 'null',
|
|
268
|
+
'undefined': 'undefined',
|
|
269
|
+
'void': 'void',
|
|
270
|
+
'never': 'never',
|
|
271
|
+
'bigint': 'bigint',
|
|
272
|
+
'symbol': 'symbol'
|
|
273
|
+
};
|
|
274
|
+
return typeMap[zodType] || zodType;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================
|
|
278
|
+
// Route File Parser
|
|
279
|
+
// ============================================
|
|
280
|
+
|
|
281
|
+
function parseRouteFile(filePath: string): { methods: string[]; description?: string } {
|
|
282
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
283
|
+
const methods: string[] = [];
|
|
284
|
+
|
|
285
|
+
// Find exported HTTP method handlers
|
|
286
|
+
const methodPatterns = [
|
|
287
|
+
/export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)/g,
|
|
288
|
+
/export\s+const\s+(GET|POST|PUT|DELETE|PATCH)\s*=/g
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
for (const pattern of methodPatterns) {
|
|
292
|
+
let match;
|
|
293
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
294
|
+
if (!methods.includes(match[1])) {
|
|
295
|
+
methods.push(match[1]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Extract description from JSDoc
|
|
301
|
+
const jsdocMatch = content.match(/\/\*\*\s*\n\s*\*\s*([^\n*]+)/);
|
|
302
|
+
const description = jsdocMatch ? jsdocMatch[1].trim() : undefined;
|
|
303
|
+
|
|
304
|
+
return { methods, description };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ============================================
|
|
308
|
+
// Interview State Reader
|
|
309
|
+
// ============================================
|
|
310
|
+
|
|
311
|
+
interface InterviewState {
|
|
312
|
+
endpoint: string;
|
|
313
|
+
decisions: Record<string, string>;
|
|
314
|
+
phase: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readInterviewState(baseDir: string): Map<string, Record<string, string>> {
|
|
318
|
+
const stateFile = path.join(baseDir, '.claude', 'api-dev-state.json');
|
|
319
|
+
const decisions = new Map<string, Record<string, string>>();
|
|
320
|
+
|
|
321
|
+
if (!fs.existsSync(stateFile)) {
|
|
322
|
+
return decisions;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const content = fs.readFileSync(stateFile, 'utf-8');
|
|
327
|
+
const state = JSON.parse(content);
|
|
328
|
+
|
|
329
|
+
if (Array.isArray(state.endpoints)) {
|
|
330
|
+
for (const ep of state.endpoints) {
|
|
331
|
+
if (ep.endpoint && ep.decisions) {
|
|
332
|
+
decisions.set(ep.endpoint, ep.decisions);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
// Invalid state file, return empty
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return decisions;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ============================================
|
|
344
|
+
// Main Generator
|
|
345
|
+
// ============================================
|
|
346
|
+
|
|
347
|
+
function generateManifest(baseDir: string): ManifestOutput {
|
|
348
|
+
console.log('🔍 Scanning for test files...');
|
|
349
|
+
|
|
350
|
+
// Find all test files
|
|
351
|
+
const testFiles = findFiles(baseDir, /\.(test|spec)\.(ts|tsx)$/);
|
|
352
|
+
console.log(` Found ${testFiles.length} test files`);
|
|
353
|
+
|
|
354
|
+
// Find all schema files
|
|
355
|
+
const schemaFiles = findFiles(baseDir, /schema.*\.ts$/);
|
|
356
|
+
console.log(` Found ${schemaFiles.length} schema files`);
|
|
357
|
+
|
|
358
|
+
// Read interview decisions
|
|
359
|
+
const interviewDecisions = readInterviewState(baseDir);
|
|
360
|
+
console.log(` Found ${interviewDecisions.size} interview records`);
|
|
361
|
+
|
|
362
|
+
const endpoints: EndpointManifest[] = [];
|
|
363
|
+
const categories = new Set<string>();
|
|
364
|
+
let totalTests = 0;
|
|
365
|
+
|
|
366
|
+
for (const testFile of testFiles) {
|
|
367
|
+
const relativePath = path.relative(baseDir, testFile);
|
|
368
|
+
console.log(`\n📄 Processing: ${relativePath}`);
|
|
369
|
+
|
|
370
|
+
// Parse test file
|
|
371
|
+
const { groups, endpoint, method } = parseTestFile(testFile);
|
|
372
|
+
|
|
373
|
+
// Count tests and extract names
|
|
374
|
+
const testCases: string[] = [];
|
|
375
|
+
function countTests(grps: TestGroup[]): number {
|
|
376
|
+
let count = 0;
|
|
377
|
+
for (const g of grps) {
|
|
378
|
+
for (const t of g.tests) {
|
|
379
|
+
count++;
|
|
380
|
+
testCases.push(t.name);
|
|
381
|
+
}
|
|
382
|
+
count += countTests(g.groups);
|
|
383
|
+
}
|
|
384
|
+
return count;
|
|
385
|
+
}
|
|
386
|
+
const testCount = countTests(groups);
|
|
387
|
+
totalTests += testCount;
|
|
388
|
+
|
|
389
|
+
if (!endpoint) {
|
|
390
|
+
console.log(` ⚠️ No endpoint detected, skipping`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Determine category from path
|
|
395
|
+
const pathParts = relativePath.split(path.sep);
|
|
396
|
+
let category = 'General';
|
|
397
|
+
if (pathParts.includes('api')) {
|
|
398
|
+
const apiIndex = pathParts.indexOf('api');
|
|
399
|
+
if (apiIndex + 1 < pathParts.length) {
|
|
400
|
+
category = pathParts[apiIndex + 1];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
categories.add(category);
|
|
404
|
+
|
|
405
|
+
// Find matching route file
|
|
406
|
+
const routeDir = path.dirname(testFile).replace('__tests__', '').replace('.test', '');
|
|
407
|
+
const possibleRoutes = [
|
|
408
|
+
path.join(routeDir, 'route.ts'),
|
|
409
|
+
path.join(routeDir, 'route.tsx'),
|
|
410
|
+
testFile.replace(/\.(test|spec)\.(ts|tsx)$/, '.ts')
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
let routeInfo = { methods: [method || 'GET'], description: undefined as string | undefined };
|
|
414
|
+
for (const routePath of possibleRoutes) {
|
|
415
|
+
if (fs.existsSync(routePath)) {
|
|
416
|
+
routeInfo = parseRouteFile(routePath);
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Find matching schema file
|
|
422
|
+
const schemaBaseName = path.basename(testFile).replace(/\.(test|spec)\.(ts|tsx)$/, '');
|
|
423
|
+
const matchingSchemas = schemaFiles.filter(s =>
|
|
424
|
+
s.includes(schemaBaseName) || s.includes(category.toLowerCase())
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
let parameters: ParameterInfo[] = [];
|
|
428
|
+
for (const schemaFile of matchingSchemas) {
|
|
429
|
+
parameters = [...parameters, ...parseZodSchema(schemaFile)];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Get interview decisions if available
|
|
433
|
+
const decisions = interviewDecisions.get(endpoint);
|
|
434
|
+
|
|
435
|
+
// Generate endpoint ID
|
|
436
|
+
const endpointId = endpoint
|
|
437
|
+
.replace(/^\/api\//, '')
|
|
438
|
+
.replace(/\//g, '-')
|
|
439
|
+
.replace(/[^a-z0-9-]/gi, '');
|
|
440
|
+
|
|
441
|
+
const manifest: EndpointManifest = {
|
|
442
|
+
id: endpointId,
|
|
443
|
+
name: groups[0]?.name || endpointId,
|
|
444
|
+
endpoint,
|
|
445
|
+
method: routeInfo.methods[0] || method || 'GET',
|
|
446
|
+
description: routeInfo.description || `API endpoint: ${endpoint}`,
|
|
447
|
+
category,
|
|
448
|
+
parameters: {
|
|
449
|
+
query: parameters.filter(p => !p.name.startsWith('body')),
|
|
450
|
+
body: parameters.filter(p => p.name.startsWith('body') || p.name === 'data'),
|
|
451
|
+
headers: []
|
|
452
|
+
},
|
|
453
|
+
responses: {
|
|
454
|
+
success: { status: 200, description: 'Successful response' },
|
|
455
|
+
error: [
|
|
456
|
+
{ status: 400, description: 'Bad request' },
|
|
457
|
+
{ status: 500, description: 'Internal server error' }
|
|
458
|
+
]
|
|
459
|
+
},
|
|
460
|
+
testFile: relativePath,
|
|
461
|
+
testCount,
|
|
462
|
+
testCases,
|
|
463
|
+
interviewDecisions: decisions,
|
|
464
|
+
generatedAt: new Date().toISOString()
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
endpoints.push(manifest);
|
|
468
|
+
console.log(` ✅ Generated manifest for ${endpoint} (${testCount} tests)`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
version: '3.0.0',
|
|
473
|
+
generatedAt: new Date().toISOString(),
|
|
474
|
+
endpoints,
|
|
475
|
+
summary: {
|
|
476
|
+
totalEndpoints: endpoints.length,
|
|
477
|
+
totalTests,
|
|
478
|
+
categories: Array.from(categories)
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ============================================
|
|
484
|
+
// CLI Entry Point
|
|
485
|
+
// ============================================
|
|
486
|
+
|
|
487
|
+
function main() {
|
|
488
|
+
const args = process.argv.slice(2);
|
|
489
|
+
const baseDir = args[0] || process.cwd();
|
|
490
|
+
const outputPath = args[1] || path.join(baseDir, 'src', 'app', 'api-test', 'api-tests-manifest.json');
|
|
491
|
+
|
|
492
|
+
console.log('═══════════════════════════════════════════════════════════════');
|
|
493
|
+
console.log(' 📋 API Test Manifest Generator');
|
|
494
|
+
console.log(' @hustle-together/api-dev-tools v3.0');
|
|
495
|
+
console.log('═══════════════════════════════════════════════════════════════');
|
|
496
|
+
console.log(`\n📁 Base directory: ${baseDir}`);
|
|
497
|
+
console.log(`📄 Output file: ${outputPath}\n`);
|
|
498
|
+
|
|
499
|
+
const manifest = generateManifest(baseDir);
|
|
500
|
+
|
|
501
|
+
// Ensure output directory exists
|
|
502
|
+
const outputDir = path.dirname(outputPath);
|
|
503
|
+
if (!fs.existsSync(outputDir)) {
|
|
504
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Write manifest
|
|
508
|
+
fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
|
|
509
|
+
|
|
510
|
+
console.log('\n═══════════════════════════════════════════════════════════════');
|
|
511
|
+
console.log(' ✅ Manifest generated successfully!');
|
|
512
|
+
console.log('═══════════════════════════════════════════════════════════════');
|
|
513
|
+
console.log(`\n📊 Summary:`);
|
|
514
|
+
console.log(` • Endpoints: ${manifest.summary.totalEndpoints}`);
|
|
515
|
+
console.log(` • Tests: ${manifest.summary.totalTests}`);
|
|
516
|
+
console.log(` • Categories: ${manifest.summary.categories.join(', ')}`);
|
|
517
|
+
console.log(`\n📄 Output: ${outputPath}\n`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
main();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
## API Development Workflow (v3.0)
|
|
2
|
+
|
|
3
|
+
This project uses **@hustle-together/api-dev-tools** for interview-driven, research-first API development.
|
|
4
|
+
|
|
5
|
+
### Available Commands
|
|
6
|
+
|
|
7
|
+
| Command | Purpose |
|
|
8
|
+
|---------|---------|
|
|
9
|
+
| `/api-create [endpoint]` | Complete 12-phase workflow |
|
|
10
|
+
| `/api-interview [endpoint]` | Questions FROM research findings |
|
|
11
|
+
| `/api-research [library]` | Adaptive propose-approve research |
|
|
12
|
+
| `/api-verify [endpoint]` | Re-research and verify implementation |
|
|
13
|
+
| `/api-env [endpoint]` | Check API keys |
|
|
14
|
+
| `/api-status [endpoint]` | Track progress |
|
|
15
|
+
|
|
16
|
+
### 12-Phase Flow
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Phase 0: DISAMBIGUATION - Clarify ambiguous terms before research
|
|
20
|
+
Phase 1: SCOPE - Confirm understanding of endpoint
|
|
21
|
+
Phase 2: INITIAL RESEARCH - 2-3 targeted searches (Context7, WebSearch)
|
|
22
|
+
Phase 3: INTERVIEW - Questions generated FROM discovered params
|
|
23
|
+
Phase 4: DEEP RESEARCH - Propose additional searches based on answers
|
|
24
|
+
Phase 5: SCHEMA - Create Zod schema from research + interview
|
|
25
|
+
Phase 6: ENVIRONMENT - Verify API keys exist
|
|
26
|
+
Phase 7: TDD RED - Write failing tests from schema
|
|
27
|
+
Phase 8: TDD GREEN - Minimal implementation to pass tests
|
|
28
|
+
Phase 9: VERIFY - Re-research docs, compare to implementation
|
|
29
|
+
Phase 10: TDD REFACTOR - Clean up code while tests pass
|
|
30
|
+
Phase 11: DOCUMENTATION - Update manifests, cache research
|
|
31
|
+
Phase 12: COMPLETION - Final verification, commit
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Key Principles
|
|
35
|
+
|
|
36
|
+
1. **Loop Until Green** - Every verification phase loops back if not successful
|
|
37
|
+
2. **Questions FROM Research** - Never use generic template questions
|
|
38
|
+
3. **Adaptive Research** - Propose searches based on context, not shotgun
|
|
39
|
+
4. **7-Turn Re-grounding** - Context injected every 7 turns to prevent dilution
|
|
40
|
+
5. **Verify After Green** - Re-research to catch memory-based implementation errors
|
|
41
|
+
|
|
42
|
+
### State Tracking
|
|
43
|
+
|
|
44
|
+
All progress is tracked in `.claude/api-dev-state.json`:
|
|
45
|
+
- Current phase and status for each
|
|
46
|
+
- Interview decisions (injected during implementation)
|
|
47
|
+
- Research sources with freshness tracking
|
|
48
|
+
- Turn count for re-grounding
|
|
49
|
+
|
|
50
|
+
### Research Cache
|
|
51
|
+
|
|
52
|
+
Research is cached in `.claude/research/` with 7-day freshness:
|
|
53
|
+
- `index.json` - Freshness tracking
|
|
54
|
+
- `[api-name]/CURRENT.md` - Latest research
|
|
55
|
+
- Stale research (>7 days) triggers re-research prompt
|
|
56
|
+
|
|
57
|
+
### Hooks (Automatic Enforcement)
|
|
58
|
+
|
|
59
|
+
| Hook | When | Action |
|
|
60
|
+
|------|------|--------|
|
|
61
|
+
| `session-startup.py` | Session start | Inject state context |
|
|
62
|
+
| `enforce-external-research.py` | API questions | Require research first |
|
|
63
|
+
| `enforce-research.py` | Write/Edit | Block without research |
|
|
64
|
+
| `enforce-interview.py` | Write/Edit | Inject interview decisions |
|
|
65
|
+
| `verify-after-green.py` | Tests pass | Trigger Phase 9 |
|
|
66
|
+
| `periodic-reground.py` | Every 7 turns | Re-inject context |
|
|
67
|
+
| `api-workflow-check.py` | Stop | Block if incomplete |
|
|
68
|
+
|
|
69
|
+
### Usage
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Full automated workflow
|
|
73
|
+
/api-create my-endpoint
|
|
74
|
+
|
|
75
|
+
# Manual step-by-step
|
|
76
|
+
/api-research [library]
|
|
77
|
+
/api-interview [endpoint]
|
|
78
|
+
/api-env [endpoint]
|
|
79
|
+
/red
|
|
80
|
+
/green
|
|
81
|
+
/api-verify [endpoint]
|
|
82
|
+
/refactor
|
|
83
|
+
/commit
|
|
84
|
+
```
|