openhack 0.1.0__py3-none-any.whl

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 (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,515 @@
1
+ """
2
+ Framework classifier — detects frameworks by reading dependency files.
3
+
4
+ Walks the repo, finds all dependency files (package.json, requirements.txt, etc.),
5
+ reads them, and maps each directory to its framework(s). Handles monorepos with
6
+ multiple frameworks in different directories.
7
+
8
+ This replaces the old framework_detection.py approach of guessing from folder names.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ from .tools.filesystem import FileSystemTools
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # Framework detection rules: dependency file → package name → framework
24
+ PACKAGE_JSON_FRAMEWORKS = {
25
+ # JavaScript/TypeScript web frameworks
26
+ "next": "nextjs",
27
+ "express": "express",
28
+ "@nestjs/core": "nestjs",
29
+ "fastify": "fastify",
30
+ "hono": "hono",
31
+ "koa": "koa",
32
+ "@hapi/hapi": "hapi",
33
+ "sails": "sails",
34
+ "nuxt": "nuxt",
35
+ "@sveltejs/kit": "sveltekit",
36
+ "@remix-run/node": "remix",
37
+ # Protocol/API frameworks
38
+ "@trpc/server": "trpc",
39
+ "graphql": "graphql",
40
+ "apollo-server": "graphql",
41
+ "@apollo/server": "graphql",
42
+ "mercurius": "graphql",
43
+ "graphql-yoga": "graphql",
44
+ "socket.io": "websocket",
45
+ "ws": "websocket",
46
+ "@grpc/grpc-js": "grpc",
47
+ # Auth libraries (supplementary detection)
48
+ "passport": "passport",
49
+ "next-auth": "nextauth",
50
+ "@auth/core": "authjs",
51
+ }
52
+
53
+ PYTHON_FRAMEWORKS = {
54
+ "django": "django",
55
+ "djangorestframework": "django_rest",
56
+ "django-rest-framework": "django_rest",
57
+ "flask": "flask",
58
+ "fastapi": "fastapi",
59
+ "starlette": "starlette",
60
+ "tornado": "tornado",
61
+ "sanic": "sanic",
62
+ "aiohttp": "aiohttp",
63
+ "graphene": "graphql",
64
+ "strawberry-graphql": "graphql",
65
+ "ariadne": "graphql",
66
+ "channels": "websocket",
67
+ "celery": "celery",
68
+ "grpcio": "grpc",
69
+ }
70
+
71
+ RUBY_FRAMEWORKS = {
72
+ "rails": "rails",
73
+ "sinatra": "sinatra",
74
+ "grape": "grape",
75
+ "graphql": "graphql",
76
+ "action_cable": "websocket",
77
+ }
78
+
79
+ PHP_FRAMEWORKS = {
80
+ "laravel/framework": "laravel",
81
+ "symfony/framework-bundle": "symfony",
82
+ "symfony/http-kernel": "symfony",
83
+ "codeigniter4/framework": "codeigniter",
84
+ "cakephp/cakephp": "cakephp",
85
+ "slim/slim": "slim",
86
+ }
87
+
88
+ GO_FRAMEWORKS = {
89
+ "github.com/gin-gonic/gin": "gin",
90
+ "github.com/labstack/echo": "echo",
91
+ "github.com/gofiber/fiber": "fiber",
92
+ "github.com/go-chi/chi": "chi",
93
+ "google.golang.org/grpc": "grpc",
94
+ "github.com/gorilla/mux": "gorilla",
95
+ "github.com/gorilla/websocket": "websocket",
96
+ "net/http": "net_http",
97
+ }
98
+
99
+ JAVA_FRAMEWORKS = {
100
+ "spring-boot-starter-web": "spring",
101
+ "spring-boot-starter-webflux": "spring_webflux",
102
+ "javax.ws.rs": "jaxrs",
103
+ "jakarta.ws.rs": "jaxrs",
104
+ "io.quarkus": "quarkus",
105
+ "io.micronaut": "micronaut",
106
+ }
107
+
108
+ DOTNET_FRAMEWORKS = {
109
+ "Microsoft.AspNetCore": "aspnet",
110
+ "Microsoft.AspNetCore.Mvc": "aspnet_mvc",
111
+ "Microsoft.AspNetCore.SignalR": "signalr",
112
+ }
113
+
114
+ RUST_FRAMEWORKS = {
115
+ "actix-web": "actix",
116
+ "axum": "axum",
117
+ "rocket": "rocket",
118
+ "warp": "warp",
119
+ "tonic": "grpc",
120
+ }
121
+
122
+
123
+ def classify_frameworks(fs: FileSystemTools) -> list[dict]:
124
+ """Classify frameworks in the repository by reading dependency files.
125
+
126
+ Returns a list of dicts, each with:
127
+ - root: directory path relative to repo root
128
+ - language: python, javascript, ruby, php, go, java, dotnet, rust, c
129
+ - frameworks: list of detected framework names
130
+ - dep_file: path to the dependency file used for detection
131
+ """
132
+ classifications = []
133
+
134
+ # Find all dependency files
135
+ dep_file_patterns = [
136
+ ("package.json", _parse_package_json),
137
+ ("requirements.txt", _parse_requirements_txt),
138
+ ("pyproject.toml", _parse_pyproject_toml),
139
+ ("Pipfile", _parse_pipfile),
140
+ ("Gemfile", _parse_gemfile),
141
+ ("composer.json", _parse_composer_json),
142
+ ("go.mod", _parse_go_mod),
143
+ ("Cargo.toml", _parse_cargo_toml),
144
+ ]
145
+
146
+ for filename, parser in dep_file_patterns:
147
+ matches = fs.glob(f"**/{filename}", ".")
148
+ for filepath in matches.get("matches", []):
149
+ # Skip node_modules, vendor, etc.
150
+ if any(skip in filepath for skip in [
151
+ "node_modules/", "vendor/", ".venv/", "__pycache__/",
152
+ "test/", "tests/", "fixtures/", "examples/", "demo/",
153
+ ]):
154
+ continue
155
+
156
+ result = fs.read_file(filepath)
157
+ if "error" in result:
158
+ continue
159
+
160
+ content = result.get("content", "")
161
+ # Strip line number prefixes from read_file
162
+ lines = []
163
+ for line in content.split("\n"):
164
+ if "\t" in line:
165
+ lines.append(line.split("\t", 1)[1])
166
+ else:
167
+ lines.append(line)
168
+ clean_content = "\n".join(lines)
169
+
170
+ try:
171
+ classification = parser(clean_content, filepath)
172
+ if classification and classification.get("frameworks"):
173
+ classifications.append(classification)
174
+ except Exception as e:
175
+ logger.debug(f"Failed to parse {filepath}: {e}")
176
+
177
+ # Also detect C/C++ projects (no dependency file, use Makefile/CMakeLists)
178
+ c_class = _detect_c_project(fs)
179
+ if c_class:
180
+ classifications.append(c_class)
181
+
182
+ # Also detect Java projects via pom.xml / build.gradle
183
+ java_class = _detect_java_project(fs)
184
+ if java_class:
185
+ classifications.append(java_class)
186
+
187
+ # Also detect .NET projects via .csproj
188
+ dotnet_class = _detect_dotnet_project(fs)
189
+ if dotnet_class:
190
+ classifications.append(dotnet_class)
191
+
192
+ # Also detect Rails projects via config/application.rb (handles Gemfile.d/ patterns)
193
+ rails_class = _detect_rails_project(fs)
194
+ if rails_class:
195
+ classifications.append(rails_class)
196
+
197
+ # Deduplicate — if same root has multiple dep files, merge
198
+ merged = _merge_classifications(classifications)
199
+
200
+ logger.info(f"Classified {len(merged)} framework(s): "
201
+ + ", ".join(f"{c['root']}={c['frameworks']}" for c in merged))
202
+
203
+ return merged
204
+
205
+
206
+ def _get_root(filepath: str) -> str:
207
+ """Get the directory of a dependency file relative to repo root."""
208
+ parts = filepath.split("/")
209
+ if len(parts) <= 1:
210
+ return "."
211
+ return "/".join(parts[:-1])
212
+
213
+
214
+ def _parse_package_json(content: str, filepath: str) -> Optional[dict]:
215
+ """Parse package.json for framework dependencies."""
216
+ try:
217
+ pkg = json.loads(content)
218
+ except json.JSONDecodeError:
219
+ return None
220
+
221
+ deps = {}
222
+ deps.update(pkg.get("dependencies", {}))
223
+ deps.update(pkg.get("devDependencies", {}))
224
+
225
+ frameworks = []
226
+ for package_name, framework_name in PACKAGE_JSON_FRAMEWORKS.items():
227
+ if package_name in deps:
228
+ frameworks.append(framework_name)
229
+
230
+ if not frameworks:
231
+ return None
232
+
233
+ return {
234
+ "root": _get_root(filepath),
235
+ "language": "javascript",
236
+ "frameworks": sorted(set(frameworks)),
237
+ "dep_file": filepath,
238
+ }
239
+
240
+
241
+ def _parse_requirements_txt(content: str, filepath: str) -> Optional[dict]:
242
+ """Parse requirements.txt for Python framework dependencies."""
243
+ frameworks = []
244
+ for line in content.split("\n"):
245
+ line = line.strip().lower()
246
+ if not line or line.startswith("#") or line.startswith("-"):
247
+ continue
248
+ # Extract package name (before ==, >=, ~=, etc.)
249
+ pkg = re.split(r"[=<>~!;\[]", line)[0].strip()
250
+ if pkg in PYTHON_FRAMEWORKS:
251
+ frameworks.append(PYTHON_FRAMEWORKS[pkg])
252
+
253
+ if not frameworks:
254
+ return None
255
+
256
+ return {
257
+ "root": _get_root(filepath),
258
+ "language": "python",
259
+ "frameworks": sorted(set(frameworks)),
260
+ "dep_file": filepath,
261
+ }
262
+
263
+
264
+ def _parse_pyproject_toml(content: str, filepath: str) -> Optional[dict]:
265
+ """Parse pyproject.toml for Python framework dependencies."""
266
+ frameworks = []
267
+ # Simple TOML parsing — look for dependency declarations
268
+ for line in content.split("\n"):
269
+ line_lower = line.strip().lower()
270
+ for pkg, framework in PYTHON_FRAMEWORKS.items():
271
+ if pkg in line_lower:
272
+ frameworks.append(framework)
273
+
274
+ if not frameworks:
275
+ return None
276
+
277
+ return {
278
+ "root": _get_root(filepath),
279
+ "language": "python",
280
+ "frameworks": sorted(set(frameworks)),
281
+ "dep_file": filepath,
282
+ }
283
+
284
+
285
+ def _parse_pipfile(content: str, filepath: str) -> Optional[dict]:
286
+ """Parse Pipfile for Python framework dependencies."""
287
+ return _parse_pyproject_toml(content, filepath) # Similar enough format
288
+
289
+
290
+ def _parse_gemfile(content: str, filepath: str) -> Optional[dict]:
291
+ """Parse Gemfile for Ruby framework dependencies."""
292
+ frameworks = []
293
+ for line in content.split("\n"):
294
+ line = line.strip()
295
+ if not line or line.startswith("#"):
296
+ continue
297
+ # gem 'rails', '~> 7.0'
298
+ match = re.match(r"gem\s+['\"]([^'\"]+)['\"]", line)
299
+ if match:
300
+ gem_name = match.group(1).lower()
301
+ if gem_name in RUBY_FRAMEWORKS:
302
+ frameworks.append(RUBY_FRAMEWORKS[gem_name])
303
+
304
+ if not frameworks:
305
+ return None
306
+
307
+ return {
308
+ "root": _get_root(filepath),
309
+ "language": "ruby",
310
+ "frameworks": sorted(set(frameworks)),
311
+ "dep_file": filepath,
312
+ }
313
+
314
+
315
+ def _parse_composer_json(content: str, filepath: str) -> Optional[dict]:
316
+ """Parse composer.json for PHP framework dependencies."""
317
+ try:
318
+ pkg = json.loads(content)
319
+ except json.JSONDecodeError:
320
+ return None
321
+
322
+ deps = pkg.get("require", {})
323
+ frameworks = []
324
+ for package_name, framework_name in PHP_FRAMEWORKS.items():
325
+ if package_name in deps:
326
+ frameworks.append(framework_name)
327
+
328
+ if not frameworks:
329
+ # Check if it's raw PHP (has composer.json but no framework)
330
+ return {
331
+ "root": _get_root(filepath),
332
+ "language": "php",
333
+ "frameworks": ["php_raw"],
334
+ "dep_file": filepath,
335
+ }
336
+
337
+ return {
338
+ "root": _get_root(filepath),
339
+ "language": "php",
340
+ "frameworks": sorted(set(frameworks)),
341
+ "dep_file": filepath,
342
+ }
343
+
344
+
345
+ def _parse_go_mod(content: str, filepath: str) -> Optional[dict]:
346
+ """Parse go.mod for Go framework dependencies."""
347
+ frameworks = []
348
+ for line in content.split("\n"):
349
+ line = line.strip()
350
+ for module_path, framework_name in GO_FRAMEWORKS.items():
351
+ if module_path in line:
352
+ frameworks.append(framework_name)
353
+
354
+ if not frameworks:
355
+ # Go project with no detected framework — could be net/http
356
+ frameworks = ["net_http"]
357
+
358
+ return {
359
+ "root": _get_root(filepath),
360
+ "language": "go",
361
+ "frameworks": sorted(set(frameworks)),
362
+ "dep_file": filepath,
363
+ }
364
+
365
+
366
+ def _parse_cargo_toml(content: str, filepath: str) -> Optional[dict]:
367
+ """Parse Cargo.toml for Rust framework dependencies."""
368
+ frameworks = []
369
+ for line in content.split("\n"):
370
+ line = line.strip().lower()
371
+ for crate_name, framework_name in RUST_FRAMEWORKS.items():
372
+ if crate_name in line:
373
+ frameworks.append(framework_name)
374
+
375
+ if not frameworks:
376
+ return None
377
+
378
+ return {
379
+ "root": _get_root(filepath),
380
+ "language": "rust",
381
+ "frameworks": sorted(set(frameworks)),
382
+ "dep_file": filepath,
383
+ }
384
+
385
+
386
+ def _detect_c_project(fs: FileSystemTools) -> Optional[dict]:
387
+ """Detect C/C++ projects by looking for Makefile/CMakeLists."""
388
+ for indicator in ["Makefile", "CMakeLists.txt", "configure", "meson.build"]:
389
+ result = fs.glob(indicator, ".")
390
+ if result.get("matches"):
391
+ # Verify there are actually C files
392
+ c_files = fs.glob("**/*.c", ".")
393
+ h_files = fs.glob("**/*.h", ".")
394
+ cpp_files = fs.glob("**/*.cpp", ".")
395
+ total = (len(c_files.get("matches", [])) +
396
+ len(h_files.get("matches", [])) +
397
+ len(cpp_files.get("matches", [])))
398
+ if total > 5:
399
+ lang = "cpp" if len(cpp_files.get("matches", [])) > len(c_files.get("matches", [])) else "c"
400
+ return {
401
+ "root": ".",
402
+ "language": lang,
403
+ "frameworks": [lang],
404
+ "dep_file": indicator,
405
+ }
406
+ return None
407
+
408
+
409
+ def _detect_java_project(fs: FileSystemTools) -> Optional[dict]:
410
+ """Detect Java projects via pom.xml or build.gradle."""
411
+ for indicator in ["pom.xml", "build.gradle", "build.gradle.kts"]:
412
+ result = fs.glob(indicator, ".")
413
+ if result.get("matches"):
414
+ filepath = result["matches"][0]
415
+ read_result = fs.read_file(filepath)
416
+ if "error" in read_result:
417
+ continue
418
+ content = read_result.get("content", "")
419
+ frameworks = []
420
+ for pattern, framework in JAVA_FRAMEWORKS.items():
421
+ if pattern.lower() in content.lower():
422
+ frameworks.append(framework)
423
+ if not frameworks:
424
+ frameworks = ["java_raw"]
425
+ return {
426
+ "root": _get_root(filepath),
427
+ "language": "java",
428
+ "frameworks": frameworks,
429
+ "dep_file": filepath,
430
+ }
431
+ return None
432
+
433
+
434
+ def _detect_dotnet_project(fs: FileSystemTools) -> Optional[dict]:
435
+ """Detect .NET projects via .csproj files."""
436
+ result = fs.glob("**/*.csproj", ".")
437
+ matches = result.get("matches", [])
438
+ if not matches:
439
+ return None
440
+
441
+ filepath = matches[0]
442
+ read_result = fs.read_file(filepath)
443
+ if "error" in read_result:
444
+ return None
445
+
446
+ content = read_result.get("content", "")
447
+ frameworks = []
448
+ for pattern, framework in DOTNET_FRAMEWORKS.items():
449
+ if pattern.lower() in content.lower():
450
+ frameworks.append(framework)
451
+ if not frameworks:
452
+ frameworks = ["dotnet_raw"]
453
+
454
+ return {
455
+ "root": _get_root(filepath),
456
+ "language": "dotnet",
457
+ "frameworks": frameworks,
458
+ "dep_file": filepath,
459
+ }
460
+
461
+
462
+ def _detect_rails_project(fs: FileSystemTools) -> Optional[dict]:
463
+ """Detect Rails projects via config/application.rb or Gemfile.d/."""
464
+ for indicator in ["config/application.rb"]:
465
+ result = fs.glob(indicator, ".")
466
+ if result.get("matches"):
467
+ filepath = result["matches"][0]
468
+ read_result = fs.read_file(filepath)
469
+ content = read_result.get("content", "") if "error" not in read_result else ""
470
+ if "Rails" in content or "rails" in content.lower():
471
+ root = _get_root(filepath).rsplit("/config", 1)[0] if "/config" in filepath else "."
472
+ frameworks = ["rails"]
473
+ if "graphql" in content.lower():
474
+ frameworks.append("graphql")
475
+ return {
476
+ "root": root,
477
+ "language": "ruby",
478
+ "frameworks": frameworks,
479
+ "dep_file": filepath,
480
+ }
481
+
482
+ for gemfile_d in ["Gemfile.d/app.rb", "Gemfile.d"]:
483
+ result = fs.glob(gemfile_d, ".")
484
+ if result.get("matches"):
485
+ filepath = result["matches"][0]
486
+ if filepath.endswith(".rb"):
487
+ read_result = fs.read_file(filepath)
488
+ if "error" not in read_result:
489
+ content = read_result.get("content", "")
490
+ for line in content.split("\n"):
491
+ if "gem" in line and "rails" in line.lower() and "rubocop" not in line.lower():
492
+ root = _get_root(filepath).rsplit("/Gemfile.d", 1)[0] if "/Gemfile.d" in filepath else "."
493
+ return {
494
+ "root": root,
495
+ "language": "ruby",
496
+ "frameworks": ["rails"],
497
+ "dep_file": filepath,
498
+ }
499
+ return None
500
+
501
+
502
+ def _merge_classifications(classifications: list[dict]) -> list[dict]:
503
+ """Merge classifications with the same root directory."""
504
+ by_root: dict[str, dict] = {}
505
+ for c in classifications:
506
+ root = c["root"]
507
+ if root in by_root:
508
+ existing = by_root[root]
509
+ existing["frameworks"] = sorted(set(existing["frameworks"] + c["frameworks"]))
510
+ # Keep the more specific language
511
+ if existing["language"] == "javascript" and c["language"] != "javascript":
512
+ existing["language"] = c["language"]
513
+ else:
514
+ by_root[root] = dict(c)
515
+ return list(by_root.values())