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.
- commiter/__init__.py +3 -0
- commiter/adapters/__init__.py +0 -0
- commiter/adapters/base.py +96 -0
- commiter/adapters/django_rest.py +247 -0
- commiter/adapters/express.py +204 -0
- commiter/adapters/fastapi.py +170 -0
- commiter/adapters/flask.py +169 -0
- commiter/adapters/nextjs.py +180 -0
- commiter/adapters/prisma.py +76 -0
- commiter/adapters/raw_sql.py +191 -0
- commiter/adapters/react.py +129 -0
- commiter/adapters/sqlalchemy.py +99 -0
- commiter/adapters/supabase.py +68 -0
- commiter/auth.py +130 -0
- commiter/cli.py +667 -0
- commiter/correlator.py +208 -0
- commiter/extractors/__init__.py +0 -0
- commiter/extractors/api_calls.py +91 -0
- commiter/extractors/api_endpoints.py +354 -0
- commiter/extractors/backend_files.py +33 -0
- commiter/extractors/base.py +40 -0
- commiter/extractors/db_operations.py +69 -0
- commiter/extractors/dependencies.py +219 -0
- commiter/generic_resolver.py +204 -0
- commiter/handler_index.py +97 -0
- commiter/lib.py +63 -0
- commiter/middleware_index.py +350 -0
- commiter/models.py +117 -0
- commiter/parser.py +1283 -0
- commiter/prefix_index.py +211 -0
- commiter/report/__init__.py +0 -0
- commiter/report/ai.py +120 -0
- commiter/report/api_guide.py +217 -0
- commiter/report/architecture.py +930 -0
- commiter/report/console.py +254 -0
- commiter/report/json_output.py +122 -0
- commiter/report/markdown.py +163 -0
- commiter/scanner.py +383 -0
- commiter/type_index.py +304 -0
- commiter/uploader.py +46 -0
- commiter/utils/__init__.py +0 -0
- commiter/utils/env_reader.py +78 -0
- commiter/utils/file_classifier.py +187 -0
- commiter/utils/path_helpers.py +73 -0
- commiter/utils/tsconfig_resolver.py +281 -0
- commiter/wrapper_index.py +288 -0
- commiter_cli-0.3.0.dist-info/METADATA +14 -0
- commiter_cli-0.3.0.dist-info/RECORD +96 -0
- commiter_cli-0.3.0.dist-info/WHEEL +5 -0
- commiter_cli-0.3.0.dist-info/entry_points.txt +2 -0
- commiter_cli-0.3.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/fixtures/arch_backend/app.py +22 -0
- tests/fixtures/arch_backend/middleware/__init__.py +0 -0
- tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
- tests/fixtures/arch_backend/routes/__init__.py +0 -0
- tests/fixtures/arch_backend/routes/analytics.py +20 -0
- tests/fixtures/arch_backend/routes/auth.py +29 -0
- tests/fixtures/arch_backend/routes/projects.py +60 -0
- tests/fixtures/arch_backend/routes/users.py +55 -0
- tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
- tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
- tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
- tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
- tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
- tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
- tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
- tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
- tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
- tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
- tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
- tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
- tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
- tests/fixtures/backend_b/app.py +17 -0
- tests/fixtures/fastapi_app/app.py +48 -0
- tests/fixtures/fastapi_crossfile/routes.py +18 -0
- tests/fixtures/fastapi_crossfile/schemas.py +21 -0
- tests/fixtures/flask_app/app.py +33 -0
- tests/fixtures/flask_blueprint/app.py +7 -0
- tests/fixtures/flask_blueprint/routes/items.py +13 -0
- tests/fixtures/flask_blueprint/routes/users.py +20 -0
- tests/fixtures/middleware_test_flask/routes/public.py +8 -0
- tests/fixtures/middleware_test_flask/routes/users.py +26 -0
- tests/fixtures/python_deep_imports/app/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/api/health.py +11 -0
- tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
- tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
- tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
- tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
- tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
- tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
- tests/fixtures/raw_sql_test/app.py +54 -0
- tests/test_architecture.py +757 -0
commiter/prefix_index.py
ADDED
|
@@ -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
|