@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.
Files changed (38) hide show
  1. package/README.md +71 -0
  2. package/bin/cli.js +184 -14
  3. package/demo/audio/generate-all-narrations.js +124 -59
  4. package/demo/audio/generate-narration.js +120 -56
  5. package/demo/audio/narration-adam-timing.json +3086 -2077
  6. package/demo/audio/narration-adam.mp3 +0 -0
  7. package/demo/audio/narration-creature-timing.json +3094 -2085
  8. package/demo/audio/narration-creature.mp3 +0 -0
  9. package/demo/audio/narration-gaming-timing.json +3091 -2082
  10. package/demo/audio/narration-gaming.mp3 +0 -0
  11. package/demo/audio/narration-hope-timing.json +3072 -2063
  12. package/demo/audio/narration-hope.mp3 +0 -0
  13. package/demo/audio/narration-mark-timing.json +3090 -2081
  14. package/demo/audio/narration-mark.mp3 +0 -0
  15. package/demo/audio/voices-manifest.json +16 -16
  16. package/demo/workflow-demo.html +1528 -411
  17. package/hooks/api-workflow-check.py +2 -0
  18. package/hooks/enforce-deep-research.py +180 -0
  19. package/hooks/enforce-disambiguation.py +149 -0
  20. package/hooks/enforce-documentation.py +187 -0
  21. package/hooks/enforce-environment.py +249 -0
  22. package/hooks/enforce-interview.py +64 -1
  23. package/hooks/enforce-refactor.py +187 -0
  24. package/hooks/enforce-research.py +93 -46
  25. package/hooks/enforce-schema.py +186 -0
  26. package/hooks/enforce-scope.py +156 -0
  27. package/hooks/enforce-tdd-red.py +246 -0
  28. package/hooks/enforce-verify.py +186 -0
  29. package/hooks/verify-after-green.py +136 -6
  30. package/package.json +2 -1
  31. package/scripts/collect-test-results.ts +404 -0
  32. package/scripts/extract-parameters.ts +483 -0
  33. package/scripts/generate-test-manifest.ts +520 -0
  34. package/templates/CLAUDE-SECTION.md +84 -0
  35. package/templates/api-dev-state.json +45 -5
  36. package/templates/api-test/page.tsx +315 -0
  37. package/templates/api-test/test-structure/route.ts +269 -0
  38. package/templates/settings.json +36 -0
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Extract Parameters Script
4
+ *
5
+ * Programmatically extracts ALL parameters from:
6
+ * 1. Zod schemas (request/response validation)
7
+ * 2. Route files (query params, headers)
8
+ * 3. Test files (parameter usage in tests)
9
+ *
10
+ * Generates a parameter matrix for each endpoint showing:
11
+ * - Name, type, required/optional
12
+ * - Valid values, defaults, constraints
13
+ * - Test coverage (which params are tested)
14
+ *
15
+ * IMPORTANT: This is 100% programmatic - NO LLM involvement.
16
+ *
17
+ * @generated by @hustle-together/api-dev-tools v3.0
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+
23
+ // ============================================
24
+ // Types
25
+ // ============================================
26
+
27
+ interface ParameterDefinition {
28
+ name: string;
29
+ location: 'query' | 'body' | 'header' | 'path';
30
+ type: string;
31
+ required: boolean;
32
+ description?: string;
33
+ default?: unknown;
34
+ enum?: string[];
35
+ min?: number;
36
+ max?: number;
37
+ pattern?: string;
38
+ example?: unknown;
39
+ }
40
+
41
+ interface EndpointParameters {
42
+ endpoint: string;
43
+ method: string;
44
+ parameters: ParameterDefinition[];
45
+ sourceFiles: string[];
46
+ testedParameters: string[];
47
+ untestedParameters: string[];
48
+ }
49
+
50
+ interface ParameterMatrix {
51
+ version: string;
52
+ generatedAt: string;
53
+ endpoints: EndpointParameters[];
54
+ coverage: {
55
+ totalParameters: number;
56
+ testedParameters: number;
57
+ coveragePercent: number;
58
+ };
59
+ }
60
+
61
+ // ============================================
62
+ // File Discovery
63
+ // ============================================
64
+
65
+ function findFiles(baseDir: string, pattern: RegExp, exclude: string[] = ['node_modules', '.git', 'dist']): string[] {
66
+ const files: string[] = [];
67
+
68
+ function walk(dir: string) {
69
+ try {
70
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
71
+ for (const entry of entries) {
72
+ if (exclude.includes(entry.name)) continue;
73
+
74
+ const fullPath = path.join(dir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ walk(fullPath);
77
+ } else if (entry.isFile() && pattern.test(entry.name)) {
78
+ files.push(fullPath);
79
+ }
80
+ }
81
+ } catch {
82
+ // Skip unreadable directories
83
+ }
84
+ }
85
+
86
+ walk(baseDir);
87
+ return files;
88
+ }
89
+
90
+ // ============================================
91
+ // Zod Schema Extractor
92
+ // ============================================
93
+
94
+ function extractFromZodSchema(content: string): ParameterDefinition[] {
95
+ const params: ParameterDefinition[] = [];
96
+
97
+ // Match z.object definitions with their field names
98
+ // This handles nested objects and various Zod types
99
+ const zodObjectRegex = /z\.object\s*\(\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/gs;
100
+
101
+ let objectMatch;
102
+ while ((objectMatch = zodObjectRegex.exec(content)) !== null) {
103
+ const objectBody = objectMatch[1];
104
+
105
+ // Parse individual fields
106
+ // Pattern handles: fieldName: z.type().chain().chain()
107
+ const fieldRegex = /(\w+)\s*:\s*(z\.(?:[^,\n]+(?:\([^)]*\))?)+)/g;
108
+
109
+ let fieldMatch;
110
+ while ((fieldMatch = fieldRegex.exec(objectBody)) !== null) {
111
+ const [, name, zodChain] = fieldMatch;
112
+
113
+ const param = parseZodChain(name, zodChain);
114
+ if (param) {
115
+ params.push(param);
116
+ }
117
+ }
118
+ }
119
+
120
+ return params;
121
+ }
122
+
123
+ function parseZodChain(name: string, chain: string): ParameterDefinition | null {
124
+ // Determine base type
125
+ const typeMatch = chain.match(/z\.(\w+)/);
126
+ if (!typeMatch) return null;
127
+
128
+ const baseType = typeMatch[1];
129
+
130
+ const param: ParameterDefinition = {
131
+ name,
132
+ location: 'body', // Default, will be refined based on context
133
+ type: mapZodType(baseType),
134
+ required: true
135
+ };
136
+
137
+ // Check for optional
138
+ if (chain.includes('.optional()') || chain.includes('.nullable()')) {
139
+ param.required = false;
140
+ }
141
+
142
+ // Extract description
143
+ const descMatch = chain.match(/\.describe\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
144
+ if (descMatch) {
145
+ param.description = descMatch[1];
146
+ }
147
+
148
+ // Extract default value
149
+ const defaultMatch = chain.match(/\.default\s*\(\s*([^)]+)\s*\)/);
150
+ if (defaultMatch) {
151
+ try {
152
+ param.default = JSON.parse(defaultMatch[1].replace(/'/g, '"'));
153
+ } catch {
154
+ param.default = defaultMatch[1].replace(/['"]/g, '');
155
+ }
156
+ param.required = false; // Has default = not required
157
+ }
158
+
159
+ // Extract enum values
160
+ if (baseType === 'enum') {
161
+ const enumMatch = chain.match(/z\.enum\s*\(\s*\[([^\]]+)\]/);
162
+ if (enumMatch) {
163
+ param.enum = enumMatch[1]
164
+ .split(',')
165
+ .map(s => s.trim().replace(/['"`]/g, ''));
166
+ }
167
+ }
168
+
169
+ // Extract min/max for numbers
170
+ const minMatch = chain.match(/\.min\s*\(\s*(\d+)\s*\)/);
171
+ if (minMatch) param.min = parseInt(minMatch[1]);
172
+
173
+ const maxMatch = chain.match(/\.max\s*\(\s*(\d+)\s*\)/);
174
+ if (maxMatch) param.max = parseInt(maxMatch[1]);
175
+
176
+ // Extract pattern for strings
177
+ const regexMatch = chain.match(/\.regex\s*\(\s*\/([^/]+)\//);
178
+ if (regexMatch) param.pattern = regexMatch[1];
179
+
180
+ return param;
181
+ }
182
+
183
+ function mapZodType(zodType: string): string {
184
+ const typeMap: Record<string, string> = {
185
+ 'string': 'string',
186
+ 'number': 'number',
187
+ 'boolean': 'boolean',
188
+ 'array': 'array',
189
+ 'object': 'object',
190
+ 'enum': 'enum',
191
+ 'literal': 'literal',
192
+ 'union': 'union',
193
+ 'date': 'string (ISO date)',
194
+ 'coerce': 'coerced'
195
+ };
196
+ return typeMap[zodType] || zodType;
197
+ }
198
+
199
+ // ============================================
200
+ // Route File Extractor
201
+ // ============================================
202
+
203
+ function extractFromRouteFile(content: string, existingParams: ParameterDefinition[]): ParameterDefinition[] {
204
+ const params: ParameterDefinition[] = [];
205
+
206
+ // Extract query parameters from searchParams usage
207
+ // Pattern: searchParams.get('paramName')
208
+ const queryParamRegex = /searchParams\.get\s*\(\s*['"`](\w+)['"`]\s*\)/g;
209
+ let match;
210
+ while ((match = queryParamRegex.exec(content)) !== null) {
211
+ const name = match[1];
212
+ if (!existingParams.some(p => p.name === name) && !params.some(p => p.name === name)) {
213
+ params.push({
214
+ name,
215
+ location: 'query',
216
+ type: 'string',
217
+ required: false // Query params are typically optional
218
+ });
219
+ }
220
+ }
221
+
222
+ // Extract header access
223
+ // Pattern: request.headers.get('X-Header-Name')
224
+ const headerRegex = /headers\.get\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
225
+ while ((match = headerRegex.exec(content)) !== null) {
226
+ const name = match[1];
227
+ if (!existingParams.some(p => p.name === name) && !params.some(p => p.name === name)) {
228
+ params.push({
229
+ name,
230
+ location: 'header',
231
+ type: 'string',
232
+ required: false
233
+ });
234
+ }
235
+ }
236
+
237
+ // Extract path parameters from dynamic routes
238
+ // Pattern: params.paramName or { paramName } = params
239
+ const pathParamRegex = /params\.(\w+)|{\s*(\w+)\s*}\s*=\s*params/g;
240
+ while ((match = pathParamRegex.exec(content)) !== null) {
241
+ const name = match[1] || match[2];
242
+ if (!existingParams.some(p => p.name === name) && !params.some(p => p.name === name)) {
243
+ params.push({
244
+ name,
245
+ location: 'path',
246
+ type: 'string',
247
+ required: true // Path params are always required
248
+ });
249
+ }
250
+ }
251
+
252
+ return params;
253
+ }
254
+
255
+ // ============================================
256
+ // Test File Analyzer
257
+ // ============================================
258
+
259
+ function extractTestedParams(content: string): string[] {
260
+ const testedParams: Set<string> = new Set();
261
+
262
+ // Find parameters in fetch/request calls
263
+ // Pattern: body: { param: value } or ?param=value
264
+ const bodyParamRegex = /body\s*:\s*(?:JSON\.stringify\s*\()?\s*\{([^}]+)\}/g;
265
+ let match;
266
+
267
+ while ((match = bodyParamRegex.exec(content)) !== null) {
268
+ const bodyContent = match[1];
269
+ const paramNames = bodyContent.match(/(\w+)\s*:/g);
270
+ if (paramNames) {
271
+ paramNames.forEach(p => testedParams.add(p.replace(':', '').trim()));
272
+ }
273
+ }
274
+
275
+ // Find query params in URLs
276
+ const queryRegex = /[?&](\w+)=/g;
277
+ while ((match = queryRegex.exec(content)) !== null) {
278
+ testedParams.add(match[1]);
279
+ }
280
+
281
+ // Find header params
282
+ const headerRegex = /['"`](X-[\w-]+|Authorization|Content-Type)['"`]\s*:/gi;
283
+ while ((match = headerRegex.exec(content)) !== null) {
284
+ testedParams.add(match[1]);
285
+ }
286
+
287
+ return Array.from(testedParams);
288
+ }
289
+
290
+ // ============================================
291
+ // Main Extractor
292
+ // ============================================
293
+
294
+ function extractAllParameters(baseDir: string): ParameterMatrix {
295
+ console.log('๐Ÿ” Scanning for parameter sources...');
296
+
297
+ // Find files
298
+ const schemaFiles = findFiles(baseDir, /schema.*\.ts$|schemas?\/.*\.ts$/);
299
+ const routeFiles = findFiles(baseDir, /route\.(ts|tsx)$/);
300
+ const testFiles = findFiles(baseDir, /\.(test|spec)\.(ts|tsx)$/);
301
+
302
+ console.log(` Found ${schemaFiles.length} schema files`);
303
+ console.log(` Found ${routeFiles.length} route files`);
304
+ console.log(` Found ${testFiles.length} test files`);
305
+
306
+ const endpointsMap = new Map<string, EndpointParameters>();
307
+
308
+ // Process schema files
309
+ for (const schemaFile of schemaFiles) {
310
+ const content = fs.readFileSync(schemaFile, 'utf-8');
311
+ const params = extractFromZodSchema(content);
312
+
313
+ if (params.length > 0) {
314
+ const relativePath = path.relative(baseDir, schemaFile);
315
+
316
+ // Determine endpoint from file path
317
+ const pathParts = relativePath.split(path.sep);
318
+ const apiIndex = pathParts.findIndex(p => p === 'api');
319
+ let endpoint = '/api/unknown';
320
+
321
+ if (apiIndex >= 0) {
322
+ endpoint = '/' + pathParts.slice(apiIndex).join('/').replace(/\/schema.*\.ts$/, '');
323
+ }
324
+
325
+ const existing = endpointsMap.get(endpoint) || {
326
+ endpoint,
327
+ method: 'POST',
328
+ parameters: [],
329
+ sourceFiles: [],
330
+ testedParameters: [],
331
+ untestedParameters: []
332
+ };
333
+
334
+ existing.parameters.push(...params);
335
+ existing.sourceFiles.push(relativePath);
336
+ endpointsMap.set(endpoint, existing);
337
+
338
+ console.log(`\n๐Ÿ“„ Schema: ${relativePath}`);
339
+ console.log(` Extracted ${params.length} parameters for ${endpoint}`);
340
+ }
341
+ }
342
+
343
+ // Process route files
344
+ for (const routeFile of routeFiles) {
345
+ const content = fs.readFileSync(routeFile, 'utf-8');
346
+ const relativePath = path.relative(baseDir, routeFile);
347
+
348
+ // Determine endpoint
349
+ const pathParts = relativePath.split(path.sep);
350
+ const apiIndex = pathParts.findIndex(p => p === 'api');
351
+ let endpoint = '/api/unknown';
352
+
353
+ if (apiIndex >= 0) {
354
+ endpoint = '/' + pathParts.slice(apiIndex, -1).join('/');
355
+ }
356
+
357
+ const existing = endpointsMap.get(endpoint) || {
358
+ endpoint,
359
+ method: 'GET',
360
+ parameters: [],
361
+ sourceFiles: [],
362
+ testedParameters: [],
363
+ untestedParameters: []
364
+ };
365
+
366
+ // Determine method
367
+ if (content.includes('export async function POST') || content.includes('export const POST')) {
368
+ existing.method = 'POST';
369
+ }
370
+
371
+ const routeParams = extractFromRouteFile(content, existing.parameters);
372
+ existing.parameters.push(...routeParams);
373
+ if (!existing.sourceFiles.includes(relativePath)) {
374
+ existing.sourceFiles.push(relativePath);
375
+ }
376
+
377
+ endpointsMap.set(endpoint, existing);
378
+
379
+ if (routeParams.length > 0) {
380
+ console.log(`\n๐Ÿ“„ Route: ${relativePath}`);
381
+ console.log(` Extracted ${routeParams.length} additional parameters`);
382
+ }
383
+ }
384
+
385
+ // Process test files to find tested parameters
386
+ for (const testFile of testFiles) {
387
+ const content = fs.readFileSync(testFile, 'utf-8');
388
+ const relativePath = path.relative(baseDir, testFile);
389
+
390
+ // Determine endpoint from test file
391
+ const endpointMatch = content.match(/(?:\/api\/[\w\/-]+)/);
392
+ if (!endpointMatch) continue;
393
+
394
+ const endpoint = endpointMatch[0];
395
+ const existing = endpointsMap.get(endpoint);
396
+
397
+ if (existing) {
398
+ const testedParams = extractTestedParams(content);
399
+ existing.testedParameters = [...new Set([...existing.testedParameters, ...testedParams])];
400
+
401
+ console.log(`\n๐Ÿงช Test: ${relativePath}`);
402
+ console.log(` Found ${testedParams.length} tested parameters`);
403
+ }
404
+ }
405
+
406
+ // Calculate untested parameters and coverage
407
+ let totalParams = 0;
408
+ let testedParams = 0;
409
+
410
+ const endpoints = Array.from(endpointsMap.values()).map(ep => {
411
+ const allParamNames = ep.parameters.map(p => p.name);
412
+ ep.untestedParameters = allParamNames.filter(p => !ep.testedParameters.includes(p));
413
+
414
+ totalParams += ep.parameters.length;
415
+ testedParams += ep.testedParameters.length;
416
+
417
+ return ep;
418
+ });
419
+
420
+ return {
421
+ version: '3.0.0',
422
+ generatedAt: new Date().toISOString(),
423
+ endpoints,
424
+ coverage: {
425
+ totalParameters: totalParams,
426
+ testedParameters: testedParams,
427
+ coveragePercent: totalParams > 0 ? Math.round((testedParams / totalParams) * 100) : 0
428
+ }
429
+ };
430
+ }
431
+
432
+ // ============================================
433
+ // CLI Entry Point
434
+ // ============================================
435
+
436
+ function main() {
437
+ const args = process.argv.slice(2);
438
+ const baseDir = args[0] || process.cwd();
439
+ const outputPath = args[1] || path.join(baseDir, 'src', 'app', 'api-test', 'parameter-matrix.json');
440
+
441
+ console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
442
+ console.log(' ๐Ÿ“Š Parameter Matrix Extractor');
443
+ console.log(' @hustle-together/api-dev-tools v3.0');
444
+ console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
445
+ console.log(`\n๐Ÿ“ Base directory: ${baseDir}`);
446
+ console.log(`๐Ÿ“„ Output file: ${outputPath}\n`);
447
+
448
+ const matrix = extractAllParameters(baseDir);
449
+
450
+ // Ensure output directory exists
451
+ const outputDir = path.dirname(outputPath);
452
+ if (!fs.existsSync(outputDir)) {
453
+ fs.mkdirSync(outputDir, { recursive: true });
454
+ }
455
+
456
+ // Write matrix
457
+ fs.writeFileSync(outputPath, JSON.stringify(matrix, null, 2));
458
+
459
+ console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
460
+ console.log(' โœ… Parameter matrix generated successfully!');
461
+ console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
462
+ console.log(`\n๐Ÿ“Š Coverage Summary:`);
463
+ console.log(` โ€ข Total parameters: ${matrix.coverage.totalParameters}`);
464
+ console.log(` โ€ข Tested parameters: ${matrix.coverage.testedParameters}`);
465
+ console.log(` โ€ข Coverage: ${matrix.coverage.coveragePercent}%`);
466
+
467
+ // List untested parameters
468
+ const untested = matrix.endpoints.flatMap(ep =>
469
+ ep.untestedParameters.map(p => `${ep.endpoint}: ${p}`)
470
+ );
471
+
472
+ if (untested.length > 0) {
473
+ console.log(`\nโš ๏ธ Untested parameters (${untested.length}):`);
474
+ untested.slice(0, 10).forEach(p => console.log(` โ€ข ${p}`));
475
+ if (untested.length > 10) {
476
+ console.log(` ... and ${untested.length - 10} more`);
477
+ }
478
+ }
479
+
480
+ console.log(`\n๐Ÿ“„ Output: ${outputPath}\n`);
481
+ }
482
+
483
+ main();