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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Base class for all documentation extractors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from tree_sitter import Tree
|
|
10
|
+
from commiter.models import FileClassification
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseExtractor(ABC):
|
|
14
|
+
"""Base class that all extractors inherit from.
|
|
15
|
+
|
|
16
|
+
Each extractor is responsible for finding a specific type of documentation
|
|
17
|
+
artifact (endpoints, dependencies, DB operations, etc.) from parsed files.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str = "base"
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def can_handle(self, file_path: str, language: str | None) -> bool:
|
|
24
|
+
"""Whether this extractor should run on the given file."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def extract(self, file_path: str, repo_name: str, tree: "Tree | None" = None, language: str | None = None) -> list[Any]:
|
|
29
|
+
"""Extract documentation artifacts from a file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
file_path: Absolute path to the file.
|
|
33
|
+
repo_name: Name of the repository this file belongs to.
|
|
34
|
+
tree: Pre-parsed Tree-sitter tree (None for non-AST extractors).
|
|
35
|
+
language: Detected language of the file.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A list of model instances (APIEndpoint, Dependency, etc.).
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Extract database operations from code using ORM/DB adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from commiter.adapters.base import DBAdapter
|
|
8
|
+
from commiter.adapters.supabase import SupabaseAdapter
|
|
9
|
+
from commiter.adapters.prisma import PrismaAdapter
|
|
10
|
+
from commiter.adapters.sqlalchemy import SQLAlchemyAdapter
|
|
11
|
+
from commiter.adapters.raw_sql import RawSQLAdapter
|
|
12
|
+
from commiter.extractors.base import BaseExtractor
|
|
13
|
+
from commiter.models import DBOperation
|
|
14
|
+
from commiter.parser import get_source
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from tree_sitter import Tree
|
|
18
|
+
|
|
19
|
+
_DB_ADAPTERS: list[DBAdapter] = [
|
|
20
|
+
SupabaseAdapter(),
|
|
21
|
+
PrismaAdapter(),
|
|
22
|
+
SQLAlchemyAdapter(),
|
|
23
|
+
RawSQLAdapter(),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DBOperationExtractor(BaseExtractor):
|
|
28
|
+
name = "db_operations"
|
|
29
|
+
|
|
30
|
+
def can_handle(self, file_path: str, language: str | None) -> bool:
|
|
31
|
+
return language in ("python", "javascript", "typescript", "tsx")
|
|
32
|
+
|
|
33
|
+
def extract(self, file_path: str, repo_name: str, tree: Tree | None = None, language: str | None = None) -> list[DBOperation]:
|
|
34
|
+
if tree is None:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
source = get_source(file_path)
|
|
38
|
+
operations = []
|
|
39
|
+
|
|
40
|
+
for adapter in _DB_ADAPTERS:
|
|
41
|
+
# Check if the source contains hints of this ORM
|
|
42
|
+
if not self._source_has_orm_hints(source, adapter):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
matches = adapter.find_db_operations(tree, source)
|
|
46
|
+
for match in matches:
|
|
47
|
+
operations.append(DBOperation(
|
|
48
|
+
repo=repo_name,
|
|
49
|
+
file_path=file_path,
|
|
50
|
+
line=match.line,
|
|
51
|
+
operation_type=match.operation_type,
|
|
52
|
+
table_name=match.table_name,
|
|
53
|
+
orm_library=adapter.orm_name,
|
|
54
|
+
filters=match.filters,
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
return operations
|
|
58
|
+
|
|
59
|
+
def _source_has_orm_hints(self, source: bytes, adapter: DBAdapter) -> bool:
|
|
60
|
+
"""Quick check if the source file likely uses this ORM."""
|
|
61
|
+
hints = {
|
|
62
|
+
"supabase": (b"supabase", b".table(", b".from(", b".from_("),
|
|
63
|
+
"prisma": (b"prisma.", b"@prisma"),
|
|
64
|
+
"sqlalchemy": (b"session.", b"sqlalchemy", b".query(", b"Model.query"),
|
|
65
|
+
"raw_sql": (b"cursor.execute", b".execute(", b"pool.query", b"client.query",
|
|
66
|
+
b"knex.raw", b"db.query", b"connection.query", b".raw("),
|
|
67
|
+
}
|
|
68
|
+
orm_hints = hints.get(adapter.orm_name, ())
|
|
69
|
+
return any(hint in source for hint in orm_hints)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Extract dependencies from manifest files (package.json, requirements.txt, pyproject.toml, etc.)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from tree_sitter import Tree
|
|
12
|
+
|
|
13
|
+
from commiter.extractors.base import BaseExtractor
|
|
14
|
+
from commiter.models import Dependency
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 11):
|
|
17
|
+
import tomllib
|
|
18
|
+
else:
|
|
19
|
+
import tomli as tomllib
|
|
20
|
+
|
|
21
|
+
# Files this extractor handles
|
|
22
|
+
MANIFEST_FILES = {
|
|
23
|
+
"package.json",
|
|
24
|
+
"requirements.txt",
|
|
25
|
+
"pyproject.toml",
|
|
26
|
+
"setup.cfg",
|
|
27
|
+
"Pipfile",
|
|
28
|
+
"go.mod",
|
|
29
|
+
"Cargo.toml",
|
|
30
|
+
"Gemfile",
|
|
31
|
+
"composer.json",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DependencyExtractor(BaseExtractor):
|
|
36
|
+
name = "dependencies"
|
|
37
|
+
|
|
38
|
+
def can_handle(self, file_path: str, language: str | None) -> bool:
|
|
39
|
+
return Path(file_path).name in MANIFEST_FILES
|
|
40
|
+
|
|
41
|
+
def extract(self, file_path: str, repo_name: str, tree: Tree | None = None, language: str | None = None) -> list[Dependency]:
|
|
42
|
+
filename = Path(file_path).name
|
|
43
|
+
try:
|
|
44
|
+
content = Path(file_path).read_text(encoding="utf-8", errors="replace")
|
|
45
|
+
except OSError:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
dispatch = {
|
|
49
|
+
"package.json": self._parse_package_json,
|
|
50
|
+
"requirements.txt": self._parse_requirements_txt,
|
|
51
|
+
"pyproject.toml": self._parse_pyproject_toml,
|
|
52
|
+
"setup.cfg": self._parse_setup_cfg,
|
|
53
|
+
"Pipfile": self._parse_pipfile,
|
|
54
|
+
"go.mod": self._parse_go_mod,
|
|
55
|
+
"Cargo.toml": self._parse_cargo_toml,
|
|
56
|
+
"Gemfile": self._parse_gemfile,
|
|
57
|
+
"composer.json": self._parse_composer_json,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
parser = dispatch.get(filename)
|
|
61
|
+
if parser is None:
|
|
62
|
+
return []
|
|
63
|
+
return parser(content, file_path, repo_name)
|
|
64
|
+
|
|
65
|
+
def _parse_package_json(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
66
|
+
deps = []
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(content)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
for name, version in data.get("dependencies", {}).items():
|
|
73
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path, dev_only=False))
|
|
74
|
+
for name, version in data.get("devDependencies", {}).items():
|
|
75
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path, dev_only=True))
|
|
76
|
+
return deps
|
|
77
|
+
|
|
78
|
+
def _parse_requirements_txt(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
79
|
+
deps = []
|
|
80
|
+
for line in content.splitlines():
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
83
|
+
continue
|
|
84
|
+
# Handle name==version, name>=version, name~=version, bare name
|
|
85
|
+
match = re.match(r"^([a-zA-Z0-9_.-]+)\s*([><=!~]+.+)?", line)
|
|
86
|
+
if match:
|
|
87
|
+
name = match.group(1)
|
|
88
|
+
version = match.group(2) or "*"
|
|
89
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version.strip(), source_file=file_path))
|
|
90
|
+
return deps
|
|
91
|
+
|
|
92
|
+
def _parse_pyproject_toml(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
93
|
+
deps = []
|
|
94
|
+
try:
|
|
95
|
+
data = tomllib.loads(content)
|
|
96
|
+
except Exception:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
for dep_str in data.get("project", {}).get("dependencies", []):
|
|
100
|
+
name, version = self._split_pep508(dep_str)
|
|
101
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path))
|
|
102
|
+
|
|
103
|
+
for group_deps in data.get("project", {}).get("optional-dependencies", {}).values():
|
|
104
|
+
for dep_str in group_deps:
|
|
105
|
+
name, version = self._split_pep508(dep_str)
|
|
106
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path, dev_only=True))
|
|
107
|
+
return deps
|
|
108
|
+
|
|
109
|
+
def _parse_setup_cfg(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
110
|
+
deps = []
|
|
111
|
+
in_install_requires = False
|
|
112
|
+
for line in content.splitlines():
|
|
113
|
+
stripped = line.strip()
|
|
114
|
+
if stripped == "install_requires =":
|
|
115
|
+
in_install_requires = True
|
|
116
|
+
continue
|
|
117
|
+
if in_install_requires:
|
|
118
|
+
if not stripped or (not line[0].isspace() and "=" in stripped):
|
|
119
|
+
break
|
|
120
|
+
name, version = self._split_pep508(stripped)
|
|
121
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path))
|
|
122
|
+
return deps
|
|
123
|
+
|
|
124
|
+
def _parse_pipfile(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
125
|
+
deps = []
|
|
126
|
+
section = None
|
|
127
|
+
for line in content.splitlines():
|
|
128
|
+
stripped = line.strip()
|
|
129
|
+
if stripped.startswith("["):
|
|
130
|
+
section = stripped.strip("[]").lower()
|
|
131
|
+
continue
|
|
132
|
+
if section in ("packages", "dev-packages") and "=" in stripped:
|
|
133
|
+
name, _, version = stripped.partition("=")
|
|
134
|
+
name = name.strip().strip('"')
|
|
135
|
+
version = version.strip().strip('"')
|
|
136
|
+
deps.append(Dependency(
|
|
137
|
+
repo=repo_name, name=name, version_constraint=version,
|
|
138
|
+
source_file=file_path, dev_only=(section == "dev-packages"),
|
|
139
|
+
))
|
|
140
|
+
return deps
|
|
141
|
+
|
|
142
|
+
def _parse_go_mod(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
143
|
+
deps = []
|
|
144
|
+
in_require = False
|
|
145
|
+
for line in content.splitlines():
|
|
146
|
+
stripped = line.strip()
|
|
147
|
+
if stripped.startswith("require ("):
|
|
148
|
+
in_require = True
|
|
149
|
+
continue
|
|
150
|
+
if in_require:
|
|
151
|
+
if stripped == ")":
|
|
152
|
+
in_require = False
|
|
153
|
+
continue
|
|
154
|
+
parts = stripped.split()
|
|
155
|
+
if len(parts) >= 2:
|
|
156
|
+
deps.append(Dependency(repo=repo_name, name=parts[0], version_constraint=parts[1], source_file=file_path))
|
|
157
|
+
elif stripped.startswith("require "):
|
|
158
|
+
parts = stripped.split()
|
|
159
|
+
if len(parts) >= 3:
|
|
160
|
+
deps.append(Dependency(repo=repo_name, name=parts[1], version_constraint=parts[2], source_file=file_path))
|
|
161
|
+
return deps
|
|
162
|
+
|
|
163
|
+
def _parse_cargo_toml(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
164
|
+
deps = []
|
|
165
|
+
try:
|
|
166
|
+
data = tomllib.loads(content)
|
|
167
|
+
except Exception:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
for section_key in ("dependencies", "dev-dependencies"):
|
|
171
|
+
dev = section_key == "dev-dependencies"
|
|
172
|
+
for name, val in data.get(section_key, {}).items():
|
|
173
|
+
if isinstance(val, str):
|
|
174
|
+
version = val
|
|
175
|
+
elif isinstance(val, dict):
|
|
176
|
+
version = val.get("version", "*")
|
|
177
|
+
else:
|
|
178
|
+
version = "*"
|
|
179
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path, dev_only=dev))
|
|
180
|
+
return deps
|
|
181
|
+
|
|
182
|
+
def _parse_gemfile(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
183
|
+
deps = []
|
|
184
|
+
for line in content.splitlines():
|
|
185
|
+
stripped = line.strip()
|
|
186
|
+
match = re.match(r"""gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?""", stripped)
|
|
187
|
+
if match:
|
|
188
|
+
name = match.group(1)
|
|
189
|
+
version = match.group(2) or "*"
|
|
190
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path))
|
|
191
|
+
return deps
|
|
192
|
+
|
|
193
|
+
def _parse_composer_json(self, content: str, file_path: str, repo_name: str) -> list[Dependency]:
|
|
194
|
+
deps = []
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(content)
|
|
197
|
+
except json.JSONDecodeError:
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
for name, version in data.get("require", {}).items():
|
|
201
|
+
if name == "php":
|
|
202
|
+
continue
|
|
203
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path))
|
|
204
|
+
for name, version in data.get("require-dev", {}).items():
|
|
205
|
+
deps.append(Dependency(repo=repo_name, name=name, version_constraint=version, source_file=file_path, dev_only=True))
|
|
206
|
+
return deps
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _split_pep508(dep_str: str) -> tuple[str, str]:
|
|
210
|
+
"""Split a PEP 508 dependency string into (name, version_constraint)."""
|
|
211
|
+
match = re.match(r"^([a-zA-Z0-9_.-]+)\s*(.*)", dep_str.strip())
|
|
212
|
+
if match:
|
|
213
|
+
name = match.group(1)
|
|
214
|
+
rest = match.group(2).strip().rstrip(";").strip()
|
|
215
|
+
# Remove environment markers
|
|
216
|
+
if ";" in rest:
|
|
217
|
+
rest = rest[:rest.index(";")].strip()
|
|
218
|
+
return name, rest if rest else "*"
|
|
219
|
+
return dep_str.strip(), "*"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Resolve generic and utility types to concrete field lists.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
Pick<User, "id" | "name"> → User's fields filtered to id, name
|
|
5
|
+
Omit<User, "password"> → User's fields minus password
|
|
6
|
+
Partial<User> → User's fields all optional
|
|
7
|
+
Required<User> → User's fields all required
|
|
8
|
+
ApiResponse<User> → ApiResponse's fields with T substituted by User
|
|
9
|
+
User[] → Recognized as array of User
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from copy import copy
|
|
15
|
+
from typing import Callable, TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from commiter.parser import parse_generic_type
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from commiter.parser import TypeField
|
|
21
|
+
|
|
22
|
+
# Built-in utility types that we can resolve
|
|
23
|
+
UTILITY_TYPES = {"Pick", "Omit", "Partial", "Required", "Readonly"}
|
|
24
|
+
|
|
25
|
+
# Wrapper types that we unwrap to get the inner type
|
|
26
|
+
WRAPPER_TYPES = {"Array", "Promise", "Set"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_generic_type(
|
|
30
|
+
type_str: str,
|
|
31
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
32
|
+
generic_def_lookup: Callable[[str], "tuple[list[TypeField], list[str]] | None"] = None,
|
|
33
|
+
) -> "list[TypeField] | None":
|
|
34
|
+
"""Resolve a generic type string to concrete fields.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
type_str: The type string (e.g. "Pick<User, \\"id\\" | \\"name\\">").
|
|
38
|
+
type_lookup: Callback to look up a type name and get its fields.
|
|
39
|
+
generic_def_lookup: Callback to look up a generic type definition,
|
|
40
|
+
returns (fields, generic_params) or None.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Resolved list of TypeField, or None if not resolvable.
|
|
44
|
+
"""
|
|
45
|
+
parsed = parse_generic_type(type_str)
|
|
46
|
+
if not parsed:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
base, args = parsed
|
|
50
|
+
if not args:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
# Built-in utility types
|
|
54
|
+
if base in UTILITY_TYPES:
|
|
55
|
+
return _resolve_utility_type(base, args, type_lookup)
|
|
56
|
+
|
|
57
|
+
# Wrapper types — unwrap to inner type
|
|
58
|
+
if base in WRAPPER_TYPES:
|
|
59
|
+
inner_type = args[0]
|
|
60
|
+
return type_lookup(inner_type)
|
|
61
|
+
|
|
62
|
+
# Custom generic type: ApiResponse<User>
|
|
63
|
+
if generic_def_lookup:
|
|
64
|
+
return _resolve_custom_generic(base, args, type_lookup, generic_def_lookup)
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_utility_type(
|
|
70
|
+
utility: str,
|
|
71
|
+
args: list[str],
|
|
72
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
73
|
+
) -> "list[TypeField] | None":
|
|
74
|
+
"""Resolve a built-in utility type."""
|
|
75
|
+
|
|
76
|
+
if utility == "Pick" and len(args) >= 2:
|
|
77
|
+
return _resolve_pick(args[0], args[1], type_lookup)
|
|
78
|
+
|
|
79
|
+
elif utility == "Omit" and len(args) >= 2:
|
|
80
|
+
return _resolve_omit(args[0], args[1], type_lookup)
|
|
81
|
+
|
|
82
|
+
elif utility == "Partial" and len(args) >= 1:
|
|
83
|
+
return _resolve_partial(args[0], type_lookup)
|
|
84
|
+
|
|
85
|
+
elif utility == "Required" and len(args) >= 1:
|
|
86
|
+
return _resolve_required(args[0], type_lookup)
|
|
87
|
+
|
|
88
|
+
elif utility == "Readonly" and len(args) >= 1:
|
|
89
|
+
# Readonly doesn't change field structure, just marks them readonly
|
|
90
|
+
return type_lookup(args[0])
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_pick(
|
|
96
|
+
base_type: str,
|
|
97
|
+
selector: str,
|
|
98
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
99
|
+
) -> "list[TypeField] | None":
|
|
100
|
+
"""Resolve Pick<User, "id" | "name"> to filtered fields."""
|
|
101
|
+
fields = type_lookup(base_type)
|
|
102
|
+
if not fields:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
selected = _parse_field_selector(selector)
|
|
106
|
+
return [f for f in fields if f.name in selected]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_omit(
|
|
110
|
+
base_type: str,
|
|
111
|
+
selector: str,
|
|
112
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
113
|
+
) -> "list[TypeField] | None":
|
|
114
|
+
"""Resolve Omit<User, "password" | "email"> to fields minus excluded."""
|
|
115
|
+
fields = type_lookup(base_type)
|
|
116
|
+
if not fields:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
excluded = _parse_field_selector(selector)
|
|
120
|
+
return [f for f in fields if f.name not in excluded]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_partial(
|
|
124
|
+
base_type: str,
|
|
125
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
126
|
+
) -> "list[TypeField] | None":
|
|
127
|
+
"""Resolve Partial<User> to all fields marked optional."""
|
|
128
|
+
fields = type_lookup(base_type)
|
|
129
|
+
if not fields:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
result = []
|
|
133
|
+
for f in fields:
|
|
134
|
+
new_f = copy(f)
|
|
135
|
+
new_f.optional = True
|
|
136
|
+
result.append(new_f)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_required(
|
|
141
|
+
base_type: str,
|
|
142
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
143
|
+
) -> "list[TypeField] | None":
|
|
144
|
+
"""Resolve Required<User> to all fields marked required."""
|
|
145
|
+
fields = type_lookup(base_type)
|
|
146
|
+
if not fields:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
result = []
|
|
150
|
+
for f in fields:
|
|
151
|
+
new_f = copy(f)
|
|
152
|
+
new_f.optional = False
|
|
153
|
+
result.append(new_f)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _resolve_custom_generic(
|
|
158
|
+
base: str,
|
|
159
|
+
args: list[str],
|
|
160
|
+
type_lookup: Callable[[str], "list[TypeField] | None"],
|
|
161
|
+
generic_def_lookup: Callable[[str], "tuple[list[TypeField], list[str]] | None"],
|
|
162
|
+
) -> "list[TypeField] | None":
|
|
163
|
+
"""Resolve a custom generic like ApiResponse<User>.
|
|
164
|
+
|
|
165
|
+
Looks up ApiResponse's definition (which has generic_params ["T"]),
|
|
166
|
+
then substitutes T → User in all field type_str values.
|
|
167
|
+
"""
|
|
168
|
+
definition = generic_def_lookup(base)
|
|
169
|
+
if not definition:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def_fields, generic_params = definition
|
|
173
|
+
|
|
174
|
+
if not generic_params or len(generic_params) != len(args):
|
|
175
|
+
# Param count mismatch — return fields as-is
|
|
176
|
+
return list(def_fields)
|
|
177
|
+
|
|
178
|
+
# Build substitution map: T → User
|
|
179
|
+
subs = dict(zip(generic_params, args))
|
|
180
|
+
|
|
181
|
+
result = []
|
|
182
|
+
for f in def_fields:
|
|
183
|
+
new_f = copy(f)
|
|
184
|
+
# Substitute generic params in type_str
|
|
185
|
+
new_type = new_f.type_str
|
|
186
|
+
for param, replacement in subs.items():
|
|
187
|
+
if new_type == param:
|
|
188
|
+
new_type = replacement
|
|
189
|
+
elif param in new_type:
|
|
190
|
+
new_type = new_type.replace(param, replacement)
|
|
191
|
+
new_f.type_str = new_type
|
|
192
|
+
result.append(new_f)
|
|
193
|
+
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_field_selector(selector: str) -> set[str]:
|
|
198
|
+
"""Parse a field selector like '"id" | "name"' into a set of field names."""
|
|
199
|
+
names = set()
|
|
200
|
+
for part in selector.split("|"):
|
|
201
|
+
name = part.strip().strip("'\"")
|
|
202
|
+
if name:
|
|
203
|
+
names.add(name)
|
|
204
|
+
return names
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Cross-file handler type index: maps class controller methods to their parameter types.
|
|
2
|
+
|
|
3
|
+
Resolves the body type when a route uses a class-based handler like:
|
|
4
|
+
router.post("/", projectController.create)
|
|
5
|
+
|
|
6
|
+
where the type annotation Request<{}, {}, CreateProjectBody> lives in the
|
|
7
|
+
controller's class definition in a separate file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from commiter.parser import (
|
|
16
|
+
find_ts_class_methods,
|
|
17
|
+
find_ts_class_instances,
|
|
18
|
+
ClassMethod,
|
|
19
|
+
ClassInstance,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from tree_sitter import Tree
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HandlerIndex:
|
|
27
|
+
"""Index of class method type annotations across all files.
|
|
28
|
+
|
|
29
|
+
Built during Pass 1, queried during Pass 2 to resolve body types
|
|
30
|
+
for class-based route handlers.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
idx = HandlerIndex()
|
|
34
|
+
idx.index_file(path, tree, source, "typescript")
|
|
35
|
+
body_type = idx.get_body_type("projectController.create")
|
|
36
|
+
# Returns "CreateProjectBody"
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
# "ClassName.methodName" -> ClassMethod
|
|
41
|
+
self._methods: dict[str, ClassMethod] = {}
|
|
42
|
+
# "varName" -> "ClassName" (e.g. "projectController" -> "ProjectController")
|
|
43
|
+
self._instances: dict[str, str] = {}
|
|
44
|
+
|
|
45
|
+
def index_file(self, file_path: str, tree: "Tree", source: bytes, language: str) -> None:
|
|
46
|
+
"""Scan a file for class method signatures and instance variables."""
|
|
47
|
+
if language not in ("javascript", "typescript", "tsx"):
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
abs_path = os.path.abspath(file_path)
|
|
51
|
+
|
|
52
|
+
# Index class methods
|
|
53
|
+
methods = find_ts_class_methods(tree.root_node, source, file_path=abs_path)
|
|
54
|
+
for method in methods:
|
|
55
|
+
key = f"{method.class_name}.{method.method_name}"
|
|
56
|
+
self._methods[key] = method
|
|
57
|
+
|
|
58
|
+
# Index instance variables: const x = new ClassName()
|
|
59
|
+
instances = find_ts_class_instances(tree.root_node, source, file_path=abs_path)
|
|
60
|
+
for inst in instances:
|
|
61
|
+
self._instances[inst.var_name] = inst.class_name
|
|
62
|
+
|
|
63
|
+
def get_body_type(self, handler_ref: str) -> str | None:
|
|
64
|
+
"""Resolve a handler reference like 'projectController.create' to its body type.
|
|
65
|
+
|
|
66
|
+
Steps:
|
|
67
|
+
1. Split into var_name='projectController', method='create'
|
|
68
|
+
2. Look up var_name in _instances -> 'ProjectController'
|
|
69
|
+
3. Look up 'ProjectController.create' in _methods
|
|
70
|
+
4. Return request_body_type
|
|
71
|
+
"""
|
|
72
|
+
if "." not in handler_ref:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
var_name, method_name = handler_ref.rsplit(".", 1)
|
|
76
|
+
|
|
77
|
+
# Resolve variable to class name
|
|
78
|
+
class_name = self._instances.get(var_name)
|
|
79
|
+
if not class_name:
|
|
80
|
+
# Try using the variable name as-is (might already be the class name)
|
|
81
|
+
class_name = var_name
|
|
82
|
+
|
|
83
|
+
# Look up the method
|
|
84
|
+
key = f"{class_name}.{method_name}"
|
|
85
|
+
method = self._methods.get(key)
|
|
86
|
+
if method:
|
|
87
|
+
return method.request_body_type
|
|
88
|
+
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def method_count(self) -> int:
|
|
93
|
+
return len(self._methods)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def instance_count(self) -> int:
|
|
97
|
+
return len(self._instances)
|
commiter/lib.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public library API for `commiter-cli`.
|
|
3
|
+
|
|
4
|
+
Programmatic callers (e.g. the Commiter backend running enrich on behalf of
|
|
5
|
+
users) should import from this module. Everything else under `commiter.cli`,
|
|
6
|
+
`commiter.scanner`, `commiter.report.*` etc. is considered internal and is
|
|
7
|
+
not a stable surface — it may change between releases.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Union
|
|
13
|
+
|
|
14
|
+
from commiter.scanner import scan_repos_full
|
|
15
|
+
from commiter.report.architecture import generate_architecture
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PathLike = Union[str, Path]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_snapshot(
|
|
22
|
+
repo_paths: list[PathLike],
|
|
23
|
+
*,
|
|
24
|
+
extra_excludes: list[str] | None = None,
|
|
25
|
+
) -> dict:
|
|
26
|
+
"""
|
|
27
|
+
Scan one or more local repository directories and return the architecture
|
|
28
|
+
snapshot as a Python dict — the same shape the CLI POSTs to
|
|
29
|
+
`/v1/architecture/upload` on commiter.dev.
|
|
30
|
+
|
|
31
|
+
Does *not* upload. The caller is responsible for persisting the result.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
repo_paths: One or more on-disk paths, each pointing at the root of a
|
|
35
|
+
repository (i.e. a directory containing the project's source).
|
|
36
|
+
extra_excludes: Additional path-substring exclusions forwarded to the
|
|
37
|
+
scanner. Useful if the caller wants to skip vendored dirs beyond
|
|
38
|
+
the scanner's built-in list.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A dict with top-level keys `nodes`, `edges`, `fileTree`,
|
|
42
|
+
`nodeAnalysis`, `nodeHashes`, and `repoFullNames`. See the upload
|
|
43
|
+
endpoint's contract for field-level details.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
FileNotFoundError: if any of the supplied paths doesn't exist.
|
|
47
|
+
"""
|
|
48
|
+
if not repo_paths:
|
|
49
|
+
raise ValueError("repo_paths must contain at least one path")
|
|
50
|
+
|
|
51
|
+
str_paths: list[str] = []
|
|
52
|
+
for p in repo_paths:
|
|
53
|
+
path = Path(p)
|
|
54
|
+
if not path.exists():
|
|
55
|
+
raise FileNotFoundError(f"Repository path does not exist: {path}")
|
|
56
|
+
str_paths.append(str(path))
|
|
57
|
+
|
|
58
|
+
results = scan_repos_full(str_paths, extra_excludes or [])
|
|
59
|
+
json_str = generate_architecture(results)
|
|
60
|
+
return json.loads(json_str)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["generate_snapshot"]
|