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
@@ -0,0 +1,19 @@
1
+ """Active checks generated FROM the graph; answers never come from the LLM."""
2
+
3
+ from codemble.checks.service import (
4
+ Check,
5
+ CheckKind,
6
+ CheckService,
7
+ InvalidCheckSubmission,
8
+ UnknownCheckError,
9
+ generate_checks,
10
+ )
11
+
12
+ __all__ = [
13
+ "Check",
14
+ "CheckKind",
15
+ "CheckService",
16
+ "InvalidCheckSubmission",
17
+ "UnknownCheckError",
18
+ "generate_checks",
19
+ ]
@@ -0,0 +1,380 @@
1
+ """Deterministic active checks whose answers come only from the graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from codemble.adapters.base import Edge, Graph, Node
10
+ from codemble.graph.layout import with_entrypoint
11
+ from codemble.progress import ProgressStore
12
+
13
+ CheckKind = Literal["first-call", "direct-importer", "removal-impact", "entrypoint"]
14
+
15
+ # Wrong options a check offers beyond its answers, when the graph holds that many.
16
+ _MINIMUM_DISTRACTORS = 2
17
+
18
+
19
+ class UnknownCheckError(KeyError):
20
+ """Raised for a region or check absent from the current graph."""
21
+
22
+
23
+ class InvalidCheckSubmission(ValueError):
24
+ """Raised when a submitted option was not offered by the check."""
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CheckOption:
29
+ id: str
30
+ label: str
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Check:
35
+ id: str
36
+ region_id: str
37
+ kind: CheckKind
38
+ prompt: str
39
+ options: tuple[CheckOption, ...]
40
+ answer_ids: tuple[str, ...]
41
+ evidence: tuple[str, ...]
42
+
43
+ def public(self, *, passed: bool) -> dict[str, object]:
44
+ """Serialize the question without exposing its graph-derived answer."""
45
+
46
+ return {
47
+ "id": self.id,
48
+ "kind": self.kind,
49
+ "prompt": self.prompt,
50
+ "multiple": len(self.answer_ids) > 1,
51
+ "options": [
52
+ {"id": option.id, "label": option.label} for option in self.options
53
+ ],
54
+ "passed": passed,
55
+ }
56
+
57
+
58
+ class CheckService:
59
+ """Generate, validate, and persist one graph-owned region check flow."""
60
+
61
+ def __init__(self, graph: Graph, progress: ProgressStore | None = None) -> None:
62
+ self._graph = graph
63
+ self._progress = progress or ProgressStore(graph)
64
+ self._checks = {
65
+ region.id: generate_checks(graph, region.id) for region in graph.regions
66
+ }
67
+ self._passed: dict[str, set[str]] = {}
68
+
69
+ def graph(self) -> Graph:
70
+ """Return render data hydrated from currently valid local progress."""
71
+
72
+ hydrated = self._progress.hydrated_graph()
73
+ return (
74
+ with_entrypoint(hydrated, self._graph.selected_entrypoint)
75
+ if self._graph.selected_entrypoint
76
+ else hydrated
77
+ )
78
+
79
+ def select_entrypoint(self, node_id: str) -> Graph:
80
+ """Apply an explicit parser-ranked Home choice to graph and check suites."""
81
+
82
+ self._graph = with_entrypoint(self._graph, node_id)
83
+ self._checks = {
84
+ region.id: generate_checks(self._graph, region.id)
85
+ for region in self._graph.regions
86
+ }
87
+ return self.graph()
88
+
89
+ def for_region(self, region_id: str) -> dict[str, object]:
90
+ """Return a region suite with answer values withheld."""
91
+
92
+ checks = self._region_checks(region_id)
93
+ understood = region_id in self._progress.understood_regions()
94
+ passed = self._passed.get(region_id, set())
95
+ return {
96
+ "region_id": region_id,
97
+ "region_understood": understood,
98
+ "checks": [
99
+ check.public(passed=understood or check.id in passed) for check in checks
100
+ ],
101
+ }
102
+
103
+ def submit(
104
+ self, region_id: str, check_id: str, selected_ids: list[str]
105
+ ) -> dict[str, object]:
106
+ """Score exact option IDs against the immutable generated answer."""
107
+
108
+ checks = self._region_checks(region_id)
109
+ check = next((candidate for candidate in checks if candidate.id == check_id), None)
110
+ if check is None:
111
+ raise UnknownCheckError(check_id)
112
+ offered = {option.id for option in check.options}
113
+ selected = set(selected_ids)
114
+ if not selected or not selected <= offered:
115
+ raise InvalidCheckSubmission("Select one or more offered answers.")
116
+
117
+ correct = selected == set(check.answer_ids)
118
+ if correct:
119
+ self._passed.setdefault(region_id, set()).add(check.id)
120
+ passed = self._passed.get(region_id, set())
121
+ complete = bool(checks) and all(candidate.id in passed for candidate in checks)
122
+ if complete:
123
+ self._progress.mark_understood(region_id)
124
+
125
+ answer_labels = [
126
+ option.label for option in check.options if option.id in check.answer_ids
127
+ ]
128
+ return {
129
+ "correct": correct,
130
+ "check_id": check.id,
131
+ "answer_ids": list(check.answer_ids),
132
+ "answer_labels": answer_labels,
133
+ "evidence": list(check.evidence),
134
+ "message": (
135
+ "Correct. That answer is fixed by the parser graph."
136
+ if correct
137
+ else "Not yet. Re-read the graph relationship and try again."
138
+ ),
139
+ "region_understood": complete,
140
+ }
141
+
142
+ def _region_checks(self, region_id: str) -> tuple[Check, ...]:
143
+ if region_id not in self._checks:
144
+ raise UnknownCheckError(region_id)
145
+ return self._checks[region_id]
146
+
147
+
148
+ def generate_checks(graph: Graph, region_id: str) -> tuple[Check, ...]:
149
+ """Build up to four stable questions from parser-owned evidence."""
150
+
151
+ if not any(region.id == region_id for region in graph.regions):
152
+ raise UnknownCheckError(region_id)
153
+ nodes = {node.id: node for node in graph.nodes}
154
+ checks: list[Check] = []
155
+
156
+ first_call = _first_call_check(graph, region_id, nodes)
157
+ if first_call:
158
+ checks.append(first_call)
159
+ importer = _importer_check(graph, region_id, nodes)
160
+ if importer:
161
+ checks.append(importer)
162
+ impact = _impact_check(graph, region_id, nodes)
163
+ if impact:
164
+ checks.append(impact)
165
+ entrypoint = _entrypoint_check(graph, region_id, nodes)
166
+ if entrypoint:
167
+ checks.append(entrypoint)
168
+ return tuple(check for check in checks if _proves_understanding(check))
169
+
170
+
171
+ def _proves_understanding(check: Check) -> bool:
172
+ """Reject a question every option answers; passing it would prove nothing."""
173
+
174
+ return bool({option.id for option in check.options} - set(check.answer_ids))
175
+
176
+
177
+ def _first_call_check(
178
+ graph: Graph, region_id: str, nodes: dict[str, Node]
179
+ ) -> Check | None:
180
+ calls_by_source: dict[str, list[Edge]] = {}
181
+ for edge in graph.edges:
182
+ source = nodes.get(edge.src)
183
+ if (
184
+ edge.kind == "call"
185
+ and edge.certain
186
+ and not edge.external
187
+ and source is not None
188
+ and source.region == region_id
189
+ and edge.dst in nodes
190
+ ):
191
+ calls_by_source.setdefault(edge.src, []).append(edge)
192
+ if not calls_by_source:
193
+ return None
194
+ source_id = sorted(calls_by_source)[0]
195
+ edge = min(calls_by_source[source_id], key=lambda item: (item.lineno, item.dst))
196
+ answers = (edge.dst,)
197
+ return _check(
198
+ graph,
199
+ region_id,
200
+ "first-call",
201
+ source_id,
202
+ f"Which structure does {source_id} call first?",
203
+ answers,
204
+ _node_options(nodes, answers, kind="function"),
205
+ (f"{nodes[source_id].file}:{edge.lineno}",),
206
+ )
207
+
208
+
209
+ def _importer_check(
210
+ graph: Graph, region_id: str, nodes: dict[str, Node]
211
+ ) -> Check | None:
212
+ incoming = sorted(
213
+ (
214
+ edge
215
+ for edge in graph.edges
216
+ if edge.kind == "import"
217
+ and edge.certain
218
+ and not edge.external
219
+ and edge.dst == region_id
220
+ and edge.src in nodes
221
+ ),
222
+ key=lambda edge: (edge.src, edge.lineno),
223
+ )
224
+ if incoming:
225
+ answers = tuple(sorted({edge.src for edge in incoming}))
226
+ return _check(
227
+ graph,
228
+ region_id,
229
+ "direct-importer",
230
+ region_id,
231
+ f"Which project module imports {region_id} directly?",
232
+ answers,
233
+ _node_options(nodes, answers, kind="module"),
234
+ tuple(f"{nodes[edge.src].file}:{edge.lineno}" for edge in incoming),
235
+ )
236
+
237
+ outgoing = sorted(
238
+ (
239
+ edge
240
+ for edge in graph.edges
241
+ if edge.kind == "import"
242
+ and edge.certain
243
+ and not edge.external
244
+ and edge.src == region_id
245
+ and edge.dst in nodes
246
+ ),
247
+ key=lambda edge: (edge.lineno, edge.dst),
248
+ )
249
+ if not outgoing:
250
+ return None
251
+ first = outgoing[0]
252
+ answers = (first.dst,)
253
+ return _check(
254
+ graph,
255
+ region_id,
256
+ "direct-importer",
257
+ region_id,
258
+ f"Which project module does {region_id} import first?",
259
+ answers,
260
+ _node_options(nodes, answers, kind="module"),
261
+ (f"{nodes[region_id].file}:{first.lineno}",),
262
+ )
263
+
264
+
265
+ def _impact_check(
266
+ graph: Graph, region_id: str, nodes: dict[str, Node]
267
+ ) -> Check | None:
268
+ callers_by_target: dict[str, list[Edge]] = {}
269
+ for edge in graph.edges:
270
+ target = nodes.get(edge.dst)
271
+ if (
272
+ edge.kind == "call"
273
+ and edge.certain
274
+ and not edge.external
275
+ and target is not None
276
+ and target.region == region_id
277
+ and edge.src in nodes
278
+ ):
279
+ callers_by_target.setdefault(edge.dst, []).append(edge)
280
+ if not callers_by_target:
281
+ return None
282
+ target_id = sorted(
283
+ callers_by_target,
284
+ key=lambda candidate: (-len(callers_by_target[candidate]), candidate),
285
+ )[0]
286
+ callers = sorted(callers_by_target[target_id], key=lambda edge: (edge.src, edge.lineno))
287
+ answers = tuple(sorted({edge.src for edge in callers}))
288
+ return _check(
289
+ graph,
290
+ region_id,
291
+ "removal-impact",
292
+ target_id,
293
+ f"Which structure directly depends on {target_id} and could break if it disappeared?",
294
+ answers,
295
+ _node_options(nodes, answers, kind="function"),
296
+ tuple(f"{nodes[edge.src].file}:{edge.lineno}" for edge in callers),
297
+ )
298
+
299
+
300
+ def _entrypoint_check(
301
+ graph: Graph, region_id: str, nodes: dict[str, Node]
302
+ ) -> Check | None:
303
+ ranked = [candidate for candidate in graph.entrypoint_candidates if candidate in nodes]
304
+ selected = graph.selected_entrypoint
305
+ if not ranked or selected is None or nodes[selected].region != region_id:
306
+ return None
307
+ answers = (selected,)
308
+ pool = ranked + [node.id for node in graph.nodes if node.id not in ranked]
309
+ return _check(
310
+ graph,
311
+ region_id,
312
+ "entrypoint",
313
+ selected,
314
+ "Which parser-ranked structure is selected as Home for this run?",
315
+ answers,
316
+ _options(nodes, answers, pool),
317
+ (f"{nodes[selected].file}:{nodes[selected].lineno}",),
318
+ )
319
+
320
+
321
+ def _node_options(
322
+ nodes: dict[str, Node], answers: tuple[str, ...], *, kind: str
323
+ ) -> tuple[CheckOption, ...]:
324
+ pool = [node.id for node in nodes.values() if node.kind == kind]
325
+ if len(set(pool) | set(answers)) < 2:
326
+ pool = list(nodes)
327
+ return _options(nodes, answers, pool)
328
+
329
+
330
+ def _options(
331
+ nodes: dict[str, Node], answers: tuple[str, ...], pool: list[str]
332
+ ) -> tuple[CheckOption, ...]:
333
+ """Offer every answer plus wrong options, or nothing if none exist.
334
+
335
+ The ceiling must clear ``len(answers)``; capping at four alone offered a
336
+ multi-answer check no wrong option, so selecting everything always passed.
337
+ """
338
+
339
+ candidates = list(answers)
340
+ ceiling = max(4, len(answers) + _MINIMUM_DISTRACTORS)
341
+ for candidate in sorted(set(pool)):
342
+ if candidate not in candidates and len(candidates) < ceiling:
343
+ candidates.append(candidate)
344
+ if len(candidates) == len(answers):
345
+ return ()
346
+ return tuple(CheckOption(candidate, nodes[candidate].id) for candidate in candidates)
347
+
348
+
349
+ def _check(
350
+ graph: Graph,
351
+ region_id: str,
352
+ kind: CheckKind,
353
+ subject: str,
354
+ prompt: str,
355
+ answers: tuple[str, ...],
356
+ options: tuple[CheckOption, ...],
357
+ evidence: tuple[str, ...],
358
+ ) -> Check:
359
+ check_id = hashlib.sha256(
360
+ f"{graph.schema_version}|{region_id}|{kind}|{subject}".encode()
361
+ ).hexdigest()[:16]
362
+ return Check(
363
+ id=check_id,
364
+ region_id=region_id,
365
+ kind=kind,
366
+ prompt=prompt,
367
+ options=options,
368
+ answer_ids=answers,
369
+ evidence=evidence,
370
+ )
371
+
372
+
373
+ __all__ = [
374
+ "Check",
375
+ "CheckKind",
376
+ "CheckService",
377
+ "InvalidCheckSubmission",
378
+ "UnknownCheckError",
379
+ "generate_checks",
380
+ ]
codemble/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ """Codemble command-line entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Callable, Sequence
9
+
10
+ from codemble import __version__
11
+ from codemble.adapters.project import (
12
+ ProjectIntake,
13
+ ProjectParseError,
14
+ ProjectParser,
15
+ ProjectScaleError,
16
+ )
17
+ from codemble.server.runtime import serve_picker, serve_project
18
+
19
+ def _parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog="codemble",
22
+ description="Turn parser-proven project structure into Codemble graph data.",
23
+ )
24
+ parser.add_argument("--version", action="version", version=f"codemble {__version__}")
25
+ commands = parser.add_subparsers(dest="command")
26
+ parse_command = commands.add_parser("parse", help="parse a project into graph JSON")
27
+ parse_command.add_argument("path", type=Path, help="source file or project directory")
28
+ parse_command.add_argument(
29
+ "--out", required=True, type=Path, help="destination for deterministic graph JSON"
30
+ )
31
+ parse_command.add_argument(
32
+ "--entrypoint", help="parser-ranked node ID to mark as Home"
33
+ )
34
+ serve_command = commands.add_parser("serve", help=argparse.SUPPRESS)
35
+ serve_command.add_argument(
36
+ "path", nargs="?", default=None, type=Path, help="source file or project directory"
37
+ )
38
+ serve_command.add_argument(
39
+ "--path",
40
+ dest="scope_path",
41
+ type=Path,
42
+ help="explicit project or subdirectory scope; skips the large-project prompt",
43
+ )
44
+ serve_command.add_argument(
45
+ "--entrypoint", help="parser-ranked node ID to mark as Home"
46
+ )
47
+ serve_command.add_argument("--host", default="127.0.0.1")
48
+ serve_command.add_argument("--port", default=0, type=int)
49
+ serve_command.add_argument("--no-open", action="store_true", help="do not open a browser")
50
+ return parser
51
+
52
+
53
+ def main(argv: Sequence[str] | None = None) -> int:
54
+ """Run the CLI, returning a process exit status for testability."""
55
+
56
+ parser = _parser()
57
+ raw_arguments = list(argv) if argv is not None else list(sys.argv[1:])
58
+ if not raw_arguments or raw_arguments[0] not in {
59
+ "parse",
60
+ "serve",
61
+ "--version",
62
+ "-h",
63
+ "--help",
64
+ }:
65
+ raw_arguments.insert(0, "serve")
66
+ arguments = parser.parse_args(raw_arguments)
67
+
68
+ if arguments.command == "parse":
69
+ try:
70
+ graph = ProjectParser().parse(
71
+ arguments.path,
72
+ entrypoint=arguments.entrypoint,
73
+ explicit=True,
74
+ )
75
+ graph.write_json(arguments.out)
76
+ except (OSError, ProjectParseError) as error:
77
+ print(f"codemble: {error}", file=sys.stderr)
78
+ return 2
79
+ print(
80
+ f"Wrote {len(graph.nodes)} nodes and {len(graph.edges)} edges to "
81
+ f"{arguments.out}"
82
+ )
83
+ elif arguments.command == "serve":
84
+ try:
85
+ if arguments.path is None and arguments.scope_path is None:
86
+ serve_picker(
87
+ host=arguments.host,
88
+ port=arguments.port,
89
+ open_browser=not arguments.no_open,
90
+ entrypoint=arguments.entrypoint,
91
+ )
92
+ return 0
93
+ requested = arguments.scope_path or arguments.path
94
+ selected_path = choose_project_scope(
95
+ requested,
96
+ explicit=arguments.scope_path is not None,
97
+ interactive=sys.stdin.isatty(),
98
+ )
99
+ serve_project(
100
+ selected_path,
101
+ host=arguments.host,
102
+ port=arguments.port,
103
+ open_browser=not arguments.no_open,
104
+ entrypoint=arguments.entrypoint,
105
+ )
106
+ except (OSError, ProjectParseError) as error:
107
+ print(f"codemble: {error}", file=sys.stderr)
108
+ return 2
109
+ return 0
110
+
111
+
112
+ def choose_project_scope(
113
+ requested: Path,
114
+ *,
115
+ explicit: bool,
116
+ interactive: bool,
117
+ input_fn: Callable[[str], str] = input,
118
+ output_fn: Callable[[str], None] = print,
119
+ ) -> ProjectIntake:
120
+ """Require an intentional subdirectory for projects above the v1 scale cap."""
121
+
122
+ parser = ProjectParser()
123
+ try:
124
+ return parser.intake(requested, explicit=explicit)
125
+ except ProjectScaleError as error:
126
+ if not interactive:
127
+ raise
128
+ intake = error.intake
129
+ scale_cap = error.scale_cap
130
+
131
+ suggestions = ", ".join(
132
+ f"{directory} ({count})" for directory, count in intake.scope_counts()[:6]
133
+ )
134
+ output_fn(
135
+ f"Codemble found {len(intake.files)} supported source files; "
136
+ f"the limit is {scale_cap}."
137
+ )
138
+ if suggestions:
139
+ output_fn(f"Top scopes: {suggestions}")
140
+
141
+ while True:
142
+ answer = input_fn("Subdirectory to map (or q to quit): ").strip()
143
+ if answer.lower() in {"q", "quit"}:
144
+ raise ProjectParseError("project scope selection cancelled")
145
+ candidate = (intake.root / answer).expanduser().resolve()
146
+ if not candidate.is_relative_to(intake.root):
147
+ output_fn("Choose a subdirectory inside the project root.")
148
+ continue
149
+ try:
150
+ return parser.intake(candidate)
151
+ except ProjectScaleError as error:
152
+ output_fn(
153
+ f"That scope still has {len(error.intake.files)} supported source files; "
154
+ "choose a smaller one."
155
+ )
156
+ except ProjectParseError as error:
157
+ output_fn(str(error))
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ """Language-tagged graph helpers and render-ready metadata."""
2
+
3
+ from codemble.adapters.base import ConceptAnnotation, Edge, Graph, Node, Region, RegionEdge
4
+ from codemble.graph.finalize import GraphFinalizationError, finalize_graph
5
+ from codemble.graph.layout import layout_graph
6
+
7
+ __all__ = [
8
+ "ConceptAnnotation",
9
+ "Edge",
10
+ "Graph",
11
+ "GraphFinalizationError",
12
+ "Node",
13
+ "Region",
14
+ "RegionEdge",
15
+ "finalize_graph",
16
+ "layout_graph",
17
+ ]
@@ -0,0 +1,91 @@
1
+ """Canonical graph finalization shared by every language adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import replace
7
+
8
+ from codemble.adapters.base import ConceptAnnotation, Edge, Graph
9
+ from codemble.graph.layout import layout_graph
10
+
11
+
12
+ class GraphFinalizationError(ValueError):
13
+ """Parser evidence cannot be finalized into one honest graph."""
14
+
15
+
16
+ def finalize_graph(graph: Graph, *, entrypoint: str | None = None) -> Graph:
17
+ """Canonicalize parser evidence and return one render-ready graph."""
18
+
19
+ edges = tuple(sorted(set(graph.edges), key=_edge_key))
20
+ node_ids = {node.id for node in graph.nodes}
21
+ indegree = Counter(
22
+ edge.dst
23
+ for edge in edges
24
+ if edge.kind == "call" and not edge.external and edge.dst in node_ids
25
+ )
26
+ nodes = tuple(
27
+ sorted(
28
+ (replace(node, centrality=indegree[node.id]) for node in graph.nodes),
29
+ key=lambda node: node.id,
30
+ )
31
+ )
32
+ node_by_id = {node.id: node for node in nodes}
33
+ candidates = tuple(
34
+ node.id
35
+ for node in sorted(
36
+ (node for node in nodes if node.entrypoint_rank is not None),
37
+ key=lambda node: (node.entrypoint_rank, node.id), # type: ignore[arg-type]
38
+ )
39
+ )
40
+ if entrypoint is not None and entrypoint not in candidates:
41
+ choices = ", ".join(candidates) or "none"
42
+ raise GraphFinalizationError(
43
+ f"entrypoint is not parser-ranked: {entrypoint} (candidates: {choices})"
44
+ )
45
+ rank_zero = [
46
+ candidate
47
+ for candidate in candidates
48
+ if node_by_id[candidate].entrypoint_rank == 0
49
+ ]
50
+ selected_entrypoint = entrypoint or (rank_zero[0] if len(rank_zero) == 1 else None)
51
+ finalized = replace(
52
+ graph,
53
+ nodes=nodes,
54
+ edges=edges,
55
+ entrypoint_candidates=candidates,
56
+ file_hashes=dict(sorted(graph.file_hashes.items())),
57
+ selected_entrypoint=selected_entrypoint,
58
+ concept_annotations=tuple(
59
+ sorted(set(graph.concept_annotations), key=_annotation_key)
60
+ ),
61
+ regions=(),
62
+ region_edges=(),
63
+ partial_files=tuple(sorted(set(graph.partial_files))),
64
+ )
65
+ return layout_graph(finalized)
66
+
67
+
68
+ def _edge_key(edge: Edge) -> tuple[str, str, str, int, bool, bool]:
69
+ return (
70
+ edge.src,
71
+ edge.dst,
72
+ edge.kind,
73
+ edge.lineno,
74
+ edge.certain,
75
+ edge.external,
76
+ )
77
+
78
+
79
+ def _annotation_key(
80
+ annotation: ConceptAnnotation,
81
+ ) -> tuple[str, str, int, str, int]:
82
+ return (
83
+ annotation.language,
84
+ annotation.node_id,
85
+ annotation.lineno,
86
+ annotation.concept,
87
+ annotation.end_lineno,
88
+ )
89
+
90
+
91
+ __all__ = ["GraphFinalizationError", "finalize_graph"]