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