codemble 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.
- codemble/__init__.py +4 -0
- codemble/adapters/__init__.py +38 -0
- codemble/adapters/base.py +199 -0
- codemble/adapters/discovery.py +190 -0
- codemble/adapters/project.py +206 -0
- codemble/adapters/python_ast.py +766 -0
- codemble/adapters/typescript_tree_sitter.py +1263 -0
- codemble/checks/__init__.py +19 -0
- codemble/checks/service.py +380 -0
- codemble/cli.py +161 -0
- codemble/graph/__init__.py +17 -0
- codemble/graph/finalize.py +91 -0
- codemble/graph/layout.py +132 -0
- codemble/lens/__init__.py +18 -0
- codemble/lens/javascript_typescript.py +82 -0
- codemble/lens/python.py +71 -0
- codemble/llm/__init__.py +6 -0
- codemble/llm/providers.py +137 -0
- codemble/llm/study.py +439 -0
- codemble/progress/__init__.py +9 -0
- codemble/progress/store.py +160 -0
- codemble/server/__init__.py +6 -0
- codemble/server/app.py +273 -0
- codemble/server/runtime.py +68 -0
- codemble/web_dist/assets/index-DOBVd_-M.css +1 -0
- codemble/web_dist/assets/index-cIG6GGIB.js +5238 -0
- codemble/web_dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- codemble/web_dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-500-normal-C-QwvIb3.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-500-normal-XI1O8euf.woff2 +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-700-normal-CkoCYOiI.woff +0 -0
- codemble/web_dist/assets/shippori-mincho-latin-700-normal-DHcmzUO5.woff2 +0 -0
- codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-BEdayliK.woff2 +0 -0
- codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-CPSmNJAU.woff +0 -0
- codemble/web_dist/index.html +18 -0
- codemble-0.3.0.dist-info/METADATA +417 -0
- codemble-0.3.0.dist-info/RECORD +43 -0
- codemble-0.3.0.dist-info/WHEEL +4 -0
- codemble-0.3.0.dist-info/entry_points.txt +2 -0
- codemble-0.3.0.dist-info/licenses/LICENSE +202 -0
- codemble-0.3.0.dist-info/licenses/NOTICE +2 -0
codemble/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Language adapters turn parser evidence into Codemble's graph contract."""
|
|
2
|
+
|
|
3
|
+
from codemble.adapters.base import (
|
|
4
|
+
AdapterParseError,
|
|
5
|
+
ConceptAnnotation,
|
|
6
|
+
Edge,
|
|
7
|
+
Graph,
|
|
8
|
+
LanguageAdapter,
|
|
9
|
+
Node,
|
|
10
|
+
)
|
|
11
|
+
from codemble.adapters.project import (
|
|
12
|
+
ProjectIntake,
|
|
13
|
+
ProjectParseError,
|
|
14
|
+
ProjectParser,
|
|
15
|
+
ProjectScaleError,
|
|
16
|
+
)
|
|
17
|
+
from codemble.adapters.python_ast import PythonAstAdapter, PythonParseError
|
|
18
|
+
from codemble.adapters.typescript_tree_sitter import (
|
|
19
|
+
JavaScriptTypeScriptAdapter,
|
|
20
|
+
JavaScriptTypeScriptParseError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AdapterParseError",
|
|
25
|
+
"ConceptAnnotation",
|
|
26
|
+
"Edge",
|
|
27
|
+
"Graph",
|
|
28
|
+
"LanguageAdapter",
|
|
29
|
+
"Node",
|
|
30
|
+
"JavaScriptTypeScriptAdapter",
|
|
31
|
+
"JavaScriptTypeScriptParseError",
|
|
32
|
+
"PythonAstAdapter",
|
|
33
|
+
"PythonParseError",
|
|
34
|
+
"ProjectIntake",
|
|
35
|
+
"ProjectParseError",
|
|
36
|
+
"ProjectParser",
|
|
37
|
+
"ProjectScaleError",
|
|
38
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Language-neutral graph contracts for Codemble parsers.
|
|
2
|
+
|
|
3
|
+
Adapters are the only layer allowed to derive structure from source text. The
|
|
4
|
+
rest of Codemble consumes this render-ready, deterministic representation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal, Protocol
|
|
13
|
+
|
|
14
|
+
NodeKind = Literal["module", "class", "function"]
|
|
15
|
+
EdgeKind = Literal["import", "call"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdapterParseError(ValueError):
|
|
19
|
+
"""A supported language could not be mapped without guessing."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Node:
|
|
24
|
+
"""A parser-proven source structure.
|
|
25
|
+
|
|
26
|
+
``partial`` is true only for module nodes whose source could not be fully
|
|
27
|
+
parsed. Keeping that node makes parse failures visible without inventing
|
|
28
|
+
any structure for the unreadable file.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
kind: NodeKind
|
|
33
|
+
name: str
|
|
34
|
+
language: str
|
|
35
|
+
file: str
|
|
36
|
+
lineno: int
|
|
37
|
+
end_lineno: int
|
|
38
|
+
loc: int
|
|
39
|
+
region: str
|
|
40
|
+
centrality: int = 0
|
|
41
|
+
entrypoint_rank: int | None = None
|
|
42
|
+
understood: bool = False
|
|
43
|
+
partial: bool = False
|
|
44
|
+
system_x: float = 0.0
|
|
45
|
+
system_y: float = 0.0
|
|
46
|
+
system_z: float = 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Edge:
|
|
51
|
+
"""A parser-observed relationship between source structures.
|
|
52
|
+
|
|
53
|
+
A destination beginning with ``external:`` is intentionally not a graph
|
|
54
|
+
node. ``external`` lets consumers render or filter those observations
|
|
55
|
+
without pretending third-party or dynamically-resolved structure exists.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
src: str
|
|
59
|
+
dst: str
|
|
60
|
+
kind: EdgeKind
|
|
61
|
+
certain: bool
|
|
62
|
+
lineno: int
|
|
63
|
+
external: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class ConceptAnnotation:
|
|
68
|
+
"""A language construct proven by an adapter at an exact source span."""
|
|
69
|
+
|
|
70
|
+
node_id: str
|
|
71
|
+
language: str
|
|
72
|
+
concept: str
|
|
73
|
+
lineno: int
|
|
74
|
+
end_lineno: int
|
|
75
|
+
snippet: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Region:
|
|
80
|
+
"""A render-ready star system derived from parser-proven nodes."""
|
|
81
|
+
|
|
82
|
+
id: str
|
|
83
|
+
language: str
|
|
84
|
+
loc: int
|
|
85
|
+
centrality: int
|
|
86
|
+
node_count: int
|
|
87
|
+
understood: bool
|
|
88
|
+
home: bool
|
|
89
|
+
x: float
|
|
90
|
+
y: float
|
|
91
|
+
z: float
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class RegionEdge:
|
|
96
|
+
"""An aggregated import route between two project regions."""
|
|
97
|
+
|
|
98
|
+
src: str
|
|
99
|
+
dst: str
|
|
100
|
+
weight: int
|
|
101
|
+
certain: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Graph:
|
|
106
|
+
"""A deterministic, language-tagged project graph."""
|
|
107
|
+
|
|
108
|
+
nodes: tuple[Node, ...]
|
|
109
|
+
edges: tuple[Edge, ...]
|
|
110
|
+
entrypoint_candidates: tuple[str, ...]
|
|
111
|
+
project_root: str
|
|
112
|
+
file_hashes: dict[str, str]
|
|
113
|
+
selected_entrypoint: str | None = None
|
|
114
|
+
concept_annotations: tuple[ConceptAnnotation, ...] = ()
|
|
115
|
+
regions: tuple[Region, ...] = ()
|
|
116
|
+
region_edges: tuple[RegionEdge, ...] = ()
|
|
117
|
+
partial_files: tuple[str, ...] = ()
|
|
118
|
+
schema_version: int = field(default=4, init=False)
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict[str, object]:
|
|
121
|
+
"""Return a JSON-ready representation in canonical collection order."""
|
|
122
|
+
|
|
123
|
+
nodes = sorted(self.nodes, key=lambda node: node.id)
|
|
124
|
+
edges = sorted(
|
|
125
|
+
self.edges,
|
|
126
|
+
key=lambda edge: (
|
|
127
|
+
edge.src,
|
|
128
|
+
edge.dst,
|
|
129
|
+
edge.kind,
|
|
130
|
+
edge.lineno,
|
|
131
|
+
edge.certain,
|
|
132
|
+
edge.external,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
return {
|
|
136
|
+
"schema_version": self.schema_version,
|
|
137
|
+
"nodes": [asdict(node) for node in nodes],
|
|
138
|
+
"edges": [asdict(edge) for edge in edges],
|
|
139
|
+
"entrypoint_candidates": list(self.entrypoint_candidates),
|
|
140
|
+
"selected_entrypoint": self.selected_entrypoint,
|
|
141
|
+
"project_root": self.project_root,
|
|
142
|
+
"file_hashes": dict(sorted(self.file_hashes.items())),
|
|
143
|
+
"concept_annotations": [
|
|
144
|
+
asdict(annotation)
|
|
145
|
+
for annotation in sorted(
|
|
146
|
+
self.concept_annotations,
|
|
147
|
+
key=lambda item: (
|
|
148
|
+
item.language,
|
|
149
|
+
item.node_id,
|
|
150
|
+
item.lineno,
|
|
151
|
+
item.concept,
|
|
152
|
+
item.end_lineno,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
],
|
|
156
|
+
"regions": [asdict(region) for region in sorted(self.regions, key=lambda item: item.id)],
|
|
157
|
+
"region_edges": [
|
|
158
|
+
asdict(edge)
|
|
159
|
+
for edge in sorted(self.region_edges, key=lambda item: (item.src, item.dst))
|
|
160
|
+
],
|
|
161
|
+
"partial_files": sorted(self.partial_files),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def to_json(self) -> str:
|
|
165
|
+
"""Serialize with stable formatting so identical input yields bytes."""
|
|
166
|
+
|
|
167
|
+
return json.dumps(self.to_dict(), indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
168
|
+
|
|
169
|
+
def write_json(self, destination: Path) -> None:
|
|
170
|
+
"""Write canonical graph JSON to ``destination``."""
|
|
171
|
+
|
|
172
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
destination.write_text(self.to_json(), encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class LanguageAdapter(Protocol):
|
|
177
|
+
"""The seam every supported language implements."""
|
|
178
|
+
|
|
179
|
+
language: str
|
|
180
|
+
file_extensions: frozenset[str]
|
|
181
|
+
ignored_directories: frozenset[str]
|
|
182
|
+
|
|
183
|
+
def discover(self, path: Path) -> tuple[Path, tuple[Path, ...]]:
|
|
184
|
+
"""Return the exact root and supported files this adapter will parse."""
|
|
185
|
+
|
|
186
|
+
def parse(self, path: Path, *, entrypoint: str | None = None) -> Graph:
|
|
187
|
+
"""Parse ``path`` into a graph without inventing source structure."""
|
|
188
|
+
|
|
189
|
+
def parse_files(
|
|
190
|
+
self,
|
|
191
|
+
project_root: Path,
|
|
192
|
+
files: tuple[Path, ...],
|
|
193
|
+
*,
|
|
194
|
+
entrypoint: str | None = None,
|
|
195
|
+
) -> Graph:
|
|
196
|
+
"""Parse files already discovered as owned by this adapter."""
|
|
197
|
+
|
|
198
|
+
def concepts(self, node: Node, source: str) -> list[ConceptAnnotation]:
|
|
199
|
+
"""Return only language constructs proven present in ``source``."""
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Shared, deterministic source discovery for language adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_IGNORED_DIRECTORIES = {"venv", ".venv", "node_modules", "__pycache__"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SourceDiscoveryError(ValueError):
|
|
14
|
+
"""The requested source scope does not exist or is not readable as a scope."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class SourceDiscovery:
|
|
19
|
+
"""A normalized project root and its supported source files."""
|
|
20
|
+
|
|
21
|
+
root: Path
|
|
22
|
+
files: tuple[Path, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class SourceOwnership:
|
|
27
|
+
"""One adapter's source extensions and directory exclusions."""
|
|
28
|
+
|
|
29
|
+
owner: str
|
|
30
|
+
extensions: frozenset[str]
|
|
31
|
+
ignored_directories: frozenset[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class OwnedSourceFiles:
|
|
36
|
+
"""Files assigned to one adapter during project discovery."""
|
|
37
|
+
|
|
38
|
+
owner: str
|
|
39
|
+
files: tuple[Path, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class ProjectSourceDiscovery:
|
|
44
|
+
"""One normalized project root with all adapter ownership resolved."""
|
|
45
|
+
|
|
46
|
+
root: Path
|
|
47
|
+
ownership: tuple[OwnedSourceFiles, ...]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def files(self) -> tuple[Path, ...]:
|
|
51
|
+
return tuple(
|
|
52
|
+
sorted({file for owned in self.ownership for file in owned.files})
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def discover_source_files(
|
|
57
|
+
requested: Path,
|
|
58
|
+
extensions: frozenset[str],
|
|
59
|
+
*,
|
|
60
|
+
ignored_directories: frozenset[str] = frozenset(),
|
|
61
|
+
) -> SourceDiscovery:
|
|
62
|
+
"""Discover matching files while honoring the project's root ``.gitignore``."""
|
|
63
|
+
owner = "source"
|
|
64
|
+
project = discover_project_sources(
|
|
65
|
+
requested,
|
|
66
|
+
(SourceOwnership(owner, extensions, ignored_directories),),
|
|
67
|
+
)
|
|
68
|
+
return SourceDiscovery(project.root, project.ownership[0].files)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def discover_project_sources(
|
|
72
|
+
requested: Path,
|
|
73
|
+
ownership: tuple[SourceOwnership, ...],
|
|
74
|
+
) -> ProjectSourceDiscovery:
|
|
75
|
+
"""Discover every adapter's files in one deterministic filesystem walk."""
|
|
76
|
+
|
|
77
|
+
normalized = requested.expanduser().resolve()
|
|
78
|
+
if not normalized.exists():
|
|
79
|
+
raise SourceDiscoveryError(f"path does not exist: {normalized}")
|
|
80
|
+
if not ownership:
|
|
81
|
+
raise ValueError("project source discovery requires at least one owner")
|
|
82
|
+
discovered: dict[str, list[Path]] = {rule.owner: [] for rule in ownership}
|
|
83
|
+
if normalized.is_file():
|
|
84
|
+
for rule in ownership:
|
|
85
|
+
if normalized.suffix.lower() in rule.extensions:
|
|
86
|
+
discovered[rule.owner].append(normalized)
|
|
87
|
+
return ProjectSourceDiscovery(
|
|
88
|
+
normalized.parent,
|
|
89
|
+
tuple(
|
|
90
|
+
OwnedSourceFiles(rule.owner, tuple(discovered[rule.owner]))
|
|
91
|
+
for rule in ownership
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
if not normalized.is_dir():
|
|
95
|
+
raise SourceDiscoveryError(f"expected a source file or directory: {normalized}")
|
|
96
|
+
|
|
97
|
+
ignore_rules = _load_gitignore(normalized)
|
|
98
|
+
for current, directory_names, file_names in os.walk(normalized):
|
|
99
|
+
current_path = Path(current)
|
|
100
|
+
directory_names[:] = sorted(
|
|
101
|
+
directory_name
|
|
102
|
+
for directory_name in directory_names
|
|
103
|
+
if not _ignore_project_directory(
|
|
104
|
+
(current_path / directory_name).relative_to(normalized),
|
|
105
|
+
ignore_rules,
|
|
106
|
+
ownership,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
for file_name in sorted(file_names):
|
|
110
|
+
candidate = current_path / file_name
|
|
111
|
+
relative = candidate.relative_to(normalized)
|
|
112
|
+
if _matches_gitignore(relative, False, ignore_rules):
|
|
113
|
+
continue
|
|
114
|
+
for rule in ownership:
|
|
115
|
+
if candidate.suffix.lower() not in rule.extensions:
|
|
116
|
+
continue
|
|
117
|
+
if any(part in rule.ignored_directories for part in relative.parts):
|
|
118
|
+
continue
|
|
119
|
+
discovered[rule.owner].append(candidate)
|
|
120
|
+
return ProjectSourceDiscovery(
|
|
121
|
+
normalized,
|
|
122
|
+
tuple(
|
|
123
|
+
OwnedSourceFiles(rule.owner, tuple(sorted(discovered[rule.owner])))
|
|
124
|
+
for rule in ownership
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_gitignore(root: Path) -> tuple[tuple[str, bool], ...]:
|
|
130
|
+
gitignore = root / ".gitignore"
|
|
131
|
+
if not gitignore.is_file():
|
|
132
|
+
return ()
|
|
133
|
+
rules: list[tuple[str, bool]] = []
|
|
134
|
+
for raw_line in gitignore.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
135
|
+
line = raw_line.strip()
|
|
136
|
+
if not line or line.startswith("#"):
|
|
137
|
+
continue
|
|
138
|
+
negated = line.startswith("!")
|
|
139
|
+
pattern = line[1:] if negated else line
|
|
140
|
+
rules.append((pattern, negated))
|
|
141
|
+
return tuple(rules)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ignore_project_directory(
|
|
145
|
+
relative: Path,
|
|
146
|
+
rules: tuple[tuple[str, bool], ...],
|
|
147
|
+
ownership: tuple[SourceOwnership, ...],
|
|
148
|
+
) -> bool:
|
|
149
|
+
if any(part.startswith(".") for part in relative.parts):
|
|
150
|
+
return True
|
|
151
|
+
if any(part in _IGNORED_DIRECTORIES for part in relative.parts):
|
|
152
|
+
return True
|
|
153
|
+
if _matches_gitignore(relative, True, rules):
|
|
154
|
+
return True
|
|
155
|
+
return all(
|
|
156
|
+
any(part in rule.ignored_directories for part in relative.parts)
|
|
157
|
+
for rule in ownership
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _matches_gitignore(
|
|
162
|
+
relative: Path,
|
|
163
|
+
is_directory: bool,
|
|
164
|
+
rules: tuple[tuple[str, bool], ...],
|
|
165
|
+
) -> bool:
|
|
166
|
+
path = relative.as_posix()
|
|
167
|
+
ignored = False
|
|
168
|
+
for raw_pattern, negated in rules:
|
|
169
|
+
directory_only = raw_pattern.endswith("/")
|
|
170
|
+
pattern = raw_pattern.rstrip("/").lstrip("/")
|
|
171
|
+
if directory_only and not is_directory and not path.startswith(f"{pattern}/"):
|
|
172
|
+
continue
|
|
173
|
+
if "/" in pattern:
|
|
174
|
+
matched = fnmatch.fnmatch(path, pattern) or path.startswith(f"{pattern}/")
|
|
175
|
+
else:
|
|
176
|
+
matched = any(fnmatch.fnmatch(part, pattern) for part in relative.parts)
|
|
177
|
+
if matched:
|
|
178
|
+
ignored = not negated
|
|
179
|
+
return ignored
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"OwnedSourceFiles",
|
|
184
|
+
"ProjectSourceDiscovery",
|
|
185
|
+
"SourceDiscovery",
|
|
186
|
+
"SourceDiscoveryError",
|
|
187
|
+
"SourceOwnership",
|
|
188
|
+
"discover_project_sources",
|
|
189
|
+
"discover_source_files",
|
|
190
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Language-neutral project parsing and graph composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from codemble.adapters.base import AdapterParseError, Graph, LanguageAdapter, Node
|
|
10
|
+
from codemble.adapters.discovery import (
|
|
11
|
+
OwnedSourceFiles,
|
|
12
|
+
SourceDiscoveryError,
|
|
13
|
+
SourceOwnership,
|
|
14
|
+
discover_project_sources,
|
|
15
|
+
)
|
|
16
|
+
from codemble.graph.finalize import GraphFinalizationError, finalize_graph
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProjectParseError(AdapterParseError):
|
|
20
|
+
"""A project cannot be composed into one honest graph."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectScaleError(ProjectParseError):
|
|
24
|
+
"""A discovered project needs a smaller learner-selected scope."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, intake: ProjectIntake, scale_cap: int) -> None:
|
|
27
|
+
self.intake = intake
|
|
28
|
+
self.scale_cap = scale_cap
|
|
29
|
+
super().__init__(
|
|
30
|
+
f"found {len(intake.files)} supported source files; Codemble is capped at "
|
|
31
|
+
f"{scale_cap}. Re-run with `codemble --path PATH` to choose a project "
|
|
32
|
+
"subdirectory."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ProjectIntake:
|
|
38
|
+
"""One supported project scope with adapter ownership resolved once."""
|
|
39
|
+
|
|
40
|
+
path: Path
|
|
41
|
+
root: Path
|
|
42
|
+
files: tuple[Path, ...]
|
|
43
|
+
_ownership: tuple[OwnedSourceFiles, ...]
|
|
44
|
+
|
|
45
|
+
def _files_for(self, language: str) -> tuple[Path, ...]:
|
|
46
|
+
return next(
|
|
47
|
+
(owned.files for owned in self._ownership if owned.owner == language),
|
|
48
|
+
(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def scope_counts(self) -> tuple[tuple[str, int], ...]:
|
|
52
|
+
"""Count supported files per top-level directory, busiest first."""
|
|
53
|
+
|
|
54
|
+
counts: dict[str, int] = {}
|
|
55
|
+
for file in self.files:
|
|
56
|
+
relative = file.relative_to(self.root)
|
|
57
|
+
directory = relative.parts[0] if len(relative.parts) > 1 else "."
|
|
58
|
+
counts[directory] = counts.get(directory, 0) + 1
|
|
59
|
+
return tuple(
|
|
60
|
+
sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProjectParser:
|
|
65
|
+
"""Discover supported languages and compose their graphs behind one interface."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, adapters: Iterable[LanguageAdapter] | None = None) -> None:
|
|
68
|
+
if adapters is None:
|
|
69
|
+
from codemble.adapters.python_ast import PythonAstAdapter
|
|
70
|
+
from codemble.adapters.typescript_tree_sitter import (
|
|
71
|
+
JavaScriptTypeScriptAdapter,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
adapters = (PythonAstAdapter(), JavaScriptTypeScriptAdapter())
|
|
75
|
+
self._adapters = tuple(adapters)
|
|
76
|
+
if not self._adapters:
|
|
77
|
+
raise ValueError("ProjectParser requires at least one language adapter")
|
|
78
|
+
languages = [adapter.language for adapter in self._adapters]
|
|
79
|
+
if len(languages) != len(set(languages)):
|
|
80
|
+
raise ValueError("ProjectParser adapter languages must be unique")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def languages(self) -> tuple[str, ...]:
|
|
84
|
+
"""Return supported language identifiers in stable registry order."""
|
|
85
|
+
|
|
86
|
+
return tuple(adapter.language for adapter in self._adapters)
|
|
87
|
+
|
|
88
|
+
scale_cap = 300
|
|
89
|
+
|
|
90
|
+
def intake(self, path: Path, *, explicit: bool = False) -> ProjectIntake:
|
|
91
|
+
"""Resolve one project scope and every adapter's owned files."""
|
|
92
|
+
|
|
93
|
+
normalized = path.expanduser().resolve()
|
|
94
|
+
extensions = frozenset().union(*(adapter.file_extensions for adapter in self._adapters))
|
|
95
|
+
ownership = tuple(
|
|
96
|
+
SourceOwnership(
|
|
97
|
+
owner=adapter.language,
|
|
98
|
+
extensions=adapter.file_extensions,
|
|
99
|
+
ignored_directories=adapter.ignored_directories,
|
|
100
|
+
)
|
|
101
|
+
for adapter in self._adapters
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
discovery = discover_project_sources(normalized, ownership)
|
|
105
|
+
except SourceDiscoveryError as error:
|
|
106
|
+
raise ProjectParseError(str(error)) from error
|
|
107
|
+
files = discovery.files
|
|
108
|
+
if not files:
|
|
109
|
+
expected = ", ".join(sorted(extensions))
|
|
110
|
+
raise ProjectParseError(
|
|
111
|
+
f"no supported source files found under: {normalized} "
|
|
112
|
+
f"(expected {expected})"
|
|
113
|
+
)
|
|
114
|
+
intake = ProjectIntake(
|
|
115
|
+
path=normalized,
|
|
116
|
+
root=discovery.root,
|
|
117
|
+
files=files,
|
|
118
|
+
_ownership=discovery.ownership,
|
|
119
|
+
)
|
|
120
|
+
if not explicit and len(files) > self.scale_cap:
|
|
121
|
+
raise ProjectScaleError(intake, self.scale_cap)
|
|
122
|
+
return intake
|
|
123
|
+
|
|
124
|
+
def discover(self, path: Path) -> tuple[Path, tuple[Path, ...]]:
|
|
125
|
+
"""Return all files accepted by the registered language adapters."""
|
|
126
|
+
|
|
127
|
+
intake = self.intake(path)
|
|
128
|
+
return intake.root, intake.files
|
|
129
|
+
|
|
130
|
+
def parse(
|
|
131
|
+
self,
|
|
132
|
+
source: Path | ProjectIntake,
|
|
133
|
+
*,
|
|
134
|
+
entrypoint: str | None = None,
|
|
135
|
+
explicit: bool = False,
|
|
136
|
+
) -> Graph:
|
|
137
|
+
"""Parse every detected language and return one deterministic graph."""
|
|
138
|
+
|
|
139
|
+
intake = (
|
|
140
|
+
source
|
|
141
|
+
if isinstance(source, ProjectIntake)
|
|
142
|
+
else self.intake(source, explicit=explicit)
|
|
143
|
+
)
|
|
144
|
+
graphs: list[Graph] = []
|
|
145
|
+
for adapter in self._adapters:
|
|
146
|
+
files = intake._files_for(adapter.language)
|
|
147
|
+
if not files:
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
graphs.append(adapter.parse_files(intake.root, files))
|
|
151
|
+
except AdapterParseError as error:
|
|
152
|
+
raise ProjectParseError(str(error)) from error
|
|
153
|
+
return _compose_graphs(tuple(graphs), intake.root, entrypoint)
|
|
154
|
+
|
|
155
|
+
def _compose_graphs(
|
|
156
|
+
graphs: tuple[Graph, ...],
|
|
157
|
+
project_root: Path,
|
|
158
|
+
entrypoint: str | None,
|
|
159
|
+
) -> Graph:
|
|
160
|
+
nodes: list[Node] = []
|
|
161
|
+
edges = []
|
|
162
|
+
annotations = []
|
|
163
|
+
partial_files: set[str] = set()
|
|
164
|
+
file_hashes: dict[str, str] = {}
|
|
165
|
+
node_ids: set[str] = set()
|
|
166
|
+
|
|
167
|
+
for graph in graphs:
|
|
168
|
+
for node in graph.nodes:
|
|
169
|
+
if node.id in node_ids:
|
|
170
|
+
raise ProjectParseError(
|
|
171
|
+
f"language adapters produced the same node ID: {node.id}"
|
|
172
|
+
)
|
|
173
|
+
node_ids.add(node.id)
|
|
174
|
+
nodes.append(node)
|
|
175
|
+
edges.extend(graph.edges)
|
|
176
|
+
annotations.extend(graph.concept_annotations)
|
|
177
|
+
partial_files.update(graph.partial_files)
|
|
178
|
+
for file, digest in graph.file_hashes.items():
|
|
179
|
+
existing = file_hashes.get(file)
|
|
180
|
+
if existing is not None and existing != digest:
|
|
181
|
+
raise ProjectParseError(
|
|
182
|
+
f"language adapters disagreed on the source hash for: {file}"
|
|
183
|
+
)
|
|
184
|
+
file_hashes[file] = digest
|
|
185
|
+
|
|
186
|
+
draft = Graph(
|
|
187
|
+
nodes=tuple(nodes),
|
|
188
|
+
edges=tuple(edges),
|
|
189
|
+
entrypoint_candidates=(),
|
|
190
|
+
project_root=str(project_root),
|
|
191
|
+
file_hashes=file_hashes,
|
|
192
|
+
concept_annotations=tuple(annotations),
|
|
193
|
+
partial_files=tuple(partial_files),
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
return finalize_graph(draft, entrypoint=entrypoint)
|
|
197
|
+
except GraphFinalizationError as error:
|
|
198
|
+
raise ProjectParseError(str(error)) from error
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = [
|
|
202
|
+
"ProjectIntake",
|
|
203
|
+
"ProjectParseError",
|
|
204
|
+
"ProjectParser",
|
|
205
|
+
"ProjectScaleError",
|
|
206
|
+
]
|