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 ADDED
File without changes
codecrate/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = None
codecrate/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .config import load_config
7
+ from .diffgen import generate_patch_markdown
8
+ from .discover import discover_files
9
+ from .markdown import render_markdown
10
+ from .packer import pack_repo
11
+ from .token_budget import split_by_max_chars
12
+ from .udiff import apply_file_diffs, parse_unified_diff
13
+ from .unpacker import unpack_to_dir
14
+ from .validate import validate_pack_markdown
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(
19
+ prog="codecrate",
20
+ description="Pack/unpack/patch/apply for repositories (Python + text files).",
21
+ )
22
+ sub = p.add_subparsers(dest="cmd", required=True)
23
+
24
+ # pack
25
+ pack = sub.add_parser("pack", help="Pack a repository/directory into Markdown.")
26
+ pack.add_argument("root", type=Path, help="Root directory to scan")
27
+ pack.add_argument(
28
+ "-o",
29
+ "--output",
30
+ type=Path,
31
+ default=None,
32
+ help="Output markdown path (default: config 'output' or context.md)",
33
+ )
34
+ pack.add_argument(
35
+ "--dedupe", action="store_true", help="Deduplicate identical function bodies"
36
+ )
37
+ pack.add_argument(
38
+ "--layout",
39
+ choices=["auto", "stubs", "full"],
40
+ default=None,
41
+ help="Output layout: auto|stubs|full (default: auto via config)",
42
+ )
43
+ pack.add_argument(
44
+ "--keep-docstrings",
45
+ action=argparse.BooleanOptionalAction,
46
+ default=None,
47
+ help="Keep docstrings in stubbed file view (default: true via config)",
48
+ )
49
+ pack.add_argument(
50
+ "--respect-gitignore",
51
+ action=argparse.BooleanOptionalAction,
52
+ default=None,
53
+ help="Respect .gitignore (default: true via config)",
54
+ )
55
+ pack.add_argument(
56
+ "--manifest",
57
+ action=argparse.BooleanOptionalAction,
58
+ default=None,
59
+ help="Include Manifest section (default: true via config)",
60
+ )
61
+ pack.add_argument(
62
+ "--include", action="append", default=None, help="Include glob (repeatable)"
63
+ )
64
+ pack.add_argument(
65
+ "--exclude", action="append", default=None, help="Exclude glob (repeatable)"
66
+ )
67
+ pack.add_argument(
68
+ "--split-max-chars",
69
+ type=int,
70
+ default=None,
71
+ help="Split output into .partN.md files",
72
+ )
73
+
74
+ # unpack
75
+ unpack = sub.add_parser(
76
+ "unpack", help="Reconstruct files from a packed context Markdown."
77
+ )
78
+ unpack.add_argument("markdown", type=Path, help="Packed Markdown file from `pack`")
79
+ unpack.add_argument(
80
+ "-o",
81
+ "--out-dir",
82
+ type=Path,
83
+ required=True,
84
+ help="Output directory for reconstructed files",
85
+ )
86
+
87
+ # patch
88
+ patch = sub.add_parser(
89
+ "patch",
90
+ help="Generate a diff-only patch Markdown from old pack + current repo.",
91
+ )
92
+ patch.add_argument(
93
+ "old_markdown", type=Path, help="Older packed Markdown (baseline)"
94
+ )
95
+ patch.add_argument("root", type=Path, help="Current repo root to compare against")
96
+ patch.add_argument(
97
+ "-o",
98
+ "--output",
99
+ type=Path,
100
+ default=Path("patch.md"),
101
+ help="Output patch markdown",
102
+ )
103
+
104
+ # apply
105
+ apply = sub.add_parser("apply", help="Apply a diff-only patch Markdown to a repo.")
106
+ apply.add_argument(
107
+ "patch_markdown", type=Path, help="Patch Markdown containing ```diff blocks"
108
+ )
109
+ apply.add_argument("root", type=Path, help="Repo root to apply patch to")
110
+ # validate-pack
111
+ vpack = sub.add_parser(
112
+ "validate-pack",
113
+ help="Validate a packed context Markdown (sha/markers/canonical consistency).",
114
+ )
115
+ vpack.add_argument("markdown", type=Path, help="Packed Markdown to validate")
116
+ vpack.add_argument(
117
+ "--root",
118
+ type=Path,
119
+ default=None,
120
+ help="Optional repo root to compare reconstructed files against",
121
+ )
122
+
123
+ return p
124
+
125
+
126
+ def _extract_diff_blocks(md_text: str) -> str:
127
+ """
128
+ Extract only diff fences from markdown and concatenate to a unified diff string.
129
+ """
130
+ lines = md_text.splitlines()
131
+ out: list[str] = []
132
+ i = 0
133
+ while i < len(lines):
134
+ if lines[i].strip() == "```diff":
135
+ i += 1
136
+ while i < len(lines) and lines[i].strip() != "```":
137
+ out.append(lines[i])
138
+ i += 1
139
+ i += 1
140
+ return "\n".join(out) + "\n"
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> None:
144
+ parser = build_parser()
145
+ args = parser.parse_args(argv)
146
+
147
+ if args.cmd == "pack":
148
+ root: Path = args.root.resolve()
149
+ cfg = load_config(root)
150
+
151
+ include = args.include if args.include is not None else cfg.include
152
+ exclude = args.exclude if args.exclude is not None else cfg.exclude
153
+
154
+ keep_docstrings = (
155
+ cfg.keep_docstrings
156
+ if args.keep_docstrings is None
157
+ else bool(args.keep_docstrings)
158
+ )
159
+ include_manifest = (
160
+ cfg.manifest if args.manifest is None else bool(args.manifest)
161
+ )
162
+ respect_gitignore = (
163
+ cfg.respect_gitignore
164
+ if args.respect_gitignore is None
165
+ else bool(args.respect_gitignore)
166
+ )
167
+ dedupe = bool(args.dedupe) or bool(cfg.dedupe)
168
+ split_max_chars = (
169
+ cfg.split_max_chars
170
+ if args.split_max_chars is None
171
+ else int(args.split_max_chars or 0)
172
+ )
173
+ layout = (
174
+ str(args.layout).strip().lower()
175
+ if args.layout is not None
176
+ else str(getattr(cfg, "layout", "auto")).strip().lower()
177
+ )
178
+ out_path = (
179
+ args.output
180
+ if args.output is not None
181
+ else Path(getattr(cfg, "output", "context.md"))
182
+ )
183
+ disc = discover_files(
184
+ root=root,
185
+ include=include,
186
+ exclude=exclude,
187
+ respect_gitignore=respect_gitignore,
188
+ )
189
+ pack, canonical = pack_repo(
190
+ disc.root, disc.files, keep_docstrings=keep_docstrings, dedupe=dedupe
191
+ )
192
+ md = render_markdown(
193
+ pack, canonical, layout=layout, include_manifest=include_manifest
194
+ )
195
+ # Always write the canonical, unsplit pack
196
+ # for machine parsing (unpack/validate).
197
+ out_path.write_text(md, encoding="utf-8")
198
+
199
+ # Additionally, write split parts for LLM consumption, if requested.
200
+ parts = split_by_max_chars(md, out_path, split_max_chars)
201
+ extra = [p for p in parts if p.path != out_path]
202
+ for part in extra:
203
+ part.path.write_text(part.content, encoding="utf-8")
204
+
205
+ if extra:
206
+ print(f"Wrote {out_path} and {len(extra)} split part file(s).")
207
+ else:
208
+ print(f"Wrote {out_path}.")
209
+ elif args.cmd == "unpack":
210
+ md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
211
+ unpack_to_dir(md_text, args.out_dir)
212
+ print(f"Unpacked into {args.out_dir}")
213
+
214
+ elif args.cmd == "patch":
215
+ old_md = args.old_markdown.read_text(encoding="utf-8", errors="replace")
216
+ cfg = load_config(args.root)
217
+ patch_md = generate_patch_markdown(
218
+ old_md,
219
+ args.root,
220
+ include=cfg.include,
221
+ exclude=cfg.exclude,
222
+ respect_gitignore=cfg.respect_gitignore,
223
+ )
224
+ args.output.write_text(patch_md, encoding="utf-8")
225
+ print(f"Wrote {args.output}")
226
+
227
+ elif args.cmd == "validate-pack":
228
+ md_text = args.markdown.read_text(encoding="utf-8", errors="replace")
229
+ report = validate_pack_markdown(md_text, root=args.root)
230
+ if report.warnings:
231
+ print("Warnings:")
232
+ for w in report.warnings:
233
+ print(f"- {w}")
234
+ if report.errors:
235
+ print("Errors:")
236
+ for e in report.errors:
237
+ print(f"- {e}")
238
+ raise SystemExit(1)
239
+ print("OK: pack is internally consistent.")
240
+
241
+ elif args.cmd == "apply":
242
+ md_text = args.patch_markdown.read_text(encoding="utf-8", errors="replace")
243
+ diff_text = _extract_diff_blocks(md_text)
244
+ diffs = parse_unified_diff(diff_text)
245
+ changed = apply_file_diffs(diffs, args.root)
246
+ print(f"Applied patch to {len(changed)} file(s).")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
codecrate/config.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ try:
8
+ import tomllib # py311+
9
+ except ModuleNotFoundError: # pragma: no cover
10
+ import tomli as tomllib # type: ignore
11
+
12
+ CONFIG_FILENAMES: tuple[str, ...] = (".codecrate.toml", "codecrate.toml")
13
+
14
+ DEFAULT_INCLUDES: list[str] = [
15
+ "**/*.py",
16
+ # Common packaging + repo metadata
17
+ "pyproject.toml",
18
+ "project.toml",
19
+ "setup.cfg",
20
+ "README*",
21
+ "LICENSE*",
22
+ # Docs
23
+ "docs/**/*.rst",
24
+ "docs/**/*.md",
25
+ ]
26
+
27
+
28
+ @dataclass
29
+ class Config:
30
+ # Default output path for `codecrate pack` when CLI does not specify -o/--output
31
+ output: str = "context.md"
32
+ keep_docstrings: bool = True
33
+ dedupe: bool = False
34
+ respect_gitignore: bool = True
35
+ include: list[str] = field(default_factory=lambda: DEFAULT_INCLUDES.copy())
36
+ exclude: list[str] = field(default_factory=list)
37
+ split_max_chars: int = 0 # 0 means no splitting
38
+ # Emit the `## Manifest` section (required for unpack/patch/validate-pack).
39
+ # Disable only for LLM-only packs to save tokens.
40
+ manifest: bool = True
41
+ # Output layout:
42
+ # - "stubs": always emit stubbed files + Function Library (current format)
43
+ # - "full": emit full file contents (no Function Library)
44
+ # - "auto": use "stubs" only if dedupe actually collapses something,
45
+ # otherwise use "full" (best token efficiency when no duplicates)
46
+ layout: Literal["auto", "stubs", "full"] = "auto"
47
+
48
+
49
+ def _find_config_path(root: Path) -> Path | None:
50
+ root = root.resolve()
51
+ for name in CONFIG_FILENAMES:
52
+ p = root / name
53
+ if p.exists():
54
+ return p
55
+ return None
56
+
57
+
58
+ def load_config(root: Path) -> Config:
59
+ cfg_path = _find_config_path(root)
60
+ if cfg_path is None:
61
+ return Config()
62
+
63
+ data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
64
+ section: dict[str, Any] = (
65
+ data.get("codecrate", {}) if isinstance(data, dict) else {}
66
+ )
67
+
68
+ cfg = Config()
69
+ out = section.get("output", cfg.output)
70
+ if isinstance(out, str) and out.strip():
71
+ cfg.output = out.strip()
72
+ cfg.keep_docstrings = bool(section.get("keep_docstrings", cfg.keep_docstrings))
73
+ cfg.dedupe = bool(section.get("dedupe", cfg.dedupe))
74
+ cfg.respect_gitignore = bool(
75
+ section.get("respect_gitignore", cfg.respect_gitignore)
76
+ )
77
+ man = section.get("manifest", section.get("include_manifest", cfg.manifest))
78
+ cfg.manifest = bool(man)
79
+ layout = section.get("layout", cfg.layout)
80
+ if isinstance(layout, str):
81
+ layout = layout.strip().lower()
82
+ if layout in {"auto", "stubs", "full"}:
83
+ cfg.layout = layout # type: ignore[assignment]
84
+
85
+ inc = section.get("include", cfg.include)
86
+ exc = section.get("exclude", cfg.exclude)
87
+ if isinstance(inc, list):
88
+ cfg.include = [str(x) for x in inc]
89
+ if isinstance(exc, list):
90
+ cfg.exclude = [str(x) for x in exc]
91
+
92
+ split = section.get("split_max_chars", cfg.split_max_chars)
93
+ try:
94
+ cfg.split_max_chars = int(split)
95
+ except Exception:
96
+ pass
97
+
98
+ return cfg
codecrate/diffgen.py ADDED
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ from pathlib import Path
5
+
6
+ from .config import DEFAULT_INCLUDES
7
+ from .discover import discover_files
8
+ from .mdparse import parse_packed_markdown
9
+ from .udiff import normalize_newlines
10
+ from .unpacker import _apply_canonical_into_stub
11
+
12
+
13
+ def generate_patch_markdown(
14
+ old_pack_md: str,
15
+ root: Path,
16
+ *,
17
+ include: list[str] | None = None,
18
+ exclude: list[str] | None = None,
19
+ respect_gitignore: bool = True,
20
+ ) -> str:
21
+ # If caller doesn't pass include/exclude, use the same defaults as Config.
22
+ include = DEFAULT_INCLUDES.copy() if include is None else list(include)
23
+ exclude = [] if exclude is None else list(exclude)
24
+
25
+ packed = parse_packed_markdown(old_pack_md)
26
+ manifest = packed.manifest
27
+ root = root.resolve()
28
+
29
+ blocks: list[str] = []
30
+ blocks.append("# Codecrate Patch\n\n")
31
+ # Do not leak absolute local paths; keep the header root stable + relative.
32
+ blocks.append("Root: `.`\n\n")
33
+ blocks.append("This file contains unified diffs inside ```diff code fences.\n\n")
34
+
35
+ any_changes = False
36
+
37
+ old_paths = {f["path"] for f in manifest.get("files", []) if "path" in f}
38
+ for f in manifest.get("files", []):
39
+ rel = f["path"]
40
+ stub = packed.stubbed_files.get(rel)
41
+ if stub is None:
42
+ continue
43
+
44
+ old_text = _apply_canonical_into_stub(
45
+ stub, f.get("defs", []), packed.canonical_sources
46
+ )
47
+ old_text = normalize_newlines(old_text)
48
+
49
+ cur_path = root / rel
50
+ if not cur_path.exists():
51
+ # treat as deleted in current
52
+ diff = difflib.unified_diff(
53
+ old_text.splitlines(),
54
+ [],
55
+ fromfile=f"a/{rel}",
56
+ tofile="/dev/null",
57
+ lineterm="",
58
+ )
59
+ else:
60
+ new_text = normalize_newlines(
61
+ cur_path.read_text(encoding="utf-8", errors="replace")
62
+ )
63
+ diff = difflib.unified_diff(
64
+ old_text.splitlines(),
65
+ new_text.splitlines(),
66
+ fromfile=f"a/{rel}",
67
+ tofile=f"b/{rel}",
68
+ lineterm="",
69
+ )
70
+
71
+ diff_lines = list(diff)
72
+ if diff_lines:
73
+ any_changes = True
74
+ blocks.append(f"## `{rel}`\n\n")
75
+ blocks.append("```diff\n")
76
+ blocks.append("\n".join(diff_lines) + "\n")
77
+ blocks.append("```\n\n")
78
+
79
+ # Added files (present in current repo, not in baseline manifest)
80
+ disc = discover_files(
81
+ root,
82
+ include=include,
83
+ exclude=exclude,
84
+ respect_gitignore=respect_gitignore,
85
+ )
86
+ for p in disc.files:
87
+ rel = p.relative_to(root).as_posix()
88
+ if rel in old_paths:
89
+ continue
90
+
91
+ new_text = normalize_newlines(p.read_text(encoding="utf-8", errors="replace"))
92
+ diff = difflib.unified_diff(
93
+ [],
94
+ new_text.splitlines(),
95
+ fromfile="/dev/null",
96
+ tofile=f"b/{rel}",
97
+ lineterm="",
98
+ )
99
+ diff_lines = list(diff)
100
+ if diff_lines:
101
+ any_changes = True
102
+ blocks.append(f"## `{rel}`\n\n")
103
+ blocks.append("```diff\n")
104
+ blocks.append("\n".join(diff_lines) + "\n")
105
+ blocks.append("```\n\n")
106
+
107
+ if not any_changes:
108
+ blocks.append("_No changes detected._\n")
109
+
110
+ return "".join(blocks)
codecrate/discover.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import pathspec
7
+
8
+ DEFAULT_EXCLUDES = [
9
+ "**/__pycache__/**",
10
+ "**/*.pyc",
11
+ "**/.git/**",
12
+ "**/.venv/**",
13
+ "**/venv/**",
14
+ "**/.tox/**",
15
+ "**/.pytest_cache/**",
16
+ "**/build/**",
17
+ "**/dist/**",
18
+ "**/_version.py",
19
+ ]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Discovery:
24
+ files: list[Path]
25
+ root: Path
26
+
27
+
28
+ def _load_gitignore(root: Path) -> pathspec.PathSpec:
29
+ gi = root / ".gitignore"
30
+ if not gi.exists():
31
+ return pathspec.PathSpec.from_lines("gitwildmatch", [])
32
+ return pathspec.PathSpec.from_lines(
33
+ "gitwildmatch", gi.read_text(encoding="utf-8").splitlines()
34
+ )
35
+
36
+
37
+ def discover_files(
38
+ root: Path,
39
+ include: list[str] | None,
40
+ exclude: list[str] | None,
41
+ respect_gitignore: bool = True,
42
+ ) -> Discovery:
43
+ """Discover repository files matching include/exclude patterns.
44
+
45
+ Unlike discover_python_files, this scans *all* files (not just *.py). This is
46
+ useful for packing metadata and docs files (e.g. pyproject.toml, *.rst).
47
+ """
48
+ root = root.resolve()
49
+
50
+ gi = (
51
+ _load_gitignore(root)
52
+ if respect_gitignore
53
+ else pathspec.PathSpec.from_lines("gitwildmatch", [])
54
+ )
55
+ inc = pathspec.PathSpec.from_lines("gitwildmatch", include or ["**/*.py"])
56
+
57
+ effective_exclude = DEFAULT_EXCLUDES + (exclude or [])
58
+ exc = pathspec.PathSpec.from_lines("gitwildmatch", effective_exclude)
59
+
60
+ out: list[Path] = []
61
+ for p in root.rglob("*"):
62
+ if not p.is_file():
63
+ continue
64
+ rel = p.relative_to(root)
65
+ rel_s = rel.as_posix()
66
+
67
+ if respect_gitignore and gi.match_file(rel_s):
68
+ continue
69
+ if not inc.match_file(rel_s):
70
+ continue
71
+ if exc.match_file(rel_s):
72
+ continue
73
+
74
+ out.append(p)
75
+
76
+ out.sort()
77
+ return Discovery(files=out, root=root)
78
+
79
+
80
+ def discover_python_files(
81
+ root: Path,
82
+ include: list[str] | None,
83
+ exclude: list[str] | None,
84
+ respect_gitignore: bool = True,
85
+ ) -> Discovery:
86
+ root = root.resolve()
87
+
88
+ gi = (
89
+ _load_gitignore(root)
90
+ if respect_gitignore
91
+ else pathspec.PathSpec.from_lines("gitwildmatch", [])
92
+ )
93
+ inc = pathspec.PathSpec.from_lines("gitwildmatch", include or ["**/*.py"])
94
+
95
+ effective_exclude = DEFAULT_EXCLUDES + (exclude or [])
96
+ exc = pathspec.PathSpec.from_lines("gitwildmatch", effective_exclude)
97
+
98
+ out: list[Path] = []
99
+ for p in root.rglob("*.py"):
100
+ rel = p.relative_to(root)
101
+ rel_s = rel.as_posix()
102
+
103
+ if respect_gitignore and gi.match_file(rel_s):
104
+ continue
105
+ if not inc.match_file(rel_s):
106
+ continue
107
+ if exc.match_file(rel_s):
108
+ continue
109
+
110
+ out.append(p)
111
+
112
+ out.sort()
113
+ return Discovery(files=out, root=root)
codecrate/ids.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+
7
+ def stable_location_id(path: Path, qualname: str, lineno: int) -> str:
8
+ payload = f"{path.as_posix()}::{qualname}::{lineno}".encode()
9
+ return hashlib.sha1(payload).hexdigest()[:8].upper()
10
+
11
+
12
+ def stable_body_hash(code: str) -> str:
13
+ norm = "\n".join(
14
+ line.rstrip()
15
+ for line in code.replace("\r\n", "\n").replace("\r", "\n").split("\n")
16
+ ).strip()
17
+ return hashlib.sha1(norm.encode("utf-8")).hexdigest().upper()
codecrate/manifest.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import asdict
5
+ from typing import Any
6
+
7
+ from .model import PackResult
8
+
9
+
10
+ def to_manifest(pack: PackResult, *, minimal: bool = False) -> dict[str, Any]:
11
+ def sha256_text(s: str) -> str:
12
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
13
+
14
+ files = []
15
+ for fp in pack.files:
16
+ rel = fp.path.relative_to(pack.root).as_posix()
17
+ entry: dict[str, Any] = {
18
+ "path": rel,
19
+ "line_count": fp.line_count,
20
+ "sha256_original": sha256_text(fp.original_text),
21
+ }
22
+ if not minimal:
23
+ entry |= {
24
+ "module": fp.module,
25
+ "sha256_stubbed": sha256_text(fp.stubbed_text),
26
+ "classes": [asdict(c) | {"path": rel} for c in fp.classes],
27
+ "defs": [asdict(d) | {"path": rel} for d in fp.defs],
28
+ }
29
+ files.append(entry)
30
+ # Root is already shown at the top of the pack; keep manifest root stable + short.
31
+ return {"format": "codecrate.v4", "root": ".", "files": files}