delimit-cli 2.4.0 → 3.0.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 (112) hide show
  1. package/.dockerignore +7 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/CODE_OF_CONDUCT.md +48 -0
  4. package/CONTRIBUTING.md +67 -0
  5. package/Dockerfile +9 -0
  6. package/LICENSE +21 -0
  7. package/README.md +18 -69
  8. package/SECURITY.md +42 -0
  9. package/adapters/gemini-forge.js +11 -0
  10. package/adapters/gemini-jamsons.js +152 -0
  11. package/bin/delimit-cli.js +8 -0
  12. package/bin/delimit-setup.js +258 -0
  13. package/gateway/ai/backends/__init__.py +0 -0
  14. package/gateway/ai/backends/async_utils.py +21 -0
  15. package/gateway/ai/backends/deploy_bridge.py +150 -0
  16. package/gateway/ai/backends/gateway_core.py +261 -0
  17. package/gateway/ai/backends/generate_bridge.py +38 -0
  18. package/gateway/ai/backends/governance_bridge.py +196 -0
  19. package/gateway/ai/backends/intel_bridge.py +59 -0
  20. package/gateway/ai/backends/memory_bridge.py +93 -0
  21. package/gateway/ai/backends/ops_bridge.py +137 -0
  22. package/gateway/ai/backends/os_bridge.py +82 -0
  23. package/gateway/ai/backends/repo_bridge.py +117 -0
  24. package/gateway/ai/backends/ui_bridge.py +118 -0
  25. package/gateway/ai/backends/vault_bridge.py +129 -0
  26. package/gateway/ai/server.py +1182 -0
  27. package/gateway/core/__init__.py +3 -0
  28. package/gateway/core/__pycache__/__init__.cpython-310.pyc +0 -0
  29. package/gateway/core/__pycache__/auto_baseline.cpython-310.pyc +0 -0
  30. package/gateway/core/__pycache__/ci_formatter.cpython-310.pyc +0 -0
  31. package/gateway/core/__pycache__/contract_ledger.cpython-310.pyc +0 -0
  32. package/gateway/core/__pycache__/dependency_graph.cpython-310.pyc +0 -0
  33. package/gateway/core/__pycache__/dependency_manifest.cpython-310.pyc +0 -0
  34. package/gateway/core/__pycache__/diff_engine_v2.cpython-310.pyc +0 -0
  35. package/gateway/core/__pycache__/event_backbone.cpython-310.pyc +0 -0
  36. package/gateway/core/__pycache__/event_schema.cpython-310.pyc +0 -0
  37. package/gateway/core/__pycache__/explainer.cpython-310.pyc +0 -0
  38. package/gateway/core/__pycache__/gateway.cpython-310.pyc +0 -0
  39. package/gateway/core/__pycache__/gateway_v2.cpython-310.pyc +0 -0
  40. package/gateway/core/__pycache__/gateway_v3.cpython-310.pyc +0 -0
  41. package/gateway/core/__pycache__/impact_analyzer.cpython-310.pyc +0 -0
  42. package/gateway/core/__pycache__/policy_engine.cpython-310.pyc +0 -0
  43. package/gateway/core/__pycache__/registry.cpython-310.pyc +0 -0
  44. package/gateway/core/__pycache__/registry_v2.cpython-310.pyc +0 -0
  45. package/gateway/core/__pycache__/registry_v3.cpython-310.pyc +0 -0
  46. package/gateway/core/__pycache__/semver_classifier.cpython-310.pyc +0 -0
  47. package/gateway/core/__pycache__/spec_detector.cpython-310.pyc +0 -0
  48. package/gateway/core/__pycache__/surface_bridge.cpython-310.pyc +0 -0
  49. package/gateway/core/auto_baseline.py +304 -0
  50. package/gateway/core/ci_formatter.py +283 -0
  51. package/gateway/core/complexity_analyzer.py +386 -0
  52. package/gateway/core/contract_ledger.py +345 -0
  53. package/gateway/core/dependency_graph.py +218 -0
  54. package/gateway/core/dependency_manifest.py +223 -0
  55. package/gateway/core/diff_engine_v2.py +477 -0
  56. package/gateway/core/diff_engine_v2.py.bak +426 -0
  57. package/gateway/core/event_backbone.py +268 -0
  58. package/gateway/core/event_schema.py +258 -0
  59. package/gateway/core/explainer.py +438 -0
  60. package/gateway/core/gateway.py +128 -0
  61. package/gateway/core/gateway_v2.py +154 -0
  62. package/gateway/core/gateway_v3.py +224 -0
  63. package/gateway/core/impact_analyzer.py +163 -0
  64. package/gateway/core/policies/default.yml +13 -0
  65. package/gateway/core/policies/relaxed.yml +48 -0
  66. package/gateway/core/policies/strict.yml +55 -0
  67. package/gateway/core/policy_engine.py +464 -0
  68. package/gateway/core/registry.py +52 -0
  69. package/gateway/core/registry_v2.py +132 -0
  70. package/gateway/core/registry_v3.py +134 -0
  71. package/gateway/core/semver_classifier.py +152 -0
  72. package/gateway/core/spec_detector.py +130 -0
  73. package/gateway/core/surface_bridge.py +307 -0
  74. package/gateway/core/zero_spec/__init__.py +4 -0
  75. package/gateway/core/zero_spec/__pycache__/__init__.cpython-310.pyc +0 -0
  76. package/gateway/core/zero_spec/__pycache__/detector.cpython-310.pyc +0 -0
  77. package/gateway/core/zero_spec/__pycache__/express_extractor.cpython-310.pyc +0 -0
  78. package/gateway/core/zero_spec/__pycache__/fastapi_extractor.cpython-310.pyc +0 -0
  79. package/gateway/core/zero_spec/__pycache__/nestjs_extractor.cpython-310.pyc +0 -0
  80. package/gateway/core/zero_spec/detector.py +353 -0
  81. package/gateway/core/zero_spec/express_extractor.py +483 -0
  82. package/gateway/core/zero_spec/fastapi_extractor.py +254 -0
  83. package/gateway/core/zero_spec/nestjs_extractor.py +369 -0
  84. package/gateway/tasks/__init__.py +1 -0
  85. package/gateway/tasks/__pycache__/__init__.cpython-310.pyc +0 -0
  86. package/gateway/tasks/__pycache__/check_policy.cpython-310.pyc +0 -0
  87. package/gateway/tasks/__pycache__/check_policy_v2.cpython-310.pyc +0 -0
  88. package/gateway/tasks/__pycache__/check_policy_v3.cpython-310.pyc +0 -0
  89. package/gateway/tasks/__pycache__/explain_diff.cpython-310.pyc +0 -0
  90. package/gateway/tasks/__pycache__/explain_diff_v2.cpython-310.pyc +0 -0
  91. package/gateway/tasks/__pycache__/validate_api.cpython-310.pyc +0 -0
  92. package/gateway/tasks/__pycache__/validate_api_v2.cpython-310.pyc +0 -0
  93. package/gateway/tasks/__pycache__/validate_api_v3.cpython-310.pyc +0 -0
  94. package/gateway/tasks/check_policy.py +177 -0
  95. package/gateway/tasks/check_policy_v2.py +255 -0
  96. package/gateway/tasks/check_policy_v3.py +255 -0
  97. package/gateway/tasks/explain_diff.py +305 -0
  98. package/gateway/tasks/explain_diff_v2.py +267 -0
  99. package/gateway/tasks/validate_api.py +131 -0
  100. package/gateway/tasks/validate_api_v2.py +208 -0
  101. package/gateway/tasks/validate_api_v3.py +163 -0
  102. package/package.json +2 -2
  103. package/adapters/codex-skill.js +0 -87
  104. package/adapters/cursor-extension.js +0 -190
  105. package/adapters/gemini-action.js +0 -93
  106. package/adapters/openai-function.js +0 -112
  107. package/adapters/xai-plugin.js +0 -151
  108. package/test-decision-engine.js +0 -181
  109. package/test-hook.js +0 -27
  110. package/tests/cli.test.js +0 -359
  111. package/tests/fixtures/openapi-changed.yaml +0 -56
  112. package/tests/fixtures/openapi.yaml +0 -87
@@ -0,0 +1,483 @@
1
+ """
2
+ Express OpenAPI extractor — generates an OpenAPI spec from Express source code
3
+ by introspecting the app's route stack at runtime. Since Express has no built-in
4
+ OpenAPI generator, we inject a script that requires the app, walks
5
+ app._router.stack, and builds a minimal spec from discovered routes.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from .detector import AppLocation, FrameworkInfo
17
+
18
+
19
+ # Node.js script that requires the Express app, walks the router stack,
20
+ # and emits a minimal OpenAPI 3.0.3 spec as JSON to stdout.
21
+ _EXTRACTOR_SCRIPT = r'''
22
+ "use strict";
23
+
24
+ const path = require("path");
25
+
26
+ const projectRoot = process.argv[2];
27
+ const appFile = process.argv[3];
28
+ const appVar = process.argv[4];
29
+
30
+ // Resolve the app module relative to the project root
31
+ const modulePath = path.resolve(projectRoot, appFile);
32
+
33
+ let appModule;
34
+ try {
35
+ appModule = require(modulePath);
36
+ } catch (e) {
37
+ console.log(JSON.stringify({ error: "Import failed: " + e.message, type: "import" }));
38
+ process.exit(1);
39
+ }
40
+
41
+ const app = appModule[appVar] || appModule.default || appModule;
42
+
43
+ if (!app || typeof app !== "function" && typeof app !== "object") {
44
+ console.log(JSON.stringify({ error: "Variable '" + appVar + "' not found or not an Express app", type: "app_not_found" }));
45
+ process.exit(1);
46
+ }
47
+
48
+ // Check if this looks like an Express app (has _router or use/get methods)
49
+ if (!app._router && !app.get && !app.use) {
50
+ console.log(JSON.stringify({ error: "'" + appVar + "' does not appear to be an Express app (no _router)", type: "not_express" }));
51
+ process.exit(1);
52
+ }
53
+
54
+ // Force Express to initialise its router if lazy
55
+ if (!app._router && typeof app.lazyrouter === "function") {
56
+ app.lazyrouter();
57
+ }
58
+
59
+ // Collect routes -------------------------------------------------------
60
+
61
+ function expressParamToOpenAPI(routePath) {
62
+ // Convert Express :param to OpenAPI {param}
63
+ return routePath.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
64
+ }
65
+
66
+ function extractPathParams(routePath) {
67
+ const params = [];
68
+ const re = /:([A-Za-z0-9_]+)/g;
69
+ let m;
70
+ while ((m = re.exec(routePath)) !== null) {
71
+ params.push(m[1]);
72
+ }
73
+ return params;
74
+ }
75
+
76
+ function collectRoutes(stack, prefix) {
77
+ const routes = [];
78
+ if (!stack) return routes;
79
+
80
+ for (const layer of stack) {
81
+ if (layer.route) {
82
+ // Direct route on the app
83
+ const routePath = prefix + layer.route.path;
84
+ const methods = Object.keys(layer.route.methods).filter(m => m !== "_all");
85
+ for (const method of methods) {
86
+ routes.push({ path: routePath, method: method.toLowerCase() });
87
+ }
88
+ } else if (layer.name === "router" && layer.handle && layer.handle.stack) {
89
+ // Mounted sub-router via app.use('/prefix', router)
90
+ let mountPath = "";
91
+ if (layer.regexp && layer.keys && layer.keys.length === 0) {
92
+ // Try to extract the mount path from the regexp source
93
+ mountPath = regexpToPath(layer.regexp);
94
+ }
95
+ if (layer.path) {
96
+ mountPath = layer.path;
97
+ }
98
+ // Recurse into the sub-router
99
+ const subRoutes = collectRoutes(layer.handle.stack, prefix + mountPath);
100
+ routes.push(...subRoutes);
101
+ }
102
+ }
103
+
104
+ return routes;
105
+ }
106
+
107
+ function regexpToPath(regexp) {
108
+ // Express stores mount paths as regexps. Common pattern:
109
+ // /^\/api\/v1\/?(?=\/|$)/i => /api/v1
110
+ if (!regexp || !regexp.source) return "";
111
+ const src = regexp.source;
112
+ // Strip anchors and optional trailing slash patterns
113
+ let cleaned = src
114
+ .replace(/^\^/, "")
115
+ .replace(/\\\/\?\(\?=\\\/\|\$\)$/i, "")
116
+ .replace(/\\\/\?\(\?:\\\/\)\?$/i, "")
117
+ .replace(/\\\//g, "/")
118
+ .replace(/\$$/,"");
119
+ // Only return if it looks like a clean path
120
+ if (/^[\/A-Za-z0-9_\-\.{}:]+$/.test(cleaned)) {
121
+ return cleaned;
122
+ }
123
+ return "";
124
+ }
125
+
126
+ let routes;
127
+ try {
128
+ routes = collectRoutes(app._router ? app._router.stack : [], "");
129
+ } catch (e) {
130
+ console.log(JSON.stringify({ error: "Route extraction failed: " + e.message, type: "route_extraction" }));
131
+ process.exit(1);
132
+ }
133
+
134
+ if (routes.length === 0) {
135
+ console.log(JSON.stringify({ error: "No routes found in app._router.stack", type: "no_routes" }));
136
+ process.exit(1);
137
+ }
138
+
139
+ // Build OpenAPI spec ---------------------------------------------------
140
+
141
+ // Read package.json for metadata
142
+ let pkgName = "Express API";
143
+ let pkgVersion = "1.0.0";
144
+ try {
145
+ const pkg = require(path.resolve(projectRoot, "package.json"));
146
+ pkgName = pkg.name || pkgName;
147
+ pkgVersion = pkg.version || pkgVersion;
148
+ } catch (_) {}
149
+
150
+ const paths = {};
151
+ for (const route of routes) {
152
+ const oapiPath = expressParamToOpenAPI(route.path);
153
+ if (!paths[oapiPath]) {
154
+ paths[oapiPath] = {};
155
+ }
156
+
157
+ const pathParams = extractPathParams(route.path);
158
+ const parameters = pathParams.map(p => ({
159
+ name: p,
160
+ in: "path",
161
+ required: true,
162
+ schema: { type: "string" },
163
+ }));
164
+
165
+ const operation = {
166
+ summary: route.method.toUpperCase() + " " + oapiPath,
167
+ responses: {
168
+ "200": { description: "Successful response" },
169
+ },
170
+ };
171
+
172
+ if (parameters.length > 0) {
173
+ operation.parameters = parameters;
174
+ }
175
+
176
+ // POST/PUT/PATCH get a requestBody placeholder
177
+ if (["post", "put", "patch"].includes(route.method)) {
178
+ operation.requestBody = {
179
+ content: {
180
+ "application/json": {
181
+ schema: { type: "object" },
182
+ },
183
+ },
184
+ };
185
+ }
186
+
187
+ paths[oapiPath][route.method] = operation;
188
+ }
189
+
190
+ const spec = {
191
+ openapi: "3.0.3",
192
+ info: {
193
+ title: pkgName,
194
+ version: pkgVersion,
195
+ },
196
+ paths: paths,
197
+ };
198
+
199
+ console.log(JSON.stringify(spec));
200
+ '''
201
+
202
+
203
+ def extract_express_spec(
204
+ info: FrameworkInfo,
205
+ project_dir: str = ".",
206
+ timeout: int = 15,
207
+ node_bin: Optional[str] = None,
208
+ ) -> Dict[str, Any]:
209
+ """
210
+ Extract OpenAPI spec from an Express project.
211
+
212
+ Args:
213
+ info: FrameworkInfo from detect_framework()
214
+ project_dir: Root of the Express project
215
+ timeout: Max seconds for extraction subprocess
216
+ node_bin: Node binary to use (auto-detected if None)
217
+
218
+ Returns:
219
+ Dict with keys:
220
+ - success: bool
221
+ - spec: OpenAPI dict (if success)
222
+ - spec_path: Path to temp YAML file (if success)
223
+ - openapi_version: str (if success)
224
+ - paths_count: int (if success)
225
+ - schemas_count: int (if success)
226
+ - error: Error message (if not success)
227
+ - error_type: Error category (if not success)
228
+ """
229
+ root = Path(project_dir).resolve()
230
+
231
+ if not info.app_locations:
232
+ # Try to auto-detect the app file
233
+ app_loc = _find_express_app_fallback(root)
234
+ if not app_loc:
235
+ return {
236
+ "success": False,
237
+ "error": "No Express app instance found. Looked for module.exports = app or exports patterns.",
238
+ "error_type": "no_app",
239
+ }
240
+ else:
241
+ app_loc = info.app_locations[0]
242
+
243
+ node = node_bin or _find_node(root)
244
+ if not node:
245
+ return {
246
+ "success": False,
247
+ "error": "Node.js not found. Install Node.js 14+ or set node_bin.",
248
+ "error_type": "no_node",
249
+ }
250
+
251
+ # Check node_modules exists (Express needs to be installed)
252
+ if not (root / "node_modules").exists():
253
+ return {
254
+ "success": False,
255
+ "error": "node_modules not found. Run: npm install",
256
+ "error_type": "missing_deps",
257
+ }
258
+
259
+ # Check express is actually importable
260
+ if not _check_express_installed(node, root):
261
+ return {
262
+ "success": False,
263
+ "error": "Express not installed. Run: npm install express",
264
+ "error_type": "missing_deps",
265
+ }
266
+
267
+ # Write extractor script to temp file inside the project (so require() resolves)
268
+ with tempfile.NamedTemporaryFile(
269
+ mode="w", suffix=".js", prefix="_delimit_extract_",
270
+ dir=str(root), delete=False,
271
+ ) as f:
272
+ f.write(_EXTRACTOR_SCRIPT)
273
+ script_path = f.name
274
+
275
+ try:
276
+ result = subprocess.run(
277
+ [node, script_path, str(root), app_loc.file, app_loc.variable],
278
+ capture_output=True,
279
+ text=True,
280
+ timeout=timeout,
281
+ cwd=str(root),
282
+ env={**os.environ, "NODE_ENV": "production"},
283
+ )
284
+
285
+ stdout = result.stdout.strip()
286
+ stderr = result.stderr.strip()
287
+
288
+ if result.returncode != 0:
289
+ # Try to parse structured error from stdout
290
+ try:
291
+ err = json.loads(stdout)
292
+ return {
293
+ "success": False,
294
+ "error": err.get("error", "Extraction failed"),
295
+ "error_type": err.get("type", "unknown"),
296
+ }
297
+ except json.JSONDecodeError:
298
+ return {
299
+ "success": False,
300
+ "error": stderr[:500] or stdout[:500] or "Express extraction subprocess failed",
301
+ "error_type": "subprocess",
302
+ }
303
+
304
+ # Parse the OpenAPI spec
305
+ try:
306
+ spec = json.loads(stdout)
307
+ except json.JSONDecodeError:
308
+ return {
309
+ "success": False,
310
+ "error": "Extractor produced invalid JSON",
311
+ "error_type": "parse",
312
+ }
313
+
314
+ if "error" in spec and "paths" not in spec:
315
+ return {
316
+ "success": False,
317
+ "error": spec["error"],
318
+ "error_type": spec.get("type", "unknown"),
319
+ }
320
+
321
+ # Validate it looks like an OpenAPI spec
322
+ if "openapi" not in spec and "swagger" not in spec:
323
+ return {
324
+ "success": False,
325
+ "error": "Output is not a valid OpenAPI spec (missing 'openapi' key)",
326
+ "error_type": "invalid_spec",
327
+ }
328
+
329
+ # Write to temp YAML/JSON file for downstream consumption
330
+ spec_path = _write_temp_spec(spec, root)
331
+
332
+ return {
333
+ "success": True,
334
+ "spec": spec,
335
+ "spec_path": spec_path,
336
+ "openapi_version": spec.get("openapi", spec.get("swagger", "unknown")),
337
+ "paths_count": len(spec.get("paths", {})),
338
+ "schemas_count": len(spec.get("components", {}).get("schemas", {})),
339
+ }
340
+
341
+ except subprocess.TimeoutExpired:
342
+ return {
343
+ "success": False,
344
+ "error": f"Extraction timed out after {timeout}s. Check for blocking I/O in app startup.",
345
+ "error_type": "timeout",
346
+ }
347
+ finally:
348
+ try:
349
+ os.unlink(script_path)
350
+ except OSError:
351
+ pass
352
+
353
+
354
+ def _find_node(root: Path) -> Optional[str]:
355
+ """Find the best Node.js binary."""
356
+ # Check for nvm/local node
357
+ for name in ["node", "nodejs"]:
358
+ try:
359
+ result = subprocess.run(
360
+ [name, "--version"], capture_output=True, text=True, timeout=5,
361
+ )
362
+ if result.returncode == 0:
363
+ return name
364
+ except (FileNotFoundError, subprocess.TimeoutExpired):
365
+ continue
366
+ return None
367
+
368
+
369
+ def _check_express_installed(node: str, root: Path) -> bool:
370
+ """Check if express is importable with the given Node."""
371
+ try:
372
+ result = subprocess.run(
373
+ [node, "-e", "require('express'); console.log('ok')"],
374
+ capture_output=True,
375
+ text=True,
376
+ timeout=10,
377
+ cwd=str(root),
378
+ )
379
+ return result.returncode == 0
380
+ except Exception:
381
+ return False
382
+
383
+
384
+ def _find_express_app_fallback(root: Path) -> Optional[AppLocation]:
385
+ """Try to find an Express app export in common entry point files."""
386
+ candidates = [
387
+ "app.js", "src/app.js", "server.js", "src/server.js",
388
+ "index.js", "src/index.js", "app.ts", "src/app.ts",
389
+ ]
390
+
391
+ for rel_path in candidates:
392
+ full_path = root / rel_path
393
+ if not full_path.exists():
394
+ continue
395
+
396
+ try:
397
+ content = full_path.read_text()
398
+ except Exception:
399
+ continue
400
+
401
+ # Check for Express app creation patterns
402
+ if not re.search(r"require\s*\(\s*['\"]express['\"]\s*\)", content) and \
403
+ not re.search(r"from\s+['\"]express['\"]", content):
404
+ continue
405
+
406
+ # Find the variable name of the Express app
407
+ var_name = _detect_app_variable(content)
408
+ if var_name:
409
+ return AppLocation(file=rel_path, variable=var_name, line=1)
410
+
411
+ return None
412
+
413
+
414
+ def _detect_app_variable(content: str) -> Optional[str]:
415
+ """Detect the variable name of an Express app instance in source code."""
416
+ # Pattern: const app = express()
417
+ m = re.search(r"(?:const|let|var)\s+(\w+)\s*=\s*(?:express\s*\(\s*\)|require\s*\(\s*['\"]express['\"]\s*\)\s*\(\s*\))", content)
418
+ if m:
419
+ return m.group(1)
420
+
421
+ # Pattern: const express = require('express'); ... const app = express();
422
+ # First find what express is called
423
+ express_var = None
424
+ m_req = re.search(r"(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['\"]express['\"]\s*\)", content)
425
+ if m_req:
426
+ express_var = m_req.group(1)
427
+
428
+ if express_var:
429
+ m_app = re.search(rf"(?:const|let|var)\s+(\w+)\s*=\s*{re.escape(express_var)}\s*\(\s*\)", content)
430
+ if m_app:
431
+ return m_app.group(1)
432
+
433
+ # Fallback: look for module.exports = <varname> where varname is likely the app
434
+ m_exp = re.search(r"module\.exports\s*=\s*(\w+)", content)
435
+ if m_exp:
436
+ return m_exp.group(1)
437
+
438
+ # Fallback: exports.app = ...
439
+ m_exp2 = re.search(r"exports\.(\w+)\s*=", content)
440
+ if m_exp2:
441
+ return m_exp2.group(1)
442
+
443
+ return None
444
+
445
+
446
+ def _iter_js_files(root: Path, max_files: int = 50) -> List[Path]:
447
+ """Iterate JS/TS files, skipping node_modules and hidden dirs."""
448
+ skip_dirs = {
449
+ "node_modules", ".git", "dist", "build", "coverage",
450
+ ".nyc_output", ".next", ".nuxt",
451
+ }
452
+ count = 0
453
+ files = []
454
+ for path in root.rglob("*.js"):
455
+ if any(part in skip_dirs for part in path.parts):
456
+ continue
457
+ files.append(path)
458
+ count += 1
459
+ if count >= max_files:
460
+ break
461
+ return files
462
+
463
+
464
+ def _write_temp_spec(spec: Dict[str, Any], root: Path) -> str:
465
+ """Write extracted spec to a temp YAML file."""
466
+ import hashlib
467
+
468
+ try:
469
+ import yaml
470
+ formatter = yaml.dump
471
+ ext = ".yaml"
472
+ except ImportError:
473
+ formatter = lambda d: json.dumps(d, indent=2)
474
+ ext = ".json"
475
+
476
+ hash_input = str(root).encode()
477
+ short_hash = hashlib.sha256(hash_input).hexdigest()[:8]
478
+ spec_path = os.path.join(tempfile.gettempdir(), f"delimit-inferred-express-{short_hash}{ext}")
479
+
480
+ with open(spec_path, "w") as f:
481
+ f.write(formatter(spec))
482
+
483
+ return spec_path