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,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"]