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.
Files changed (43) hide show
  1. codemble/__init__.py +4 -0
  2. codemble/adapters/__init__.py +38 -0
  3. codemble/adapters/base.py +199 -0
  4. codemble/adapters/discovery.py +190 -0
  5. codemble/adapters/project.py +206 -0
  6. codemble/adapters/python_ast.py +766 -0
  7. codemble/adapters/typescript_tree_sitter.py +1263 -0
  8. codemble/checks/__init__.py +19 -0
  9. codemble/checks/service.py +380 -0
  10. codemble/cli.py +161 -0
  11. codemble/graph/__init__.py +17 -0
  12. codemble/graph/finalize.py +91 -0
  13. codemble/graph/layout.py +132 -0
  14. codemble/lens/__init__.py +18 -0
  15. codemble/lens/javascript_typescript.py +82 -0
  16. codemble/lens/python.py +71 -0
  17. codemble/llm/__init__.py +6 -0
  18. codemble/llm/providers.py +137 -0
  19. codemble/llm/study.py +439 -0
  20. codemble/progress/__init__.py +9 -0
  21. codemble/progress/store.py +160 -0
  22. codemble/server/__init__.py +6 -0
  23. codemble/server/app.py +273 -0
  24. codemble/server/runtime.py +68 -0
  25. codemble/web_dist/assets/index-DOBVd_-M.css +1 -0
  26. codemble/web_dist/assets/index-cIG6GGIB.js +5238 -0
  27. codemble/web_dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  28. codemble/web_dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  29. codemble/web_dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  30. codemble/web_dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  31. codemble/web_dist/assets/shippori-mincho-latin-500-normal-C-QwvIb3.woff +0 -0
  32. codemble/web_dist/assets/shippori-mincho-latin-500-normal-XI1O8euf.woff2 +0 -0
  33. codemble/web_dist/assets/shippori-mincho-latin-700-normal-CkoCYOiI.woff +0 -0
  34. codemble/web_dist/assets/shippori-mincho-latin-700-normal-DHcmzUO5.woff2 +0 -0
  35. codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-BEdayliK.woff2 +0 -0
  36. codemble/web_dist/assets/zen-kaku-gothic-new-latin-400-normal-CPSmNJAU.woff +0 -0
  37. codemble/web_dist/index.html +18 -0
  38. codemble-0.3.0.dist-info/METADATA +417 -0
  39. codemble-0.3.0.dist-info/RECORD +43 -0
  40. codemble-0.3.0.dist-info/WHEEL +4 -0
  41. codemble-0.3.0.dist-info/entry_points.txt +2 -0
  42. codemble-0.3.0.dist-info/licenses/LICENSE +202 -0
  43. codemble-0.3.0.dist-info/licenses/NOTICE +2 -0
codemble/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Codemble — a learning game that turns the code AI wrote for you into a galaxy
2
+ you light up by understanding it. See CLAUDE.md for the agent brief and roadmap."""
3
+
4
+ __version__ = "0.3.0"
@@ -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
+ ]