codecrate 0.1.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.
- codecrate/__init__.py +0 -0
- codecrate/_version.py +34 -0
- codecrate/cli.py +250 -0
- codecrate/config.py +98 -0
- codecrate/diffgen.py +110 -0
- codecrate/discover.py +113 -0
- codecrate/ids.py +17 -0
- codecrate/manifest.py +31 -0
- codecrate/markdown.py +457 -0
- codecrate/mdparse.py +145 -0
- codecrate/model.py +51 -0
- codecrate/packer.py +108 -0
- codecrate/parse.py +133 -0
- codecrate/stubber.py +82 -0
- codecrate/token_budget.py +388 -0
- codecrate/udiff.py +187 -0
- codecrate/unpacker.py +149 -0
- codecrate/validate.py +120 -0
- codecrate-0.1.0.dist-info/METADATA +357 -0
- codecrate-0.1.0.dist-info/RECORD +24 -0
- codecrate-0.1.0.dist-info/WHEEL +5 -0
- codecrate-0.1.0.dist-info/entry_points.txt +2 -0
- codecrate-0.1.0.dist-info/licenses/LICENSE +21 -0
- codecrate-0.1.0.dist-info/top_level.txt +1 -0
codecrate/markdown.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .manifest import to_manifest
|
|
8
|
+
from .model import ClassRef, PackResult
|
|
9
|
+
from .parse import parse_symbols
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _anchor_for(defn_id: str, module: str, qualname: str) -> str:
|
|
13
|
+
# Anchors should be stable under dedupe: multiple defs can share the same
|
|
14
|
+
# canonical id, so we anchor by id only.
|
|
15
|
+
base = f"func-{defn_id}".lower()
|
|
16
|
+
safe = "".join(ch if ch.isalnum() else "-" for ch in base)
|
|
17
|
+
while "--" in safe:
|
|
18
|
+
safe = safe.replace("--", "-")
|
|
19
|
+
return safe.strip("-")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _file_anchor(rel_path: str) -> str:
|
|
23
|
+
base = f"file-{rel_path}".lower()
|
|
24
|
+
safe = "".join(ch if ch.isalnum() else "-" for ch in base)
|
|
25
|
+
while "--" in safe:
|
|
26
|
+
safe = safe.replace("--", "-")
|
|
27
|
+
return safe.strip("-")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _file_src_anchor(rel_path: str) -> str:
|
|
31
|
+
# Separate anchor namespace from _file_anchor(): index vs file content.
|
|
32
|
+
base = f"src-{rel_path}".lower()
|
|
33
|
+
safe = "".join(ch if ch.isalnum() else "-" for ch in base)
|
|
34
|
+
while "--" in safe:
|
|
35
|
+
safe = safe.replace("--", "-")
|
|
36
|
+
return safe.strip("-")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _file_range(line_count: int) -> str:
|
|
40
|
+
return "(empty)" if line_count == 0 else f"(L1-{line_count})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ensure_nl(s: str) -> str:
|
|
44
|
+
return s if (not s or s.endswith("\n")) else (s + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fence_lang_for(rel_path: str) -> str:
|
|
48
|
+
ext = rel_path.rsplit(".", 1)[-1].lower() if "." in rel_path else ""
|
|
49
|
+
return {
|
|
50
|
+
"py": "python",
|
|
51
|
+
"toml": "toml",
|
|
52
|
+
"rst": "rst",
|
|
53
|
+
"md": "markdown",
|
|
54
|
+
"txt": "text",
|
|
55
|
+
"ini": "ini",
|
|
56
|
+
"cfg": "ini",
|
|
57
|
+
"yaml": "yaml",
|
|
58
|
+
"yml": "yaml",
|
|
59
|
+
"json": "json",
|
|
60
|
+
}.get(ext, "text")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _range_token(kind: str, key: str) -> str:
|
|
64
|
+
return f"<<CC:{kind}:{key}>>"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_range(start: int | None, end: int | None) -> str:
|
|
68
|
+
if start is None or end is None or start > end:
|
|
69
|
+
return "(empty)"
|
|
70
|
+
return f"(L{start}-{end})"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_rel_path(line: str) -> str | None:
|
|
74
|
+
if not line.startswith("### `"):
|
|
75
|
+
return None
|
|
76
|
+
start = line.find("`") + 1
|
|
77
|
+
end = line.find("`", start)
|
|
78
|
+
if start <= 0 or end <= start:
|
|
79
|
+
return None
|
|
80
|
+
return line[start:end]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _scan_file_blocks(lines: list[str]) -> dict[str, tuple[int, int] | None]:
|
|
84
|
+
ranges: dict[str, tuple[int, int] | None] = {}
|
|
85
|
+
in_files = False
|
|
86
|
+
i = 0
|
|
87
|
+
while i < len(lines):
|
|
88
|
+
line = lines[i]
|
|
89
|
+
if line.strip() == "## Files":
|
|
90
|
+
in_files = True
|
|
91
|
+
i += 1
|
|
92
|
+
continue
|
|
93
|
+
if in_files and line.startswith("## ") and line.strip() != "## Files":
|
|
94
|
+
break
|
|
95
|
+
if in_files and line.startswith("### `"):
|
|
96
|
+
rel = _extract_rel_path(line)
|
|
97
|
+
if rel is None:
|
|
98
|
+
i += 1
|
|
99
|
+
continue
|
|
100
|
+
j = i + 1
|
|
101
|
+
while j < len(lines) and not (
|
|
102
|
+
lines[j].strip().startswith("```") and lines[j].strip() != "```"
|
|
103
|
+
):
|
|
104
|
+
j += 1
|
|
105
|
+
if j >= len(lines):
|
|
106
|
+
ranges[rel] = None
|
|
107
|
+
i = j
|
|
108
|
+
continue
|
|
109
|
+
start_line = j + 2
|
|
110
|
+
k = j + 1
|
|
111
|
+
while k < len(lines) and lines[k].strip() != "```":
|
|
112
|
+
k += 1
|
|
113
|
+
end_line = k
|
|
114
|
+
if start_line > end_line:
|
|
115
|
+
ranges[rel] = None
|
|
116
|
+
else:
|
|
117
|
+
ranges[rel] = (start_line, end_line)
|
|
118
|
+
i = k + 1
|
|
119
|
+
continue
|
|
120
|
+
i += 1
|
|
121
|
+
return ranges
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _scan_function_library(lines: list[str]) -> dict[str, tuple[int, int] | None]:
|
|
125
|
+
ranges: dict[str, tuple[int, int] | None] = {}
|
|
126
|
+
in_lib = False
|
|
127
|
+
i = 0
|
|
128
|
+
while i < len(lines):
|
|
129
|
+
line = lines[i]
|
|
130
|
+
if line.strip() == "## Function Library":
|
|
131
|
+
in_lib = True
|
|
132
|
+
i += 1
|
|
133
|
+
continue
|
|
134
|
+
if in_lib and line.startswith("## ") and line.strip() != "## Function Library":
|
|
135
|
+
break
|
|
136
|
+
if in_lib and line.startswith("### "):
|
|
137
|
+
defn_id = line.replace("###", "").strip()
|
|
138
|
+
j = i + 1
|
|
139
|
+
while j < len(lines) and lines[j].strip() != "```python":
|
|
140
|
+
j += 1
|
|
141
|
+
if j >= len(lines):
|
|
142
|
+
i += 1
|
|
143
|
+
continue
|
|
144
|
+
start_line = j + 2
|
|
145
|
+
k = j + 1
|
|
146
|
+
while k < len(lines) and lines[k].strip() != "```":
|
|
147
|
+
k += 1
|
|
148
|
+
end_line = k
|
|
149
|
+
if start_line > end_line:
|
|
150
|
+
ranges[defn_id] = None
|
|
151
|
+
else:
|
|
152
|
+
ranges[defn_id] = (start_line, end_line)
|
|
153
|
+
i = k + 1
|
|
154
|
+
continue
|
|
155
|
+
i += 1
|
|
156
|
+
return ranges
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _apply_context_line_numbers(
|
|
160
|
+
text: str,
|
|
161
|
+
def_line_map: dict[str, tuple[int, int]],
|
|
162
|
+
class_line_map: dict[str, tuple[int, int]],
|
|
163
|
+
def_to_canon: dict[str, str],
|
|
164
|
+
def_to_file: dict[str, str],
|
|
165
|
+
class_to_file: dict[str, str],
|
|
166
|
+
use_stubs: bool,
|
|
167
|
+
) -> str:
|
|
168
|
+
lines = text.splitlines()
|
|
169
|
+
file_ranges = _scan_file_blocks(lines)
|
|
170
|
+
func_ranges = _scan_function_library(lines) if use_stubs else {}
|
|
171
|
+
|
|
172
|
+
replacements: dict[str, str] = {}
|
|
173
|
+
for rel, rng in file_ranges.items():
|
|
174
|
+
token = _range_token("FILE", rel)
|
|
175
|
+
if rng is None:
|
|
176
|
+
replacements[token] = "(empty)"
|
|
177
|
+
else:
|
|
178
|
+
replacements[token] = _format_range(rng[0], rng[1])
|
|
179
|
+
|
|
180
|
+
for class_id, loc in class_line_map.items():
|
|
181
|
+
rel = class_to_file.get(class_id)
|
|
182
|
+
token = _range_token("CLASS", class_id)
|
|
183
|
+
file_range = file_ranges.get(rel) if rel else None
|
|
184
|
+
if file_range is None:
|
|
185
|
+
replacements[token] = _format_range(None, None)
|
|
186
|
+
continue
|
|
187
|
+
start = file_range[0] + loc[0] - 1
|
|
188
|
+
end = file_range[0] + loc[1] - 1
|
|
189
|
+
replacements[token] = _format_range(start, end)
|
|
190
|
+
|
|
191
|
+
for local_id, loc in def_line_map.items():
|
|
192
|
+
token = _range_token("DEF", local_id)
|
|
193
|
+
if use_stubs:
|
|
194
|
+
canon_id = def_to_canon.get(local_id)
|
|
195
|
+
canon_range = func_ranges.get(canon_id) if canon_id else None
|
|
196
|
+
if canon_range is not None:
|
|
197
|
+
replacements[token] = _format_range(canon_range[0], canon_range[1])
|
|
198
|
+
continue
|
|
199
|
+
rel = def_to_file.get(local_id)
|
|
200
|
+
file_range = file_ranges.get(rel) if rel else None
|
|
201
|
+
if file_range is None:
|
|
202
|
+
replacements[token] = _format_range(None, None)
|
|
203
|
+
continue
|
|
204
|
+
start = file_range[0] + loc[0] - 1
|
|
205
|
+
end = file_range[0] + loc[1] - 1
|
|
206
|
+
replacements[token] = _format_range(start, end)
|
|
207
|
+
|
|
208
|
+
for token, value in replacements.items():
|
|
209
|
+
text = text.replace(token, value)
|
|
210
|
+
return text
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _has_dedupe_effect(pack: PackResult) -> bool:
|
|
214
|
+
"""
|
|
215
|
+
True iff at least one definition has local_id != id (meaning dedupe actually
|
|
216
|
+
collapsed identical bodies and rewrote canonical ids).
|
|
217
|
+
"""
|
|
218
|
+
for fp in pack.files:
|
|
219
|
+
for d in fp.defs:
|
|
220
|
+
local_id = getattr(d, "local_id", d.id)
|
|
221
|
+
if local_id != d.id:
|
|
222
|
+
return True
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _read_full_text(fp) -> str:
|
|
227
|
+
"""Return the packed file contents."""
|
|
228
|
+
return fp.original_text
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _render_tree(paths: list[str]) -> str:
|
|
232
|
+
root: dict[str, Any] = {}
|
|
233
|
+
for p in paths:
|
|
234
|
+
cur = root
|
|
235
|
+
parts = [x for x in p.split("/") if x]
|
|
236
|
+
for part in parts[:-1]:
|
|
237
|
+
child = cur.setdefault(part, {})
|
|
238
|
+
cur = child if isinstance(child, dict) else {}
|
|
239
|
+
cur.setdefault(parts[-1], None)
|
|
240
|
+
|
|
241
|
+
def walk(node: dict[str, Any], prefix: str = "") -> list[str]:
|
|
242
|
+
items = sorted(node.items(), key=lambda kv: (kv[1] is None, kv[0].lower()))
|
|
243
|
+
out: list[str] = []
|
|
244
|
+
for i, (name, child) in enumerate(items):
|
|
245
|
+
last = i == len(items) - 1
|
|
246
|
+
branch = "└─ " if last else "├─ "
|
|
247
|
+
out.append(prefix + branch + name)
|
|
248
|
+
if isinstance(child, dict):
|
|
249
|
+
ext = " " if last else "│ "
|
|
250
|
+
out.extend(walk(child, prefix + ext))
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
return "\n".join(walk(root))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def render_markdown( # noqa: C901
|
|
257
|
+
pack: PackResult,
|
|
258
|
+
canonical_sources: dict[str, str],
|
|
259
|
+
layout: str = "auto",
|
|
260
|
+
*,
|
|
261
|
+
include_manifest: bool = True,
|
|
262
|
+
) -> str:
|
|
263
|
+
lines: list[str] = []
|
|
264
|
+
lines.append("# Codecrate Context Pack\n\n")
|
|
265
|
+
# Do not leak absolute local paths; keep the header root stable + relative.
|
|
266
|
+
lines.append("Root: `.`\n\n")
|
|
267
|
+
layout_norm = (layout or "auto").strip().lower()
|
|
268
|
+
if layout_norm not in {"auto", "stubs", "full"}:
|
|
269
|
+
layout_norm = "auto"
|
|
270
|
+
use_stubs = layout_norm == "stubs" or (
|
|
271
|
+
layout_norm == "auto" and _has_dedupe_effect(pack)
|
|
272
|
+
)
|
|
273
|
+
resolved_layout = "stubs" if use_stubs else "full"
|
|
274
|
+
lines.append(f"Layout: `{resolved_layout}`\n\n")
|
|
275
|
+
|
|
276
|
+
def_line_map: dict[str, tuple[int, int]] = {}
|
|
277
|
+
class_line_map: dict[str, tuple[int, int]] = {}
|
|
278
|
+
def_to_canon: dict[str, str] = {}
|
|
279
|
+
def_to_file: dict[str, str] = {}
|
|
280
|
+
class_to_file: dict[str, str] = {}
|
|
281
|
+
|
|
282
|
+
for fp in pack.files:
|
|
283
|
+
rel = fp.path.relative_to(pack.root).as_posix()
|
|
284
|
+
for d in fp.defs:
|
|
285
|
+
def_line_map[d.local_id] = (d.def_line, d.end_line)
|
|
286
|
+
def_to_canon[d.local_id] = d.id
|
|
287
|
+
def_to_file[d.local_id] = rel
|
|
288
|
+
for c in fp.classes:
|
|
289
|
+
class_to_file[c.id] = rel
|
|
290
|
+
|
|
291
|
+
if use_stubs:
|
|
292
|
+
for fp in pack.files:
|
|
293
|
+
by_qualname: dict[str, list[ClassRef]] = defaultdict(list)
|
|
294
|
+
try:
|
|
295
|
+
parsed_classes, _ = parse_symbols(
|
|
296
|
+
path=fp.path, root=pack.root, text=fp.stubbed_text
|
|
297
|
+
)
|
|
298
|
+
except SyntaxError:
|
|
299
|
+
parsed_classes = []
|
|
300
|
+
for c in parsed_classes:
|
|
301
|
+
by_qualname[c.qualname].append(c)
|
|
302
|
+
for c in sorted(fp.classes, key=lambda x: (x.class_line, x.qualname)):
|
|
303
|
+
matches = by_qualname.get(c.qualname)
|
|
304
|
+
if matches:
|
|
305
|
+
match = matches.pop(0)
|
|
306
|
+
class_line_map[c.id] = (match.class_line, match.end_line)
|
|
307
|
+
else:
|
|
308
|
+
class_line_map[c.id] = (c.class_line, c.end_line)
|
|
309
|
+
else:
|
|
310
|
+
for fp in pack.files:
|
|
311
|
+
for c in fp.classes:
|
|
312
|
+
class_line_map[c.id] = (c.class_line, c.end_line)
|
|
313
|
+
|
|
314
|
+
lines.append("## How to Use This Pack\n\n")
|
|
315
|
+
if use_stubs:
|
|
316
|
+
lines.append(
|
|
317
|
+
"This Markdown is a self-contained *context pack* for an LLM. It\n"
|
|
318
|
+
"contains the repository structure, a symbol index, full canonical\n"
|
|
319
|
+
"definitions, and compact file stubs. Use it like this:\n\n"
|
|
320
|
+
"**Suggested read order**\n"
|
|
321
|
+
"1. **Directory Tree**: get a mental map of the project.\n"
|
|
322
|
+
"2. **Symbol Index**: find the file / symbol you care about (with\n"
|
|
323
|
+
" jump links).\n"
|
|
324
|
+
"3. **Function Library**: read the full implementation of a\n"
|
|
325
|
+
" function by ID.\n"
|
|
326
|
+
"4. **Files**: read file-level context; function bodies may be\n"
|
|
327
|
+
" stubbed.\n\n"
|
|
328
|
+
"**Stubs and markers**\n"
|
|
329
|
+
"- In the **Files** section, function bodies may be replaced with\n"
|
|
330
|
+
" a compact placeholder line like `... # ↪ FUNC:XXXXXXXX`.\n"
|
|
331
|
+
"- The 8-hex value after `FUNC:` is the function's **local_id**\n"
|
|
332
|
+
" (unique per occurrence in the repo).\n\n"
|
|
333
|
+
"**IDs (important for dedupe)**\n"
|
|
334
|
+
"- `id` is the **canonical** ID for a function body (deduped when\n"
|
|
335
|
+
" configured).\n"
|
|
336
|
+
"- `local_id` is unique per definition occurrence. Multiple defs\n"
|
|
337
|
+
" can share the same `id` but must have different `local_id`.\n\n"
|
|
338
|
+
"**When proposing changes**\n"
|
|
339
|
+
"- Reference changes by **file path** plus **function ID** (and\n"
|
|
340
|
+
" local_id if shown).\n"
|
|
341
|
+
"- Prefer emitting a unified diff patch (`--- a/...` / `+++ b/...`).\n\n"
|
|
342
|
+
"**Line numbers**\n"
|
|
343
|
+
"- All `Lx-y` ranges refer to line numbers in this markdown file.\n"
|
|
344
|
+
"- File ranges/classes point into **Files**; function ranges point\n"
|
|
345
|
+
" into **Function Library**.\n\n"
|
|
346
|
+
)
|
|
347
|
+
else:
|
|
348
|
+
lines.append(
|
|
349
|
+
"This Markdown is a self-contained *context pack* for an LLM.\n\n"
|
|
350
|
+
"**Suggested read order**\n"
|
|
351
|
+
"1. **Directory Tree**\n"
|
|
352
|
+
"2. **Symbol Index** (jump to file contents)\n"
|
|
353
|
+
"3. **Files** (full contents)\n\n"
|
|
354
|
+
"**When proposing changes**\n"
|
|
355
|
+
"- Prefer unified diffs (`--- a/...` / `+++ b/...`).\n\n"
|
|
356
|
+
"**Line numbers**\n"
|
|
357
|
+
"- `Lx-y` ranges refer to line numbers in this markdown file (the\n"
|
|
358
|
+
" code blocks under **Files**).\n\n"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if include_manifest:
|
|
362
|
+
lines.append("## Manifest\n\n")
|
|
363
|
+
lines.append("```codecrate-manifest\n")
|
|
364
|
+
lines.append(
|
|
365
|
+
json.dumps(
|
|
366
|
+
to_manifest(pack, minimal=not use_stubs), indent=2, sort_keys=False
|
|
367
|
+
)
|
|
368
|
+
+ "\n"
|
|
369
|
+
)
|
|
370
|
+
lines.append("```\n\n")
|
|
371
|
+
|
|
372
|
+
rel_paths = [f.path.relative_to(pack.root).as_posix() for f in pack.files]
|
|
373
|
+
lines.append("## Directory Tree\n\n")
|
|
374
|
+
lines.append("```text\n")
|
|
375
|
+
lines.append(_render_tree(rel_paths) + "\n")
|
|
376
|
+
lines.append("```\n\n")
|
|
377
|
+
|
|
378
|
+
lines.append("## Symbol Index\n\n")
|
|
379
|
+
|
|
380
|
+
for fp in sorted(pack.files, key=lambda x: x.path.as_posix()):
|
|
381
|
+
rel = fp.path.relative_to(pack.root).as_posix()
|
|
382
|
+
fa = _file_anchor(rel)
|
|
383
|
+
sa = _file_src_anchor(rel)
|
|
384
|
+
file_range = _range_token("FILE", rel)
|
|
385
|
+
# Always provide a jump target to the file contents.
|
|
386
|
+
lines.append(f"### `{rel}` {file_range} — [jump](#{sa})\n")
|
|
387
|
+
lines.append(f'<a id="{fa}"></a>\n')
|
|
388
|
+
|
|
389
|
+
for c in sorted(fp.classes, key=lambda x: (x.class_line, x.qualname)):
|
|
390
|
+
class_loc = _range_token("CLASS", c.id)
|
|
391
|
+
lines.append(f"- `class {c.qualname}` {class_loc}\n")
|
|
392
|
+
|
|
393
|
+
for d in sorted(fp.defs, key=lambda d: (d.def_line, d.qualname)):
|
|
394
|
+
loc = _range_token("DEF", d.local_id)
|
|
395
|
+
link = "\n"
|
|
396
|
+
if use_stubs:
|
|
397
|
+
anchor = _anchor_for(d.id, d.module, d.qualname)
|
|
398
|
+
link = f" — [jump](#{anchor})\n"
|
|
399
|
+
id_display = f"**{d.id}**"
|
|
400
|
+
if getattr(d, "local_id", d.id) != d.id:
|
|
401
|
+
id_display += f" (local **{d.local_id}**)"
|
|
402
|
+
lines.append(f"- `{d.qualname}` → {id_display} {loc}{link}")
|
|
403
|
+
else:
|
|
404
|
+
lines.append(f"- `{d.qualname}` → {loc}\n")
|
|
405
|
+
lines.append("\n")
|
|
406
|
+
|
|
407
|
+
if use_stubs:
|
|
408
|
+
lines.append("## Function Library\n\n")
|
|
409
|
+
for defn_id, code in canonical_sources.items():
|
|
410
|
+
lines.append(f'<a id="{_anchor_for(defn_id, "", "")}"></a>\n')
|
|
411
|
+
lines.append(f"### {defn_id}\n")
|
|
412
|
+
lines.append("```python\n")
|
|
413
|
+
lines.append(_ensure_nl(code))
|
|
414
|
+
lines.append("```\n\n")
|
|
415
|
+
|
|
416
|
+
lines.append("## Files\n\n")
|
|
417
|
+
for fp in pack.files:
|
|
418
|
+
rel = fp.path.relative_to(pack.root).as_posix()
|
|
419
|
+
fa = _file_anchor(rel)
|
|
420
|
+
sa = _file_src_anchor(rel)
|
|
421
|
+
file_range = _range_token("FILE", rel)
|
|
422
|
+
lines.append(f"### `{rel}` {file_range}\n")
|
|
423
|
+
lines.append(f'<a id="{sa}"></a>\n')
|
|
424
|
+
lines.append(f"[jump to index](#{fa})\n\n")
|
|
425
|
+
|
|
426
|
+
# Compact stubs are not line-count aligned, so render as a single block.
|
|
427
|
+
|
|
428
|
+
lines.append(f"```{_fence_lang_for(rel)}\n")
|
|
429
|
+
if use_stubs:
|
|
430
|
+
lines.append(_ensure_nl(fp.stubbed_text))
|
|
431
|
+
else:
|
|
432
|
+
lines.append(_ensure_nl(_read_full_text(fp)))
|
|
433
|
+
lines.append("```\n\n")
|
|
434
|
+
# Only emit the Symbols block when there are actually symbols.
|
|
435
|
+
if use_stubs and fp.defs:
|
|
436
|
+
lines.append("**Symbols**\n\n")
|
|
437
|
+
if fp.module:
|
|
438
|
+
lines.append(f"_Module_: `{fp.module}`\n\n")
|
|
439
|
+
for d in sorted(fp.defs, key=lambda x: (x.def_line, x.qualname)):
|
|
440
|
+
anchor = _anchor_for(d.id, d.module, d.qualname)
|
|
441
|
+
loc = _range_token("DEF", d.local_id)
|
|
442
|
+
link = f" — [jump](#{anchor})\n"
|
|
443
|
+
id_display = f"**{d.id}**"
|
|
444
|
+
if getattr(d, "local_id", d.id) != d.id:
|
|
445
|
+
id_display += f" (local **{d.local_id}**)"
|
|
446
|
+
lines.append(f"- `{d.qualname}` → {id_display} {loc}{link}")
|
|
447
|
+
lines.append("\n")
|
|
448
|
+
text = "".join(lines)
|
|
449
|
+
return _apply_context_line_numbers(
|
|
450
|
+
text,
|
|
451
|
+
def_line_map=def_line_map,
|
|
452
|
+
class_line_map=class_line_map,
|
|
453
|
+
def_to_canon=def_to_canon,
|
|
454
|
+
def_to_file=def_to_file,
|
|
455
|
+
class_to_file=class_to_file,
|
|
456
|
+
use_stubs=use_stubs,
|
|
457
|
+
)
|
codecrate/mdparse.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
_CODE_FENCE_RE = re.compile(r"^```([a-zA-Z0-9_-]+)\s*$")
|
|
8
|
+
_FENCE_END_RE = re.compile(r"^```\s*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PackedMarkdown:
|
|
13
|
+
manifest: dict
|
|
14
|
+
canonical_sources: dict[str, str] # id -> python code
|
|
15
|
+
# NOTE: we don't strictly need stubbed files for unpack, but helpful for debugging
|
|
16
|
+
stubbed_files: dict[str, str] # rel path -> code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _iter_fenced_blocks(lines: list[str]):
|
|
20
|
+
i = 0
|
|
21
|
+
while i < len(lines):
|
|
22
|
+
m = _CODE_FENCE_RE.match(lines[i])
|
|
23
|
+
if not m:
|
|
24
|
+
i += 1
|
|
25
|
+
continue
|
|
26
|
+
lang = m.group(1)
|
|
27
|
+
i += 1
|
|
28
|
+
buf = []
|
|
29
|
+
while i < len(lines) and not _FENCE_END_RE.match(lines[i]):
|
|
30
|
+
buf.append(lines[i])
|
|
31
|
+
i += 1
|
|
32
|
+
if i < len(lines) and _FENCE_END_RE.match(lines[i]):
|
|
33
|
+
i += 1
|
|
34
|
+
yield lang, "".join(buf)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _section_bounds(title: str, text_lines: list[str]) -> tuple[int, int]:
|
|
38
|
+
start = None
|
|
39
|
+
for i, ln in enumerate(text_lines):
|
|
40
|
+
if ln.strip() == title:
|
|
41
|
+
start = i + 1
|
|
42
|
+
break
|
|
43
|
+
if start is None:
|
|
44
|
+
return (0, len(text_lines))
|
|
45
|
+
end = len(text_lines)
|
|
46
|
+
for j in range(start, len(text_lines)):
|
|
47
|
+
if text_lines[j].startswith("## ") and text_lines[j].strip() != title:
|
|
48
|
+
end = j
|
|
49
|
+
break
|
|
50
|
+
return (start, end)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_function_library(text_lines: list[str]) -> dict[str, str]:
|
|
54
|
+
canonical_sources: dict[str, str] = {}
|
|
55
|
+
fl_start, fl_end = _section_bounds("## Function Library", text_lines)
|
|
56
|
+
for idx in range(fl_start, fl_end):
|
|
57
|
+
line = text_lines[idx]
|
|
58
|
+
if not line.startswith("### "):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Support both header styles:
|
|
62
|
+
# - v4 current: "### <ID>"
|
|
63
|
+
# - older: "### <ID> — <extra metadata>"
|
|
64
|
+
title = line.replace("###", "", 1).strip()
|
|
65
|
+
maybe_id = title.split(" — ", 1)[0].strip()
|
|
66
|
+
if not maybe_id:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
j = idx + 1
|
|
70
|
+
while j < fl_end and text_lines[j].strip() != "```python":
|
|
71
|
+
j += 1
|
|
72
|
+
if j < fl_end and text_lines[j].strip() == "```python":
|
|
73
|
+
k = j + 1
|
|
74
|
+
buf: list[str] = []
|
|
75
|
+
while k < fl_end and text_lines[k].strip() != "```":
|
|
76
|
+
buf.append(text_lines[k])
|
|
77
|
+
k += 1
|
|
78
|
+
if buf:
|
|
79
|
+
canonical_sources[maybe_id] = "\n".join(buf).rstrip() + "\n"
|
|
80
|
+
return canonical_sources
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_stubbed_files(text_lines: list[str]) -> dict[str, str]:
|
|
84
|
+
stubbed_files: dict[str, str] = {}
|
|
85
|
+
fs_start, fs_end = _section_bounds("## Files", text_lines)
|
|
86
|
+
i = fs_start
|
|
87
|
+
while i < fs_end:
|
|
88
|
+
line = text_lines[i]
|
|
89
|
+
if line.startswith("### `") and "`" in line:
|
|
90
|
+
start = line.find("`") + 1
|
|
91
|
+
end = line.find("`", start)
|
|
92
|
+
rel = line[start:end]
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
j = i + 1
|
|
95
|
+
while j < fs_end and not (
|
|
96
|
+
text_lines[j].startswith("### `") and "`" in text_lines[j]
|
|
97
|
+
):
|
|
98
|
+
fence = text_lines[j].strip()
|
|
99
|
+
if fence.startswith("```") and fence != "```":
|
|
100
|
+
k = j + 1
|
|
101
|
+
buf: list[str] = []
|
|
102
|
+
while k < fs_end and text_lines[k].strip() != "```":
|
|
103
|
+
buf.append(text_lines[k])
|
|
104
|
+
k += 1
|
|
105
|
+
if buf:
|
|
106
|
+
chunk = "\n".join(buf)
|
|
107
|
+
if not chunk.endswith("\n"):
|
|
108
|
+
chunk += "\n"
|
|
109
|
+
parts.append(chunk)
|
|
110
|
+
j = k + 1
|
|
111
|
+
continue
|
|
112
|
+
j += 1
|
|
113
|
+
if parts:
|
|
114
|
+
stubbed_files[rel] = "".join(parts)
|
|
115
|
+
else:
|
|
116
|
+
stubbed_files[rel] = ""
|
|
117
|
+
i = j
|
|
118
|
+
continue
|
|
119
|
+
i += 1
|
|
120
|
+
return stubbed_files
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parse_packed_markdown(text: str) -> PackedMarkdown:
|
|
124
|
+
lines = text.splitlines(keepends=True)
|
|
125
|
+
manifest = None
|
|
126
|
+
for lang, body in _iter_fenced_blocks(lines):
|
|
127
|
+
if lang == "codecrate-manifest":
|
|
128
|
+
manifest = json.loads(body)
|
|
129
|
+
break
|
|
130
|
+
if manifest is None:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"No codecrate-manifest block found. This pack cannot be used for "
|
|
133
|
+
"unpack/patch/validate-pack; re-run `codecrate pack` with --manifest "
|
|
134
|
+
"(or omit --no-manifest)."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
text_lines = text.splitlines()
|
|
138
|
+
canonical_sources = _parse_function_library(text_lines)
|
|
139
|
+
stubbed_files = _parse_stubbed_files(text_lines)
|
|
140
|
+
|
|
141
|
+
return PackedMarkdown(
|
|
142
|
+
manifest=manifest,
|
|
143
|
+
canonical_sources=canonical_sources,
|
|
144
|
+
stubbed_files=stubbed_files,
|
|
145
|
+
)
|
codecrate/model.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class DefRef:
|
|
9
|
+
path: Path
|
|
10
|
+
module: str
|
|
11
|
+
qualname: str
|
|
12
|
+
id: str
|
|
13
|
+
local_id: str
|
|
14
|
+
kind: str
|
|
15
|
+
decorator_start: int
|
|
16
|
+
def_line: int
|
|
17
|
+
body_start: int
|
|
18
|
+
end_line: int
|
|
19
|
+
doc_start: int | None = None
|
|
20
|
+
doc_end: int | None = None
|
|
21
|
+
is_single_line: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ClassRef:
|
|
26
|
+
path: Path
|
|
27
|
+
module: str
|
|
28
|
+
qualname: str
|
|
29
|
+
id: str
|
|
30
|
+
decorator_start: int
|
|
31
|
+
class_line: int
|
|
32
|
+
end_line: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FilePack:
|
|
37
|
+
path: Path
|
|
38
|
+
module: str
|
|
39
|
+
original_text: str
|
|
40
|
+
stubbed_text: str
|
|
41
|
+
line_count: int
|
|
42
|
+
classes: list[ClassRef]
|
|
43
|
+
defs: list[DefRef]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class PackResult:
|
|
48
|
+
root: Path
|
|
49
|
+
files: list[FilePack]
|
|
50
|
+
classes: list[ClassRef]
|
|
51
|
+
defs: list[DefRef]
|