commiter-cli 0.3.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 (96) hide show
  1. commiter/__init__.py +3 -0
  2. commiter/adapters/__init__.py +0 -0
  3. commiter/adapters/base.py +96 -0
  4. commiter/adapters/django_rest.py +247 -0
  5. commiter/adapters/express.py +204 -0
  6. commiter/adapters/fastapi.py +170 -0
  7. commiter/adapters/flask.py +169 -0
  8. commiter/adapters/nextjs.py +180 -0
  9. commiter/adapters/prisma.py +76 -0
  10. commiter/adapters/raw_sql.py +191 -0
  11. commiter/adapters/react.py +129 -0
  12. commiter/adapters/sqlalchemy.py +99 -0
  13. commiter/adapters/supabase.py +68 -0
  14. commiter/auth.py +130 -0
  15. commiter/cli.py +667 -0
  16. commiter/correlator.py +208 -0
  17. commiter/extractors/__init__.py +0 -0
  18. commiter/extractors/api_calls.py +91 -0
  19. commiter/extractors/api_endpoints.py +354 -0
  20. commiter/extractors/backend_files.py +33 -0
  21. commiter/extractors/base.py +40 -0
  22. commiter/extractors/db_operations.py +69 -0
  23. commiter/extractors/dependencies.py +219 -0
  24. commiter/generic_resolver.py +204 -0
  25. commiter/handler_index.py +97 -0
  26. commiter/lib.py +63 -0
  27. commiter/middleware_index.py +350 -0
  28. commiter/models.py +117 -0
  29. commiter/parser.py +1283 -0
  30. commiter/prefix_index.py +211 -0
  31. commiter/report/__init__.py +0 -0
  32. commiter/report/ai.py +120 -0
  33. commiter/report/api_guide.py +217 -0
  34. commiter/report/architecture.py +930 -0
  35. commiter/report/console.py +254 -0
  36. commiter/report/json_output.py +122 -0
  37. commiter/report/markdown.py +163 -0
  38. commiter/scanner.py +383 -0
  39. commiter/type_index.py +304 -0
  40. commiter/uploader.py +46 -0
  41. commiter/utils/__init__.py +0 -0
  42. commiter/utils/env_reader.py +78 -0
  43. commiter/utils/file_classifier.py +187 -0
  44. commiter/utils/path_helpers.py +73 -0
  45. commiter/utils/tsconfig_resolver.py +281 -0
  46. commiter/wrapper_index.py +288 -0
  47. commiter_cli-0.3.0.dist-info/METADATA +14 -0
  48. commiter_cli-0.3.0.dist-info/RECORD +96 -0
  49. commiter_cli-0.3.0.dist-info/WHEEL +5 -0
  50. commiter_cli-0.3.0.dist-info/entry_points.txt +2 -0
  51. commiter_cli-0.3.0.dist-info/top_level.txt +2 -0
  52. tests/__init__.py +0 -0
  53. tests/fixtures/arch_backend/app.py +22 -0
  54. tests/fixtures/arch_backend/middleware/__init__.py +0 -0
  55. tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
  56. tests/fixtures/arch_backend/routes/__init__.py +0 -0
  57. tests/fixtures/arch_backend/routes/analytics.py +20 -0
  58. tests/fixtures/arch_backend/routes/auth.py +29 -0
  59. tests/fixtures/arch_backend/routes/projects.py +60 -0
  60. tests/fixtures/arch_backend/routes/users.py +55 -0
  61. tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
  62. tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
  63. tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
  64. tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
  65. tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
  66. tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
  67. tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
  68. tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
  69. tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
  70. tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
  71. tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
  72. tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
  73. tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
  74. tests/fixtures/backend_b/app.py +17 -0
  75. tests/fixtures/fastapi_app/app.py +48 -0
  76. tests/fixtures/fastapi_crossfile/routes.py +18 -0
  77. tests/fixtures/fastapi_crossfile/schemas.py +21 -0
  78. tests/fixtures/flask_app/app.py +33 -0
  79. tests/fixtures/flask_blueprint/app.py +7 -0
  80. tests/fixtures/flask_blueprint/routes/items.py +13 -0
  81. tests/fixtures/flask_blueprint/routes/users.py +20 -0
  82. tests/fixtures/middleware_test_flask/routes/public.py +8 -0
  83. tests/fixtures/middleware_test_flask/routes/users.py +26 -0
  84. tests/fixtures/python_deep_imports/app/__init__.py +0 -0
  85. tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
  86. tests/fixtures/python_deep_imports/app/api/health.py +11 -0
  87. tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
  88. tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
  89. tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
  90. tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
  91. tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
  92. tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
  93. tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
  94. tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
  95. tests/fixtures/raw_sql_test/app.py +54 -0
  96. tests/test_architecture.py +757 -0
@@ -0,0 +1,211 @@
1
+ """Cross-file router/blueprint prefix index.
2
+
3
+ Detects prefix declarations across files and resolves them to prepend
4
+ to route patterns found on routers/blueprints.
5
+
6
+ Patterns detected:
7
+ Flask: Blueprint("name", __name__, url_prefix="/api/users")
8
+ app.register_blueprint(bp, url_prefix="/api/users")
9
+ FastAPI: app.include_router(users_router, prefix="/api/users")
10
+ Express: app.use("/api/users", usersRouter)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from commiter.parser import node_text, find_nodes_by_type
22
+
23
+ if TYPE_CHECKING:
24
+ from tree_sitter import Tree, Node
25
+
26
+
27
+ @dataclass
28
+ class PrefixBinding:
29
+ """A binding between a router/blueprint variable name and its URL prefix."""
30
+ variable_name: str
31
+ prefix: str
32
+ file_path: str
33
+ line: int
34
+
35
+
36
+ class PrefixIndex:
37
+ """Index of router/blueprint prefix bindings across all files.
38
+
39
+ Usage:
40
+ idx = PrefixIndex()
41
+ # Pass 1: index all files
42
+ for path in files:
43
+ idx.index_file(path, tree, source, language)
44
+ # Pass 2: look up prefix for a route
45
+ prefix = idx.get_prefix_for_file(file_path, router_var_name)
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ # variable_name -> list of PrefixBinding
50
+ self._bindings: dict[str, list[PrefixBinding]] = {}
51
+ # file_path -> list of PrefixBinding (all bindings found in a file)
52
+ self._by_file: dict[str, list[PrefixBinding]] = {}
53
+ # For cross-file: imported router name -> (source_file, original_name)
54
+ self._router_imports: dict[str, dict[str, str]] = {} # file -> {local_name: source_file}
55
+
56
+ def index_file(self, file_path: str, tree: "Tree", source: bytes, language: str) -> None:
57
+ """Scan a file for prefix declarations."""
58
+ abs_path = os.path.abspath(file_path)
59
+ text = source.decode("utf-8", errors="replace")
60
+
61
+ if language == "python":
62
+ self._index_python_prefixes(abs_path, tree, source, text)
63
+ elif language in ("javascript", "typescript", "tsx"):
64
+ self._index_js_prefixes(abs_path, tree, source, text)
65
+
66
+ def get_prefix_for_file(self, file_path: str, router_var: str | None = None) -> str:
67
+ """Get the accumulated URL prefix for routes in a file.
68
+
69
+ Checks if any other file mounts a router from this file with a prefix.
70
+
71
+ Args:
72
+ file_path: The file containing the routes.
73
+ router_var: Optional variable name of the router (e.g. "bp", "router").
74
+
75
+ Returns:
76
+ The prefix string (e.g. "/api/v1") or empty string if none found.
77
+ """
78
+ abs_path = os.path.abspath(file_path)
79
+ file_name = Path(abs_path).stem
80
+
81
+ # Strategy 1: Check if a specific router variable has a known prefix
82
+ if router_var and router_var in self._bindings:
83
+ for binding in self._bindings[router_var]:
84
+ return binding.prefix
85
+
86
+ # Strategy 2: Check if any binding references this file (cross-file)
87
+ # Look through all bindings for ones that import from this file
88
+ for importing_file, imports in self._router_imports.items():
89
+ for local_name, source_file in imports.items():
90
+ # Check if source_file matches our file
91
+ if self._files_match(source_file, abs_path, importing_file):
92
+ if local_name in self._bindings:
93
+ for binding in self._bindings[local_name]:
94
+ if binding.file_path == importing_file:
95
+ return binding.prefix
96
+
97
+ # Strategy 3: Check same-file Blueprint/Router definitions with inline prefix
98
+ for binding in self._by_file.get(abs_path, []):
99
+ return binding.prefix
100
+
101
+ return ""
102
+
103
+ def _index_python_prefixes(self, abs_path: str, tree: "Tree", source: bytes, text: str) -> None:
104
+ """Index Flask Blueprint and FastAPI include_router prefixes."""
105
+
106
+ # Pattern 1: Blueprint("name", __name__, url_prefix="/api/users")
107
+ for match in re.finditer(
108
+ r'(\w+)\s*=\s*Blueprint\s*\([^)]*url_prefix\s*=\s*["\']([^"\']+)["\']',
109
+ text,
110
+ ):
111
+ var_name = match.group(1)
112
+ prefix = match.group(2)
113
+ line = text[:match.start()].count("\n") + 1
114
+ binding = PrefixBinding(variable_name=var_name, prefix=prefix, file_path=abs_path, line=line)
115
+ self._bindings.setdefault(var_name, []).append(binding)
116
+ self._by_file.setdefault(abs_path, []).append(binding)
117
+
118
+ # Pattern 2: app.register_blueprint(bp, url_prefix="/api/users")
119
+ for match in re.finditer(
120
+ r'\.register_blueprint\s*\(\s*(\w+)(?:\s*,\s*url_prefix\s*=\s*["\']([^"\']+)["\'])?',
121
+ text,
122
+ ):
123
+ var_name = match.group(1)
124
+ prefix = match.group(2)
125
+ if prefix:
126
+ line = text[:match.start()].count("\n") + 1
127
+ binding = PrefixBinding(variable_name=var_name, prefix=prefix, file_path=abs_path, line=line)
128
+ self._bindings.setdefault(var_name, []).append(binding)
129
+ self._by_file.setdefault(abs_path, []).append(binding)
130
+
131
+ # Pattern 3: app.include_router(users_router, prefix="/api/users")
132
+ for match in re.finditer(
133
+ r'\.include_router\s*\(\s*(\w+)(?:\s*,\s*prefix\s*=\s*["\']([^"\']+)["\'])?',
134
+ text,
135
+ ):
136
+ var_name = match.group(1)
137
+ prefix = match.group(2)
138
+ if prefix:
139
+ line = text[:match.start()].count("\n") + 1
140
+ binding = PrefixBinding(variable_name=var_name, prefix=prefix, file_path=abs_path, line=line)
141
+ self._bindings.setdefault(var_name, []).append(binding)
142
+ self._by_file.setdefault(abs_path, []).append(binding)
143
+
144
+ # Track imports: from .routes import router, from routes.users import users_router
145
+ for match in re.finditer(r'from\s+(\S+)\s+import\s+(.+)', text):
146
+ module_path = match.group(1)
147
+ names = [n.strip().split(" as ")[-1].strip() for n in match.group(2).split(",")]
148
+ for name in names:
149
+ if name:
150
+ self._router_imports.setdefault(abs_path, {})[name] = module_path
151
+
152
+ def _index_js_prefixes(self, abs_path: str, tree: "Tree", source: bytes, text: str) -> None:
153
+ """Index Express app.use("/prefix", router) patterns."""
154
+
155
+ # Pattern: app.use("/api/users", usersRouter)
156
+ for match in re.finditer(
157
+ r'(\w+)\.use\s*\(\s*["\']([^"\']+)["\']\s*,\s*(\w+)\s*\)',
158
+ text,
159
+ ):
160
+ mount_obj = match.group(1) # app, server, etc.
161
+ prefix = match.group(2)
162
+ router_var = match.group(3)
163
+ line = text[:match.start()].count("\n") + 1
164
+ binding = PrefixBinding(variable_name=router_var, prefix=prefix, file_path=abs_path, line=line)
165
+ self._bindings.setdefault(router_var, []).append(binding)
166
+ self._by_file.setdefault(abs_path, []).append(binding)
167
+
168
+ # Track imports: import usersRouter from "./routes/users"
169
+ # const usersRouter = require("./routes/users")
170
+ for match in re.finditer(r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']', text):
171
+ name = match.group(1)
172
+ source_path = match.group(2)
173
+ self._router_imports.setdefault(abs_path, {})[name] = source_path
174
+
175
+ for match in re.finditer(r'(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*["\']([^"\']+)["\']\s*\)', text):
176
+ name = match.group(1)
177
+ source_path = match.group(2)
178
+ self._router_imports.setdefault(abs_path, {})[name] = source_path
179
+
180
+ def _files_match(self, import_path: str, target_abs_path: str, importer_abs_path: str) -> bool:
181
+ """Check if an import path could resolve to the target file."""
182
+ target_stem = Path(target_abs_path).stem
183
+ target_name = Path(target_abs_path).name
184
+
185
+ # Direct name match
186
+ if import_path.endswith(target_stem) or import_path.endswith(target_name):
187
+ return True
188
+
189
+ # Relative path resolution
190
+ if import_path.startswith("."):
191
+ importer_dir = str(Path(importer_abs_path).parent)
192
+ resolved = os.path.normpath(os.path.join(importer_dir, import_path))
193
+ # Try with common extensions
194
+ for ext in ("", ".py", ".js", ".ts", ".tsx", "/index.ts", "/index.js"):
195
+ if os.path.normpath(resolved + ext) == os.path.normpath(target_abs_path):
196
+ return True
197
+ # Also compare stems
198
+ if Path(resolved + ext).stem == target_stem:
199
+ return True
200
+
201
+ # Python dotted imports: .routes -> routes.py in same dir
202
+ if import_path.startswith("."):
203
+ module = import_path.lstrip(".").replace(".", "/")
204
+ if module == target_stem or module.endswith("/" + target_stem):
205
+ return True
206
+
207
+ return False
208
+
209
+ @property
210
+ def binding_count(self) -> int:
211
+ return sum(len(b) for b in self._bindings.values())
File without changes
commiter/report/ai.py ADDED
@@ -0,0 +1,120 @@
1
+ """Compact plain-text output optimized for LLM context windows.
2
+
3
+ Minimal tokens, no decorative formatting, all essential data with file:line references.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from commiter.models import RepoDocumentation
11
+
12
+
13
+ def generate_ai(docs: list[RepoDocumentation],
14
+ sections: dict[str, str | None] | None = None) -> str:
15
+ """Generate compact AI-friendly plain text output."""
16
+ lines: list[str] = []
17
+
18
+ for doc in docs:
19
+ lines.extend(_repo_section(doc, sections))
20
+
21
+ return "\n".join(lines)
22
+
23
+
24
+ def _show(sections: dict | None, key: str) -> bool:
25
+ if sections is None:
26
+ return True
27
+ return sections.get(key) is not None
28
+
29
+
30
+ def _rel(file_path: str, repo_name: str) -> str:
31
+ """Make file path relative to repo."""
32
+ if repo_name + "/" in file_path:
33
+ return file_path.split(repo_name + "/", 1)[-1]
34
+ return Path(file_path).name
35
+
36
+
37
+ def _repo_section(doc: RepoDocumentation,
38
+ sections: dict[str, str | None] | None = None) -> list[str]:
39
+ lines: list[str] = []
40
+
41
+ # Header
42
+ lines.append(f"REPO: {doc.repo_name}")
43
+ if doc.frameworks:
44
+ lines.append(f"FRAMEWORKS: {', '.join(doc.frameworks)}")
45
+ if doc.languages:
46
+ lines.append(f"LANGUAGES: {', '.join(doc.languages)}")
47
+ lines.append("")
48
+
49
+ # Endpoints
50
+ if doc.endpoints and _show(sections, "endpoints"):
51
+ lines.append("ENDPOINTS:")
52
+ for ep in doc.endpoints:
53
+ rel = _rel(ep.file_path, doc.repo_name)
54
+ lines.append(f" {ep.http_method} {ep.route_pattern} → {ep.handler_name} [{rel}:{ep.line}]")
55
+
56
+ if ep.parameters:
57
+ params = ", ".join(f"{p.name} ({p.source.value})" for p in ep.parameters)
58
+ lines.append(f" params: {params}")
59
+
60
+ if ep.request_body_type and ep.request_body_fields:
61
+ fields = ", ".join(ep.request_body_fields)
62
+ lines.append(f" body: {ep.request_body_type} {{{fields}}}")
63
+ elif ep.request_body_type:
64
+ lines.append(f" body: {ep.request_body_type}")
65
+ elif ep.request_body_fields:
66
+ fields = ", ".join(ep.request_body_fields)
67
+ lines.append(f" body: {{{fields}}}")
68
+
69
+ if ep.response_fields:
70
+ lines.append(f" response: {{{', '.join(ep.response_fields)}}}")
71
+
72
+ if ep.auth_decorators:
73
+ lines.append(f" auth: {', '.join(ep.auth_decorators)}")
74
+
75
+ if ep.middleware:
76
+ lines.append(f" middleware: {', '.join(ep.middleware)}")
77
+
78
+ if ep.db_tables:
79
+ lines.append(f" db: {', '.join(ep.db_tables)}")
80
+
81
+ lines.append("")
82
+
83
+ # Frontend API Calls
84
+ if doc.api_calls and _show(sections, "calls"):
85
+ lines.append("API CALLS:")
86
+ for call in doc.api_calls:
87
+ rel = _rel(call.file_path, doc.repo_name)
88
+ traced = f" via {call.traced_from}" if call.traced_from else ""
89
+ lines.append(f" {call.http_method} {call.url_pattern} ← {call.component_or_page} [{rel}:{call.line}]{traced}")
90
+ lines.append("")
91
+
92
+ # DB Operations
93
+ if doc.db_operations and _show(sections, "db"):
94
+ lines.append("DB OPERATIONS:")
95
+ for op in doc.db_operations:
96
+ rel = _rel(op.file_path, doc.repo_name)
97
+ filters = f" WHERE {', '.join(op.filters)}" if op.filters else ""
98
+ lines.append(f" {op.operation_type.upper()} {op.table_name}{filters} ({op.orm_library}) [{rel}:{op.line}]")
99
+ lines.append("")
100
+
101
+ # Dependencies
102
+ if doc.dependencies and _show(sections, "deps"):
103
+ runtime = [d for d in doc.dependencies if not d.dev_only]
104
+ dev = [d for d in doc.dependencies if d.dev_only]
105
+ if runtime:
106
+ deps_str = ", ".join(f"{d.name} {d.version_constraint}" for d in sorted(runtime, key=lambda x: x.name))
107
+ lines.append(f"DEPS: {deps_str}")
108
+ if dev:
109
+ dev_str = ", ".join(f"{d.name} {d.version_constraint}" for d in sorted(dev, key=lambda x: x.name))
110
+ lines.append(f"DEV DEPS: {dev_str}")
111
+ lines.append("")
112
+
113
+ # Cross-repo relationships
114
+ shared_db = [r for r in doc.service_relationships if r.connection_type == "shared_db"]
115
+ if shared_db:
116
+ tables = set(r.target_endpoint.replace("table:", "") for r in shared_db)
117
+ lines.append(f"SHARED DB TABLES: {', '.join(sorted(tables))}")
118
+ lines.append("")
119
+
120
+ return lines
@@ -0,0 +1,217 @@
1
+ """Generate curl command guides for API endpoints.
2
+
3
+ Produces ready-to-paste curl commands with all known data pre-filled:
4
+ method, route, body template, auth headers, path params, query params.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from commiter.models import RepoDocumentation, APIEndpoint, ParamSource
13
+
14
+
15
+ # Common auth-related middleware/decorator names
16
+ _AUTH_INDICATORS = {
17
+ "auth", "authenticate", "authMiddleware", "requireAuth", "isAuthenticated",
18
+ "verifyToken", "requireLogin", "protect", "jwt_required", "login_required",
19
+ "require_auth", "token_required", "Depends(get_current_user",
20
+ }
21
+
22
+ # Env var names that might contain the API base URL
23
+ _BASE_URL_ENV_KEYS = [
24
+ "API_URL", "BASE_URL", "SERVER_URL",
25
+ "NEXT_PUBLIC_API_URL", "VITE_API_URL", "REACT_APP_API_URL",
26
+ "NUXT_PUBLIC_API_BASE",
27
+ ]
28
+
29
+ # Env var names for port
30
+ _PORT_ENV_KEYS = ["PORT", "SERVER_PORT", "APP_PORT"]
31
+
32
+
33
+ def generate_api_guide(
34
+ docs: list[RepoDocumentation],
35
+ env_vars: dict[str, str] | None = None,
36
+ ) -> str:
37
+ """Generate curl command guides for all endpoints across repos."""
38
+ lines: list[str] = []
39
+ base_url = _detect_base_url(env_vars or {})
40
+
41
+ for doc in docs:
42
+ if not doc.endpoints:
43
+ continue
44
+
45
+ lines.append(f"API Guide: {doc.repo_name}")
46
+ lines.append(f"Base URL: {base_url}")
47
+ lines.append("")
48
+
49
+ for ep in doc.endpoints:
50
+ lines.extend(_endpoint_guide(ep, doc.repo_name, base_url))
51
+ lines.append("")
52
+
53
+ return "\n".join(lines)
54
+
55
+
56
+ def _detect_base_url(env_vars: dict[str, str]) -> str:
57
+ """Detect the API base URL from environment variables."""
58
+ # Check explicit URL vars
59
+ for key in _BASE_URL_ENV_KEYS:
60
+ if key in env_vars:
61
+ return env_vars[key].rstrip("/")
62
+
63
+ # Check for PORT
64
+ for key in _PORT_ENV_KEYS:
65
+ if key in env_vars:
66
+ return f"http://localhost:{env_vars[key]}"
67
+
68
+ return "http://localhost:3000"
69
+
70
+
71
+ def _endpoint_guide(ep: APIEndpoint, repo_name: str, base_url: str) -> list[str]:
72
+ """Generate a guide + curl command for a single endpoint."""
73
+ lines: list[str] = []
74
+ rel = _rel_path(ep.file_path, repo_name)
75
+
76
+ # Header
77
+ lines.append(f"{ep.http_method} {ep.route_pattern} → {ep.handler_name} [{rel}:{ep.line}]")
78
+
79
+ # Auth info
80
+ has_auth = _detect_auth(ep)
81
+ if has_auth:
82
+ lines.append(f" Auth: {', '.join(has_auth)}")
83
+
84
+ # Path parameters
85
+ path_params = [p for p in ep.parameters if p.source == ParamSource.PATH]
86
+ if path_params:
87
+ params_str = ", ".join(f"{p.name}: {p.type_hint or 'string'}" for p in path_params)
88
+ lines.append(f" Path params: {params_str}")
89
+
90
+ # Body fields
91
+ if ep.request_body_fields:
92
+ body_type = ep.request_body_type or "object"
93
+ lines.append(f" Body: {body_type}")
94
+ for field_str in ep.request_body_fields:
95
+ # Parse "name: type" or "name: type?" format
96
+ optional = "?" in field_str
97
+ clean = field_str.rstrip("?").strip()
98
+ req_label = "(optional)" if optional else "(required)"
99
+ lines.append(f" - {clean} {req_label}")
100
+
101
+ # Curl command
102
+ lines.append("")
103
+ lines.extend(_build_curl(ep, base_url, has_auth))
104
+
105
+ return lines
106
+
107
+
108
+ def _build_curl(ep: APIEndpoint, base_url: str, auth: list[str]) -> list[str]:
109
+ """Build the curl command for an endpoint."""
110
+ # Build URL with path param placeholders
111
+ url = _build_url(ep, base_url)
112
+
113
+ parts: list[str] = [f" curl -X {ep.http_method} '{url}'"]
114
+
115
+ # Content-Type header for methods with body
116
+ if ep.http_method in ("POST", "PUT", "PATCH"):
117
+ parts.append(' -H "Content-Type: application/json"')
118
+
119
+ # Auth header if needed
120
+ if auth:
121
+ parts.append(' -H "Authorization: Bearer <YOUR_TOKEN>"')
122
+
123
+ # Request body
124
+ if ep.request_body_fields and ep.http_method in ("POST", "PUT", "PATCH"):
125
+ body = _build_body_template(ep)
126
+ body_json = json.dumps(body, indent=6)
127
+ # Indent the JSON to align with the -d flag
128
+ body_lines = body_json.split("\n")
129
+ parts.append(f" -d '{body_lines[0]}")
130
+ for bl in body_lines[1:]:
131
+ parts[-1] += f"\n {bl}"
132
+ parts[-1] += "'"
133
+
134
+ # Join with line continuation
135
+ return [" \\\n".join(parts)]
136
+
137
+
138
+ def _build_url(ep: APIEndpoint, base_url: str) -> str:
139
+ """Build the full URL with path param placeholders."""
140
+ route = ep.route_pattern
141
+
142
+ # Replace framework-specific param syntax with readable placeholders
143
+ # Express :id → <id>
144
+ import re
145
+ route = re.sub(r":(\w+)", r"<\1>", route)
146
+ # Flask <param> already in the right format
147
+ # FastAPI {param} → <param>
148
+ route = re.sub(r"\{(\w+)\}", r"<\1>", route)
149
+
150
+ # Add query params if any
151
+ query_params = [p for p in ep.parameters if p.source == ParamSource.QUERY]
152
+ if query_params:
153
+ query_str = "&".join(f"{p.name}=<{p.type_hint or 'value'}>" for p in query_params)
154
+ route += f"?{query_str}"
155
+
156
+ return f"{base_url}{route}"
157
+
158
+
159
+ def _build_body_template(ep: APIEndpoint) -> dict:
160
+ """Build a JSON body template from request body fields."""
161
+ body = {}
162
+ for field_str in ep.request_body_fields:
163
+ # Parse "name: type" format
164
+ if ":" in field_str:
165
+ name, type_info = field_str.split(":", 1)
166
+ name = name.strip()
167
+ type_info = type_info.strip().rstrip("?").strip()
168
+ body[name] = _type_placeholder(type_info)
169
+ else:
170
+ body[field_str.strip()] = "<value>"
171
+ return body
172
+
173
+
174
+ def _type_placeholder(type_str: str) -> str | int | bool | list:
175
+ """Generate a placeholder value based on the type string."""
176
+ t = type_str.lower().strip()
177
+
178
+ if t == "string" or t == "str":
179
+ return "<string>"
180
+ elif t in ("number", "int", "float", "integer"):
181
+ return 0
182
+ elif t in ("boolean", "bool"):
183
+ return False
184
+ elif t.endswith("[]") or t.startswith("array"):
185
+ return []
186
+ elif "|" in type_str:
187
+ # Enum-like: "admin" | "editor" | "viewer"
188
+ options = [o.strip().strip("'\"") for o in type_str.split("|")]
189
+ return f"<{'|'.join(options)}>"
190
+ elif type_str[0].isupper():
191
+ # CamelCase type name — likely an enum or custom type
192
+ return f"<{type_str}>"
193
+ else:
194
+ return f"<{type_str}>"
195
+
196
+
197
+ def _detect_auth(ep: APIEndpoint) -> list[str]:
198
+ """Detect auth requirements from decorators and middleware."""
199
+ auth_items = []
200
+
201
+ for dec in ep.auth_decorators:
202
+ auth_items.append(dec)
203
+
204
+ for mw in ep.middleware:
205
+ # Check if this middleware name suggests authentication
206
+ mw_clean = mw.rstrip("()")
207
+ if any(indicator.lower() in mw_clean.lower() for indicator in _AUTH_INDICATORS):
208
+ if mw not in auth_items:
209
+ auth_items.append(mw)
210
+
211
+ return auth_items
212
+
213
+
214
+ def _rel_path(file_path: str, repo_name: str) -> str:
215
+ if repo_name + "/" in file_path:
216
+ return file_path.split(repo_name + "/", 1)[-1]
217
+ return Path(file_path).name