@hustle-together/api-dev-tools 3.6.5 → 3.9.2

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 (61) hide show
  1. package/README.md +5307 -258
  2. package/bin/cli.js +348 -20
  3. package/commands/README.md +459 -71
  4. package/commands/hustle-api-continue.md +158 -0
  5. package/commands/{api-create.md → hustle-api-create.md} +22 -2
  6. package/commands/{api-env.md → hustle-api-env.md} +4 -4
  7. package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
  8. package/commands/{api-research.md → hustle-api-research.md} +3 -3
  9. package/commands/hustle-api-sessions.md +149 -0
  10. package/commands/{api-status.md → hustle-api-status.md} +16 -16
  11. package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
  12. package/commands/hustle-combine.md +763 -0
  13. package/commands/hustle-ui-create.md +825 -0
  14. package/hooks/api-workflow-check.py +385 -19
  15. package/hooks/cache-research.py +337 -0
  16. package/hooks/check-playwright-setup.py +103 -0
  17. package/hooks/check-storybook-setup.py +81 -0
  18. package/hooks/detect-interruption.py +165 -0
  19. package/hooks/enforce-brand-guide.py +131 -0
  20. package/hooks/enforce-documentation.py +60 -8
  21. package/hooks/enforce-freshness.py +184 -0
  22. package/hooks/enforce-questions-sourced.py +146 -0
  23. package/hooks/enforce-schema-from-interview.py +248 -0
  24. package/hooks/enforce-ui-disambiguation.py +108 -0
  25. package/hooks/enforce-ui-interview.py +130 -0
  26. package/hooks/generate-manifest-entry.py +981 -0
  27. package/hooks/session-logger.py +297 -0
  28. package/hooks/session-startup.py +65 -10
  29. package/hooks/track-scope-coverage.py +220 -0
  30. package/hooks/track-tool-use.py +81 -1
  31. package/hooks/update-api-showcase.py +149 -0
  32. package/hooks/update-registry.py +352 -0
  33. package/hooks/update-ui-showcase.py +148 -0
  34. package/package.json +8 -2
  35. package/templates/BRAND_GUIDE.md +299 -0
  36. package/templates/CLAUDE-SECTION.md +56 -24
  37. package/templates/SPEC.json +640 -0
  38. package/templates/api-dev-state.json +179 -161
  39. package/templates/api-showcase/APICard.tsx +153 -0
  40. package/templates/api-showcase/APIModal.tsx +375 -0
  41. package/templates/api-showcase/APIShowcase.tsx +231 -0
  42. package/templates/api-showcase/APITester.tsx +522 -0
  43. package/templates/api-showcase/page.tsx +41 -0
  44. package/templates/component/Component.stories.tsx +172 -0
  45. package/templates/component/Component.test.tsx +237 -0
  46. package/templates/component/Component.tsx +86 -0
  47. package/templates/component/Component.types.ts +55 -0
  48. package/templates/component/index.ts +15 -0
  49. package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
  50. package/templates/dev-tools/page.tsx +10 -0
  51. package/templates/page/page.e2e.test.ts +218 -0
  52. package/templates/page/page.tsx +42 -0
  53. package/templates/performance-budgets.json +58 -0
  54. package/templates/registry.json +13 -0
  55. package/templates/settings.json +74 -0
  56. package/templates/shared/HeroHeader.tsx +261 -0
  57. package/templates/shared/index.ts +1 -0
  58. package/templates/ui-showcase/PreviewCard.tsx +315 -0
  59. package/templates/ui-showcase/PreviewModal.tsx +676 -0
  60. package/templates/ui-showcase/UIShowcase.tsx +262 -0
  61. package/templates/ui-showcase/page.tsx +26 -0
@@ -0,0 +1,981 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: PostToolUse for Write (triggered by cache-research.py or manually)
4
+ Purpose: Generate api-tests-manifest.json entry from Zod schema
5
+
6
+ This hook reads the created Zod schema file and generates a comprehensive
7
+ manifest entry that gets appended to api-tests-manifest.json.
8
+
9
+ Added in v3.6.7 for complete API documentation automation.
10
+
11
+ What it generates:
12
+ - Full requestSchema from Zod
13
+ - All parameters with types, required, descriptions
14
+ - Example curl commands for every parameter combination
15
+ - Test cases from the test file
16
+ - Response schema
17
+
18
+ Returns:
19
+ - {"continue": true} - Always continues
20
+ """
21
+ import json
22
+ import sys
23
+ import re
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
28
+ # Default manifest location - can be overridden
29
+ DEFAULT_MANIFEST = Path.cwd() / "src" / "app" / "api-test" / "api-tests-manifest.json"
30
+
31
+
32
+ def get_active_endpoint(state):
33
+ """Get active endpoint - supports both old and new state formats."""
34
+ if "endpoints" in state and "active_endpoint" in state:
35
+ active = state.get("active_endpoint")
36
+ if active and active in state["endpoints"]:
37
+ return active, state["endpoints"][active]
38
+ return None, None
39
+
40
+ endpoint = state.get("endpoint")
41
+ if endpoint:
42
+ return endpoint, state
43
+
44
+ return None, None
45
+
46
+
47
+ def parse_zod_schema(schema_content: str) -> dict:
48
+ """Parse Zod schema content and extract all field information."""
49
+ properties = {}
50
+ required = []
51
+
52
+ # Match Zod object definitions
53
+ # Pattern: fieldName: z.type(...).chain()
54
+ field_pattern = r'(\w+):\s*z\.(\w+)\(([^)]*)\)([^\n,}]*)'
55
+
56
+ for match in re.finditer(field_pattern, schema_content):
57
+ field_name = match.group(1)
58
+ zod_type = match.group(2)
59
+ type_args = match.group(3)
60
+ chain = match.group(4) or ""
61
+
62
+ # Skip if it's not a real field
63
+ if field_name in {'z', 'const', 'export', 'type', 'schema'}:
64
+ continue
65
+
66
+ prop = {}
67
+
68
+ # Map Zod types to JSON Schema types
69
+ type_map = {
70
+ "string": "string",
71
+ "number": "number",
72
+ "boolean": "boolean",
73
+ "array": "array",
74
+ "object": "object",
75
+ "date": "string",
76
+ "bigint": "integer",
77
+ "any": "any",
78
+ "unknown": "any",
79
+ "null": "null",
80
+ "undefined": "null",
81
+ "void": "null",
82
+ "never": "null",
83
+ }
84
+
85
+ # Handle enum
86
+ if zod_type == "enum":
87
+ prop["type"] = "string"
88
+ # Extract enum values: z.enum(["a", "b", "c"])
89
+ enum_match = re.search(r'\[([^\]]+)\]', type_args)
90
+ if enum_match:
91
+ enum_str = enum_match.group(1)
92
+ enum_values = re.findall(r'["\']([^"\']+)["\']', enum_str)
93
+ if enum_values:
94
+ prop["enum"] = enum_values
95
+ elif zod_type == "literal":
96
+ prop["type"] = "string"
97
+ literal_match = re.search(r'["\']([^"\']+)["\']', type_args)
98
+ if literal_match:
99
+ prop["const"] = literal_match.group(1)
100
+ elif zod_type == "union":
101
+ prop["type"] = "string" # Simplified
102
+ elif zod_type == "array":
103
+ prop["type"] = "array"
104
+ prop["items"] = {"type": "any"}
105
+ else:
106
+ prop["type"] = type_map.get(zod_type, "string")
107
+
108
+ # Check for optional
109
+ is_optional = ".optional()" in chain or ".nullish()" in chain
110
+
111
+ # Extract description
112
+ desc_match = re.search(r'\.describe\(["\']([^"\']+)["\']', chain)
113
+ if desc_match:
114
+ prop["description"] = desc_match.group(1)
115
+
116
+ # Extract default
117
+ default_match = re.search(r'\.default\(([^)]+)\)', chain)
118
+ if default_match:
119
+ default_val = default_match.group(1).strip()
120
+ # Try to parse the default value
121
+ if default_val.startswith('"') or default_val.startswith("'"):
122
+ prop["default"] = default_val.strip('"\'')
123
+ elif default_val == "true":
124
+ prop["default"] = True
125
+ elif default_val == "false":
126
+ prop["default"] = False
127
+ elif default_val.isdigit():
128
+ prop["default"] = int(default_val)
129
+ else:
130
+ try:
131
+ prop["default"] = float(default_val)
132
+ except ValueError:
133
+ prop["default"] = default_val
134
+
135
+ # Extract min/max for numbers
136
+ min_match = re.search(r'\.min\((\d+)\)', chain)
137
+ if min_match:
138
+ prop["minimum"] = int(min_match.group(1))
139
+
140
+ max_match = re.search(r'\.max\((\d+)\)', chain)
141
+ if max_match:
142
+ prop["maximum"] = int(max_match.group(1))
143
+
144
+ # Extract minLength/maxLength for strings
145
+ min_len_match = re.search(r'\.min\((\d+)\)', chain)
146
+ if min_len_match and prop.get("type") == "string":
147
+ prop["minLength"] = int(min_len_match.group(1))
148
+
149
+ max_len_match = re.search(r'\.max\((\d+)\)', chain)
150
+ if max_len_match and prop.get("type") == "string":
151
+ prop["maxLength"] = int(max_len_match.group(1))
152
+
153
+ properties[field_name] = prop
154
+
155
+ if not is_optional:
156
+ required.append(field_name)
157
+
158
+ return {
159
+ "type": "object",
160
+ "properties": properties,
161
+ "required": required
162
+ }
163
+
164
+
165
+ def detect_http_method(route_content: str) -> str:
166
+ """Detect HTTP method from route file."""
167
+ if "export async function GET" in route_content or "export const GET" in route_content:
168
+ return "GET"
169
+ elif "export async function DELETE" in route_content or "export const DELETE" in route_content:
170
+ return "DELETE"
171
+ elif "export async function PUT" in route_content or "export const PUT" in route_content:
172
+ return "PUT"
173
+ elif "export async function PATCH" in route_content or "export const PATCH" in route_content:
174
+ return "PATCH"
175
+ return "POST"
176
+
177
+
178
+ def generate_example_value(prop: dict, field_name: str, variant: str = "default") -> any:
179
+ """Generate an example value for a property.
180
+
181
+ Args:
182
+ prop: Property definition from schema
183
+ field_name: Name of the field
184
+ variant: Which variant to generate:
185
+ - "default": First/default value
186
+ - "alt": Alternative value (2nd enum, different example)
187
+ - "min": Minimum boundary value
188
+ - "max": Maximum boundary value
189
+ - "empty": Empty/minimal value
190
+ """
191
+ # Handle enums with variants
192
+ if "enum" in prop:
193
+ enum_values = prop["enum"]
194
+ if variant == "alt" and len(enum_values) > 1:
195
+ return enum_values[1]
196
+ elif variant == "all":
197
+ return enum_values # Return all for reference
198
+ return enum_values[0]
199
+
200
+ if "const" in prop:
201
+ return prop["const"]
202
+
203
+ if variant == "default" and "default" in prop:
204
+ return prop["default"]
205
+
206
+ prop_type = prop.get("type", "string")
207
+
208
+ if prop_type == "string":
209
+ # Handle boundary variants
210
+ min_len = prop.get("minLength", 0)
211
+ max_len = prop.get("maxLength", 100)
212
+
213
+ if variant == "min" and min_len > 0:
214
+ return "x" * min_len
215
+ elif variant == "max" and max_len < 1000:
216
+ return "x" * min(max_len, 50) # Cap at 50 for readability
217
+ elif variant == "empty":
218
+ return ""
219
+
220
+ # Try to generate meaningful example based on field name
221
+ name_lower = field_name.lower()
222
+ if "email" in name_lower:
223
+ return "user@example.com" if variant == "default" else "admin@test.org"
224
+ elif "url" in name_lower or "link" in name_lower:
225
+ return "https://example.com" if variant == "default" else "https://test.org/page"
226
+ elif "domain" in name_lower:
227
+ return "example.com" if variant == "default" else "google.com"
228
+ elif "id" in name_lower:
229
+ return "abc123" if variant == "default" else "xyz789"
230
+ elif "name" in name_lower:
231
+ return "Example Name" if variant == "default" else "Test User"
232
+ elif "model" in name_lower:
233
+ return "gpt-4o" if variant == "default" else "claude-3-opus"
234
+ elif "prompt" in name_lower or "message" in name_lower or "content" in name_lower:
235
+ return "Hello, how can I help you?" if variant == "default" else "Explain quantum computing"
236
+ elif "query" in name_lower or "search" in name_lower:
237
+ return "search term" if variant == "default" else "alternative query"
238
+ else:
239
+ return f"example-{field_name}" if variant == "default" else f"alt-{field_name}"
240
+
241
+ elif prop_type == "number":
242
+ minimum = prop.get("minimum", 0)
243
+ maximum = prop.get("maximum", 1000)
244
+ if variant == "min":
245
+ return minimum
246
+ elif variant == "max":
247
+ return maximum
248
+ elif variant == "alt":
249
+ return (minimum + maximum) // 2 if maximum > minimum else 100
250
+ return prop.get("default", 42)
251
+
252
+ elif prop_type == "integer":
253
+ minimum = prop.get("minimum", 0)
254
+ maximum = prop.get("maximum", 1000)
255
+ if variant == "min":
256
+ return minimum
257
+ elif variant == "max":
258
+ return maximum
259
+ elif variant == "alt":
260
+ return (minimum + maximum) // 2 if maximum > minimum else 50
261
+ return prop.get("default", 100)
262
+
263
+ elif prop_type == "boolean":
264
+ if variant == "alt":
265
+ return not prop.get("default", True)
266
+ return prop.get("default", True)
267
+
268
+ elif prop_type == "array":
269
+ items = prop.get("items", {})
270
+ if variant == "empty":
271
+ return []
272
+ elif variant == "single":
273
+ return [generate_example_value(items, f"{field_name}_item", "default")]
274
+ elif variant == "multiple":
275
+ return [
276
+ generate_example_value(items, f"{field_name}_item", "default"),
277
+ generate_example_value(items, f"{field_name}_item", "alt")
278
+ ]
279
+ # Default: show array with one example item
280
+ return [generate_example_value(items, f"{field_name}_item", "default")]
281
+
282
+ elif prop_type == "object":
283
+ # For nested objects, try to generate example if we have properties
284
+ nested_props = prop.get("properties", {})
285
+ if nested_props and variant != "empty":
286
+ result = {}
287
+ for nested_name, nested_prop in nested_props.items():
288
+ result[nested_name] = generate_example_value(nested_prop, nested_name, variant)
289
+ return result
290
+ return {}
291
+
292
+ return "example"
293
+
294
+
295
+ def get_all_enum_fields(properties: dict) -> list:
296
+ """Find all fields with enum values for generating enum-specific examples."""
297
+ enum_fields = []
298
+ for field_name, prop in properties.items():
299
+ if "enum" in prop and len(prop["enum"]) > 1:
300
+ enum_fields.append({
301
+ "name": field_name,
302
+ "values": prop["enum"]
303
+ })
304
+ return enum_fields
305
+
306
+
307
+ def generate_curl_examples(endpoint: str, method: str, schema: dict) -> list:
308
+ """Generate comprehensive curl examples covering all parameter possibilities."""
309
+ examples = []
310
+ properties = schema.get("properties", {})
311
+ required = schema.get("required", [])
312
+
313
+ base_url = f"http://localhost:3001/api/v2/{endpoint}"
314
+
315
+ def make_curl(body: dict, extra_headers: list = None) -> str:
316
+ """Helper to generate curl command."""
317
+ if method in ["POST", "PUT", "PATCH"]:
318
+ curl = f"curl -X {method} {base_url} \\\n"
319
+ curl += " -H \"Content-Type: application/json\""
320
+ if extra_headers:
321
+ for h in extra_headers:
322
+ curl += f" \\\n -H \"{h}\""
323
+ curl += f" \\\n -d '{json.dumps(body, indent=2)}'"
324
+ else:
325
+ # GET/DELETE - use query params
326
+ if body:
327
+ params = []
328
+ for k, v in body.items():
329
+ if isinstance(v, str):
330
+ params.append(f"{k}={v}")
331
+ else:
332
+ params.append(f"{k}={json.dumps(v)}")
333
+ curl = f"curl \"{base_url}?{'&'.join(params)}\""
334
+ else:
335
+ curl = f"curl {base_url}"
336
+ if extra_headers:
337
+ for h in extra_headers:
338
+ curl += f" \\\n -H \"{h}\""
339
+ return curl
340
+
341
+ # ================================================================
342
+ # Example 1: Minimal (required fields only)
343
+ # ================================================================
344
+ if required:
345
+ minimal_body = {}
346
+ for field in required:
347
+ if field in properties:
348
+ minimal_body[field] = generate_example_value(properties[field], field, "default")
349
+
350
+ if minimal_body:
351
+ examples.append({
352
+ "name": "Minimal (required fields only)",
353
+ "description": f"Request with only required fields: {', '.join(required)}",
354
+ "request": minimal_body,
355
+ "curl": make_curl(minimal_body)
356
+ })
357
+
358
+ # ================================================================
359
+ # Example 2: Full (all parameters)
360
+ # ================================================================
361
+ if properties:
362
+ full_body = {}
363
+ for field, prop in properties.items():
364
+ full_body[field] = generate_example_value(prop, field, "default")
365
+
366
+ examples.append({
367
+ "name": "Full (all parameters)",
368
+ "description": f"Request with all {len(properties)} parameters",
369
+ "request": full_body,
370
+ "curl": make_curl(full_body)
371
+ })
372
+
373
+ # ================================================================
374
+ # Example 3: With authentication header
375
+ # ================================================================
376
+ auth_body = {}
377
+ for field in required[:2]: # First 2 required fields
378
+ if field in properties:
379
+ auth_body[field] = generate_example_value(properties[field], field, "default")
380
+ if not auth_body:
381
+ auth_body = {"param": "value"}
382
+
383
+ examples.append({
384
+ "name": "With authentication",
385
+ "description": "Request with X-API-Key header",
386
+ "request": auth_body,
387
+ "curl": make_curl(auth_body, ["X-API-Key: your-api-key"])
388
+ })
389
+
390
+ # ================================================================
391
+ # Example 4-N: Enum variations (one example per enum field per value)
392
+ # ================================================================
393
+ enum_fields = get_all_enum_fields(properties)
394
+ for enum_field in enum_fields:
395
+ field_name = enum_field["name"]
396
+ enum_values = enum_field["values"]
397
+
398
+ for i, enum_val in enumerate(enum_values[:4]): # Cap at 4 values per enum
399
+ enum_body = {}
400
+ # Include required fields
401
+ for field in required:
402
+ if field in properties:
403
+ enum_body[field] = generate_example_value(properties[field], field, "default")
404
+ # Set the enum field to this specific value
405
+ enum_body[field_name] = enum_val
406
+
407
+ examples.append({
408
+ "name": f"{field_name}={enum_val}",
409
+ "description": f"Using {field_name} option: {enum_val}",
410
+ "request": enum_body,
411
+ "curl": make_curl(enum_body)
412
+ })
413
+
414
+ # ================================================================
415
+ # Example: Alternative values
416
+ # ================================================================
417
+ if len(properties) > 1:
418
+ alt_body = {}
419
+ for field, prop in properties.items():
420
+ alt_body[field] = generate_example_value(prop, field, "alt")
421
+
422
+ examples.append({
423
+ "name": "Alternative values",
424
+ "description": "Request with alternative/varied parameter values",
425
+ "request": alt_body,
426
+ "curl": make_curl(alt_body)
427
+ })
428
+
429
+ # ================================================================
430
+ # Example: Array fields with multiple items
431
+ # ================================================================
432
+ array_fields = [f for f, p in properties.items() if p.get("type") == "array"]
433
+ if array_fields:
434
+ array_body = {}
435
+ for field in required:
436
+ if field in properties:
437
+ array_body[field] = generate_example_value(properties[field], field, "default")
438
+
439
+ # Show arrays with multiple items
440
+ for field in array_fields:
441
+ array_body[field] = generate_example_value(properties[field], field, "multiple")
442
+
443
+ examples.append({
444
+ "name": "With array items",
445
+ "description": f"Request showing array fields with multiple items: {', '.join(array_fields)}",
446
+ "request": array_body,
447
+ "curl": make_curl(array_body)
448
+ })
449
+
450
+ # ================================================================
451
+ # Example: Boundary values (min/max)
452
+ # ================================================================
453
+ numeric_fields = [f for f, p in properties.items()
454
+ if p.get("type") in ["number", "integer"]
455
+ and (p.get("minimum") is not None or p.get("maximum") is not None)]
456
+ if numeric_fields:
457
+ # Minimum values example
458
+ min_body = {}
459
+ for field in required:
460
+ if field in properties:
461
+ if field in numeric_fields:
462
+ min_body[field] = generate_example_value(properties[field], field, "min")
463
+ else:
464
+ min_body[field] = generate_example_value(properties[field], field, "default")
465
+ for field in numeric_fields:
466
+ min_body[field] = generate_example_value(properties[field], field, "min")
467
+
468
+ examples.append({
469
+ "name": "Minimum boundary values",
470
+ "description": f"Request with minimum values for: {', '.join(numeric_fields)}",
471
+ "request": min_body,
472
+ "curl": make_curl(min_body)
473
+ })
474
+
475
+ # Maximum values example
476
+ max_body = {}
477
+ for field in required:
478
+ if field in properties:
479
+ if field in numeric_fields:
480
+ max_body[field] = generate_example_value(properties[field], field, "max")
481
+ else:
482
+ max_body[field] = generate_example_value(properties[field], field, "default")
483
+ for field in numeric_fields:
484
+ max_body[field] = generate_example_value(properties[field], field, "max")
485
+
486
+ examples.append({
487
+ "name": "Maximum boundary values",
488
+ "description": f"Request with maximum values for: {', '.join(numeric_fields)}",
489
+ "request": max_body,
490
+ "curl": make_curl(max_body)
491
+ })
492
+
493
+ # ================================================================
494
+ # Example: Optional fields only (no required - for APIs with all optional)
495
+ # ================================================================
496
+ optional_fields = [f for f in properties.keys() if f not in required]
497
+ if optional_fields and len(optional_fields) > 1:
498
+ optional_body = {}
499
+ for field in optional_fields[:3]: # First 3 optional
500
+ optional_body[field] = generate_example_value(properties[field], field, "default")
501
+
502
+ # Must include required fields too
503
+ for field in required:
504
+ if field in properties:
505
+ optional_body[field] = generate_example_value(properties[field], field, "default")
506
+
507
+ if len(optional_body) != len(properties): # Only if different from full
508
+ examples.append({
509
+ "name": "With optional parameters",
510
+ "description": f"Request including optional fields: {', '.join(optional_fields[:3])}",
511
+ "request": optional_body,
512
+ "curl": make_curl(optional_body)
513
+ })
514
+
515
+ return examples
516
+
517
+
518
+ def generate_test_cases(schema: dict) -> list:
519
+ """Generate comprehensive test case definitions covering all parameter scenarios."""
520
+ test_cases = []
521
+ properties = schema.get("properties", {})
522
+ required = schema.get("required", [])
523
+
524
+ # Helper to create valid base body
525
+ def make_valid_body():
526
+ body = {}
527
+ for field in required:
528
+ if field in properties:
529
+ body[field] = generate_example_value(properties[field], field, "default")
530
+ return body
531
+
532
+ # ================================================================
533
+ # SUCCESS CASES
534
+ # ================================================================
535
+
536
+ # Test: Valid request with required fields only
537
+ valid_body = make_valid_body()
538
+ if valid_body:
539
+ test_cases.append({
540
+ "name": "Valid request (required only)",
541
+ "description": "Should succeed with valid required parameters",
542
+ "input": valid_body,
543
+ "expectedStatus": 200
544
+ })
545
+
546
+ # Test: Valid request with all fields
547
+ if properties:
548
+ full_body = {}
549
+ for field, prop in properties.items():
550
+ full_body[field] = generate_example_value(prop, field, "default")
551
+ test_cases.append({
552
+ "name": "Valid request (all fields)",
553
+ "description": f"Should succeed with all {len(properties)} parameters",
554
+ "input": full_body,
555
+ "expectedStatus": 200
556
+ })
557
+
558
+ # Test: Valid request with alternative values
559
+ if len(properties) > 1:
560
+ alt_body = {}
561
+ for field, prop in properties.items():
562
+ alt_body[field] = generate_example_value(prop, field, "alt")
563
+ test_cases.append({
564
+ "name": "Valid request (alternative values)",
565
+ "description": "Should succeed with different valid values",
566
+ "input": alt_body,
567
+ "expectedStatus": 200
568
+ })
569
+
570
+ # ================================================================
571
+ # ENUM VALIDATION TESTS
572
+ # ================================================================
573
+ enum_fields = get_all_enum_fields(properties)
574
+ for enum_field in enum_fields:
575
+ field_name = enum_field["name"]
576
+ enum_values = enum_field["values"]
577
+
578
+ # Test: Each valid enum value
579
+ for enum_val in enum_values[:3]: # First 3 values
580
+ enum_body = make_valid_body()
581
+ enum_body[field_name] = enum_val
582
+ test_cases.append({
583
+ "name": f"Valid enum: {field_name}={enum_val}",
584
+ "description": f"Should succeed with {field_name}='{enum_val}'",
585
+ "input": enum_body,
586
+ "expectedStatus": 200
587
+ })
588
+
589
+ # Test: Invalid enum value
590
+ invalid_enum_body = make_valid_body()
591
+ invalid_enum_body[field_name] = "INVALID_ENUM_VALUE_XYZ"
592
+ test_cases.append({
593
+ "name": f"Invalid enum: {field_name}",
594
+ "description": f"Should fail with invalid {field_name} value",
595
+ "input": invalid_enum_body,
596
+ "expectedStatus": 400,
597
+ "expectedError": f"Invalid {field_name}"
598
+ })
599
+
600
+ # ================================================================
601
+ # REQUIRED FIELD TESTS
602
+ # ================================================================
603
+ for req_field in required:
604
+ missing_body = make_valid_body()
605
+ if req_field in missing_body:
606
+ del missing_body[req_field]
607
+ test_cases.append({
608
+ "name": f"Missing required: {req_field}",
609
+ "description": f"Should fail when {req_field} is missing",
610
+ "input": missing_body,
611
+ "expectedStatus": 400,
612
+ "expectedError": f"Required"
613
+ })
614
+
615
+ # ================================================================
616
+ # TYPE VALIDATION TESTS
617
+ # ================================================================
618
+ for field_name, prop in properties.items():
619
+ field_type = prop.get("type", "string")
620
+ invalid_body = make_valid_body()
621
+
622
+ # Generate wrong type based on field type
623
+ if field_type == "string":
624
+ invalid_body[field_name] = 12345 # Number instead of string
625
+ type_desc = "number instead of string"
626
+ elif field_type in ["number", "integer"]:
627
+ invalid_body[field_name] = "not-a-number"
628
+ type_desc = "string instead of number"
629
+ elif field_type == "boolean":
630
+ invalid_body[field_name] = "not-a-boolean"
631
+ type_desc = "string instead of boolean"
632
+ elif field_type == "array":
633
+ invalid_body[field_name] = "not-an-array"
634
+ type_desc = "string instead of array"
635
+ elif field_type == "object":
636
+ invalid_body[field_name] = "not-an-object"
637
+ type_desc = "string instead of object"
638
+ else:
639
+ continue
640
+
641
+ test_cases.append({
642
+ "name": f"Invalid type: {field_name}",
643
+ "description": f"Should fail with {type_desc} for {field_name}",
644
+ "input": invalid_body,
645
+ "expectedStatus": 400
646
+ })
647
+
648
+ # ================================================================
649
+ # BOUNDARY VALUE TESTS
650
+ # ================================================================
651
+ for field_name, prop in properties.items():
652
+ field_type = prop.get("type", "string")
653
+
654
+ # Number/Integer min/max tests
655
+ if field_type in ["number", "integer"]:
656
+ minimum = prop.get("minimum")
657
+ maximum = prop.get("maximum")
658
+
659
+ if minimum is not None:
660
+ # Test at minimum (should pass)
661
+ min_body = make_valid_body()
662
+ min_body[field_name] = minimum
663
+ test_cases.append({
664
+ "name": f"Boundary: {field_name} at minimum ({minimum})",
665
+ "description": f"Should succeed at minimum value",
666
+ "input": min_body,
667
+ "expectedStatus": 200
668
+ })
669
+
670
+ # Test below minimum (should fail)
671
+ below_min_body = make_valid_body()
672
+ below_min_body[field_name] = minimum - 1
673
+ test_cases.append({
674
+ "name": f"Boundary: {field_name} below minimum",
675
+ "description": f"Should fail below minimum ({minimum - 1} < {minimum})",
676
+ "input": below_min_body,
677
+ "expectedStatus": 400
678
+ })
679
+
680
+ if maximum is not None:
681
+ # Test at maximum (should pass)
682
+ max_body = make_valid_body()
683
+ max_body[field_name] = maximum
684
+ test_cases.append({
685
+ "name": f"Boundary: {field_name} at maximum ({maximum})",
686
+ "description": f"Should succeed at maximum value",
687
+ "input": max_body,
688
+ "expectedStatus": 200
689
+ })
690
+
691
+ # Test above maximum (should fail)
692
+ above_max_body = make_valid_body()
693
+ above_max_body[field_name] = maximum + 1
694
+ test_cases.append({
695
+ "name": f"Boundary: {field_name} above maximum",
696
+ "description": f"Should fail above maximum ({maximum + 1} > {maximum})",
697
+ "input": above_max_body,
698
+ "expectedStatus": 400
699
+ })
700
+
701
+ # String length tests
702
+ if field_type == "string":
703
+ min_length = prop.get("minLength")
704
+ max_length = prop.get("maxLength")
705
+
706
+ if min_length is not None and min_length > 0:
707
+ # Test below minLength
708
+ short_body = make_valid_body()
709
+ short_body[field_name] = "x" * (min_length - 1) if min_length > 1 else ""
710
+ test_cases.append({
711
+ "name": f"Boundary: {field_name} too short",
712
+ "description": f"Should fail when {field_name} < {min_length} chars",
713
+ "input": short_body,
714
+ "expectedStatus": 400
715
+ })
716
+
717
+ if max_length is not None:
718
+ # Test above maxLength
719
+ long_body = make_valid_body()
720
+ long_body[field_name] = "x" * (max_length + 1)
721
+ test_cases.append({
722
+ "name": f"Boundary: {field_name} too long",
723
+ "description": f"Should fail when {field_name} > {max_length} chars",
724
+ "input": long_body,
725
+ "expectedStatus": 400
726
+ })
727
+
728
+ # ================================================================
729
+ # ARRAY TESTS
730
+ # ================================================================
731
+ array_fields = [(f, p) for f, p in properties.items() if p.get("type") == "array"]
732
+ for field_name, prop in array_fields:
733
+ # Empty array (if allowed)
734
+ empty_array_body = make_valid_body()
735
+ empty_array_body[field_name] = []
736
+ test_cases.append({
737
+ "name": f"Array: {field_name} empty",
738
+ "description": f"Test with empty {field_name} array",
739
+ "input": empty_array_body,
740
+ "expectedStatus": 200 # Usually allowed unless minItems
741
+ })
742
+
743
+ # Array with multiple items
744
+ multi_array_body = make_valid_body()
745
+ multi_array_body[field_name] = generate_example_value(prop, field_name, "multiple")
746
+ test_cases.append({
747
+ "name": f"Array: {field_name} multiple items",
748
+ "description": f"Test with multiple items in {field_name}",
749
+ "input": multi_array_body,
750
+ "expectedStatus": 200
751
+ })
752
+
753
+ # ================================================================
754
+ # EDGE CASES
755
+ # ================================================================
756
+
757
+ # Empty body
758
+ test_cases.append({
759
+ "name": "Empty body",
760
+ "description": "Should fail with empty request body",
761
+ "input": {},
762
+ "expectedStatus": 400
763
+ })
764
+
765
+ # Null values for required fields
766
+ for req_field in required[:2]: # First 2 required
767
+ null_body = make_valid_body()
768
+ null_body[req_field] = None
769
+ test_cases.append({
770
+ "name": f"Null value: {req_field}",
771
+ "description": f"Should fail when {req_field} is null",
772
+ "input": null_body,
773
+ "expectedStatus": 400
774
+ })
775
+
776
+ # Extra unknown field (should be ignored or error depending on strictness)
777
+ extra_field_body = make_valid_body()
778
+ extra_field_body["unknownExtraField123"] = "should-be-ignored"
779
+ test_cases.append({
780
+ "name": "Extra unknown field",
781
+ "description": "Test behavior with unexpected field",
782
+ "input": extra_field_body,
783
+ "expectedStatus": 200, # Most APIs ignore extra fields
784
+ "note": "Depends on schema strictness"
785
+ })
786
+
787
+ return test_cases
788
+
789
+
790
+ def generate_manifest_entry(endpoint: str, endpoint_data: dict, state: dict) -> dict:
791
+ """Generate a complete manifest entry for the endpoint."""
792
+ # Find schema file
793
+ schema_file = endpoint_data.get("phases", {}).get("schema_creation", {}).get("schema_file")
794
+ if not schema_file:
795
+ # Try to find it
796
+ schema_file = f"src/lib/schemas/{endpoint}.ts"
797
+
798
+ schema_path = STATE_FILE.parent.parent / schema_file
799
+ schema_content = ""
800
+ if schema_path.exists():
801
+ schema_content = schema_path.read_text()
802
+
803
+ # Find route file
804
+ route_content = ""
805
+ files_created = endpoint_data.get("files_created", []) or state.get("files_created", [])
806
+ for f in files_created:
807
+ if "route.ts" in f:
808
+ route_path = STATE_FILE.parent.parent / f
809
+ if route_path.exists():
810
+ route_content = route_path.read_text()
811
+ break
812
+
813
+ # Parse schema
814
+ request_schema = parse_zod_schema(schema_content) if schema_content else {"type": "object", "properties": {}}
815
+
816
+ # Detect method
817
+ method = detect_http_method(route_content)
818
+
819
+ # Generate examples
820
+ examples = generate_curl_examples(endpoint, method, request_schema)
821
+
822
+ # Generate test cases
823
+ test_cases = generate_test_cases(request_schema)
824
+
825
+ # Get interview decisions for description
826
+ interview = endpoint_data.get("phases", {}).get("interview", {})
827
+ decisions = interview.get("decisions", {})
828
+ questions = interview.get("questions", [])
829
+
830
+ # Build description from interview
831
+ description = f"API endpoint for {endpoint}"
832
+ if decisions:
833
+ decision_summary = ", ".join(f"{k}: {v}" for k, v in list(decisions.items())[:3])
834
+ description = f"{endpoint} endpoint. Configured for: {decision_summary}"
835
+
836
+ # Build the manifest entry
837
+ entry = {
838
+ "id": f"{endpoint}-{method.lower()}",
839
+ "method": method,
840
+ "path": f"/api/v2/{endpoint}",
841
+ "summary": f"{method} {endpoint}",
842
+ "description": description,
843
+ "requestSchema": request_schema,
844
+ "responseSchema": {
845
+ "type": "object",
846
+ "properties": {
847
+ "success": {"type": "boolean"},
848
+ "data": {"type": "object"},
849
+ "error": {"type": "string"}
850
+ }
851
+ },
852
+ "examples": examples,
853
+ "testCases": test_cases,
854
+ "metadata": {
855
+ "generatedAt": datetime.now().isoformat(),
856
+ "generatedBy": "api-dev-tools v3.6.7",
857
+ "schemaFile": schema_file,
858
+ "researchFresh": True
859
+ }
860
+ }
861
+
862
+ return entry
863
+
864
+
865
+ def update_manifest(entry: dict, manifest_path: Path = None):
866
+ """Add or update entry in api-tests-manifest.json."""
867
+ if manifest_path is None:
868
+ manifest_path = DEFAULT_MANIFEST
869
+
870
+ if not manifest_path.exists():
871
+ # Create minimal manifest structure
872
+ manifest = {
873
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
874
+ "version": "2.0.0",
875
+ "lastUpdated": datetime.now().strftime("%Y-%m-%d"),
876
+ "baseUrl": "http://localhost:3001",
877
+ "sections": []
878
+ }
879
+ else:
880
+ manifest = json.loads(manifest_path.read_text())
881
+
882
+ # Find or create "Generated APIs" section
883
+ generated_section = None
884
+ for section in manifest.get("sections", []):
885
+ if section.get("id") == "generated-apis":
886
+ generated_section = section
887
+ break
888
+
889
+ if not generated_section:
890
+ generated_section = {
891
+ "id": "generated-apis",
892
+ "name": "Generated APIs",
893
+ "description": "APIs created with /api-create workflow",
894
+ "endpoints": []
895
+ }
896
+ manifest.setdefault("sections", []).append(generated_section)
897
+
898
+ # Remove existing entry with same ID
899
+ generated_section["endpoints"] = [
900
+ e for e in generated_section.get("endpoints", [])
901
+ if e.get("id") != entry["id"]
902
+ ]
903
+
904
+ # Add new entry
905
+ generated_section["endpoints"].append(entry)
906
+
907
+ # Update timestamp
908
+ manifest["lastUpdated"] = datetime.now().strftime("%Y-%m-%d")
909
+
910
+ # Write back
911
+ manifest_path.write_text(json.dumps(manifest, indent=2))
912
+ return True
913
+
914
+
915
+ def main():
916
+ try:
917
+ input_data = json.load(sys.stdin)
918
+ except json.JSONDecodeError:
919
+ input_data = {}
920
+
921
+ # Load state
922
+ if not STATE_FILE.exists():
923
+ print(json.dumps({"continue": True}))
924
+ sys.exit(0)
925
+
926
+ try:
927
+ state = json.loads(STATE_FILE.read_text())
928
+ except json.JSONDecodeError:
929
+ print(json.dumps({"continue": True}))
930
+ sys.exit(0)
931
+
932
+ endpoint, endpoint_data = get_active_endpoint(state)
933
+ if not endpoint or not endpoint_data:
934
+ print(json.dumps({"continue": True}))
935
+ sys.exit(0)
936
+
937
+ # Check if documentation phase is complete or completing
938
+ doc_phase = endpoint_data.get("phases", {}).get("documentation", {})
939
+ if doc_phase.get("status") not in ["in_progress", "complete"]:
940
+ print(json.dumps({"continue": True}))
941
+ sys.exit(0)
942
+
943
+ # Check if manifest already updated
944
+ if doc_phase.get("manifest_updated"):
945
+ print(json.dumps({"continue": True}))
946
+ sys.exit(0)
947
+
948
+ # Generate manifest entry
949
+ try:
950
+ entry = generate_manifest_entry(endpoint, endpoint_data, state)
951
+
952
+ # Update manifest file
953
+ manifest_path = STATE_FILE.parent.parent / "src" / "app" / "api-test" / "api-tests-manifest.json"
954
+ if manifest_path.exists():
955
+ update_manifest(entry, manifest_path)
956
+
957
+ # Update state to mark manifest as updated
958
+ doc_phase["manifest_updated"] = True
959
+ doc_phase["manifest_entry_id"] = entry["id"]
960
+ STATE_FILE.write_text(json.dumps(state, indent=2))
961
+
962
+ print(json.dumps({
963
+ "continue": True,
964
+ "message": f"Generated manifest entry: {entry['id']} with {len(entry['examples'])} examples and {len(entry['testCases'])} test cases"
965
+ }))
966
+ else:
967
+ print(json.dumps({
968
+ "continue": True,
969
+ "message": f"Manifest file not found at {manifest_path}"
970
+ }))
971
+ except Exception as e:
972
+ print(json.dumps({
973
+ "continue": True,
974
+ "message": f"Error generating manifest: {str(e)}"
975
+ }))
976
+
977
+ sys.exit(0)
978
+
979
+
980
+ if __name__ == "__main__":
981
+ main()