zigpeek 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.
zigpeek/__init__.py ADDED
File without changes
Binary file
zigpeek/builtins.py ADDED
@@ -0,0 +1,148 @@
1
+ """Port of mcp/extract-builtin-functions.ts and the ranking from mcp/tools.ts.
2
+
3
+ Parses Zig's langref HTML into a list of BuiltinFunction records, then ranks
4
+ those records by relevance to a query string.
5
+ """
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+ from bs4 import BeautifulSoup, NavigableString, Tag
11
+
12
+
13
+ @dataclass
14
+ class BuiltinFunction:
15
+ func: str
16
+ signature: str
17
+ docs: str
18
+
19
+
20
+ _WS_RE = re.compile(r"\s+")
21
+ _BLANK_LINES_RE = re.compile(r"\n{2,}")
22
+ _TRAILING_NEWLINES_RE = re.compile(r"\n+$")
23
+
24
+
25
+ def _rewrite_link(text: str, href: str, link_base_url: str | None) -> str:
26
+ if href.startswith("#") and link_base_url:
27
+ return f"[{text}]({link_base_url}{href})"
28
+ return f"[{text}]({href})"
29
+
30
+
31
+ def _inline_text(tag: Tag, link_base_url: str | None) -> str:
32
+ cloned = BeautifulSoup(str(tag), "lxml").find()
33
+ if cloned is None:
34
+ return ""
35
+
36
+ for a in list(cloned.find_all("a")):
37
+ href = a.get("href", "")
38
+ text = a.get_text()
39
+ a.replace_with(NavigableString(_rewrite_link(text, href, link_base_url)))
40
+
41
+ for code in list(cloned.find_all("code")):
42
+ code.replace_with(NavigableString(f"`{code.get_text()}`"))
43
+
44
+ return _WS_RE.sub(" ", cloned.get_text()).strip()
45
+
46
+
47
+ def _next_sibling_tag(tag: Tag) -> Tag | None:
48
+ sib = tag.next_sibling
49
+ while sib is not None and not isinstance(sib, Tag):
50
+ sib = sib.next_sibling
51
+ return sib
52
+
53
+
54
+ def parse_builtin_functions_html(
55
+ html: str,
56
+ link_base_url: str | None,
57
+ ) -> list[BuiltinFunction]:
58
+ soup = BeautifulSoup(html, "lxml")
59
+ section = soup.find("h2", id="Builtin-Functions")
60
+ if section is None:
61
+ raise ValueError("Could not find Builtin Functions section in HTML")
62
+
63
+ builtins: list[BuiltinFunction] = []
64
+ current = _next_sibling_tag(section)
65
+
66
+ while current is not None and current.name != "h2":
67
+ if current.name == "h3" and current.has_attr("id"):
68
+ first_a = current.find("a")
69
+ func = first_a.get_text() if first_a is not None else ""
70
+ if func.startswith("@"):
71
+ pre = _next_sibling_tag(current)
72
+ signature = ""
73
+ desc_start = pre
74
+ if pre is not None and pre.name == "pre":
75
+ signature = pre.get_text().strip()
76
+ desc_start = _next_sibling_tag(pre)
77
+
78
+ description_parts: list[str] = []
79
+ desc_current = desc_start
80
+ while desc_current is not None and desc_current.name not in ("h2", "h3"):
81
+ if desc_current.name == "p":
82
+ description_parts.append(
83
+ _inline_text(desc_current, link_base_url)
84
+ )
85
+ elif desc_current.name == "ul":
86
+ for li in desc_current.find_all("li", recursive=False):
87
+ li_text = _inline_text(li, link_base_url)
88
+ if li_text:
89
+ description_parts.append(f"* {li_text}")
90
+ elif desc_current.name == "figure":
91
+ figcaption = ""
92
+ cap_el = desc_current.find("figcaption")
93
+ if cap_el is not None:
94
+ figcaption = cap_el.get_text().strip()
95
+ pre_el = desc_current.find("pre")
96
+ code = pre_el.get_text() if pre_el is not None else ""
97
+ lang = ""
98
+ label = ""
99
+ if figcaption:
100
+ label = f"**{figcaption}**\n"
101
+ if figcaption.endswith(".zig"):
102
+ lang = "zig"
103
+ elif "shell" in figcaption.lower():
104
+ lang = "sh"
105
+ if code:
106
+ block = f"{label}\n```{lang}\n{code.strip()}\n```"
107
+ description_parts.append(block.strip())
108
+ desc_current = _next_sibling_tag(desc_current)
109
+
110
+ docs = "\n".join(description_parts)
111
+ docs = _BLANK_LINES_RE.sub("\n", docs)
112
+ docs = _TRAILING_NEWLINES_RE.sub("", docs)
113
+ if docs.lower().endswith("see also:"):
114
+ docs = docs[: -len("see also:")].strip()
115
+
116
+ builtins.append(
117
+ BuiltinFunction(func=func, signature=signature, docs=docs)
118
+ )
119
+
120
+ current = _next_sibling_tag(current)
121
+
122
+ return builtins
123
+
124
+
125
+ def rank_builtin_functions(
126
+ functions: list[BuiltinFunction],
127
+ query: str,
128
+ ) -> list[BuiltinFunction]:
129
+ q = query.lower().strip()
130
+ if not q:
131
+ return []
132
+
133
+ scored: list[tuple[int, BuiltinFunction]] = []
134
+ for fn in functions:
135
+ f_lower = fn.func.lower()
136
+ score = 0
137
+ if f_lower == q:
138
+ score += 1000
139
+ elif f_lower.startswith(q):
140
+ score += 500
141
+ elif q in f_lower:
142
+ score += 300
143
+ if score > 0:
144
+ score += max(0, 50 - len(fn.func))
145
+ scored.append((score, fn))
146
+
147
+ scored.sort(key=lambda pair: pair[0], reverse=True)
148
+ return [fn for _, fn in scored]
zigpeek/cli.py ADDED
@@ -0,0 +1,320 @@
1
+ """argparse entrypoint for `zigpeek`. Five subcommands matching the MCP tools
2
+ plus a `prefetch` helper for offline-first workflows and a `batch` runner
3
+ that amortizes Python+wasmtime startup across many lookups.
4
+
5
+ Exit codes:
6
+ 0 — success (markdown on stdout)
7
+ 1 — bad input or "not found" (message on stderr)
8
+ 2 — network or cache failure (message on stderr)
9
+
10
+ For `batch`, the exit code is the maximum exit code seen across the
11
+ input lines, so a single not-found does not mask a later cache error.
12
+ """
13
+
14
+ import argparse
15
+ import functools
16
+ import io
17
+ import shlex
18
+ import sys
19
+ from importlib.resources import files
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+ import wasmtime
24
+
25
+ from zigpeek import builtins as builtins_mod
26
+ from zigpeek.fetch import (
27
+ fetch_langref_html,
28
+ fetch_sources_tar,
29
+ langref_url,
30
+ prefetch as fetch_prefetch,
31
+ )
32
+ from zigpeek.stdlib import render_get_item, render_search
33
+ from zigpeek.version import resolve_version
34
+ from zigpeek.wasm import WasmStd
35
+
36
+ _BATCH_DISALLOWED = frozenset({"batch", "prefetch"})
37
+
38
+
39
+ def _err(msg: str, code: int) -> int:
40
+ print(msg, file=sys.stderr)
41
+ return code
42
+
43
+
44
+ def _wrap_errors(fn):
45
+ """Translate the two exception families every command shares into the
46
+ documented exit codes. Keeps the per-command bodies focused on the
47
+ happy path."""
48
+
49
+ @functools.wraps(fn)
50
+ def inner(args: argparse.Namespace) -> int:
51
+ try:
52
+ return fn(args)
53
+ except (httpx.HTTPError, OSError) as e:
54
+ return _err(f"network/cache error: {e}", 2)
55
+ except (wasmtime.WasmtimeError, RuntimeError) as e:
56
+ return _err(
57
+ f"data error: {e}\n"
58
+ "The cached or bundled sources.tar may be corrupt. "
59
+ "Try `zigpeek prefetch --refresh`.",
60
+ 2,
61
+ )
62
+
63
+ return inner
64
+
65
+
66
+ def _vendor_wasm_bytes() -> bytes:
67
+ wasm = files("zigpeek").joinpath("_vendor", "main.wasm")
68
+ if not wasm.is_file():
69
+ raise FileNotFoundError(
70
+ "main.wasm not found inside the zigpeek package "
71
+ "(expected at zigpeek/_vendor/main.wasm). "
72
+ "See vendor/PROVENANCE.md for build instructions."
73
+ )
74
+ return wasm.read_bytes()
75
+
76
+
77
+ @functools.lru_cache(maxsize=4)
78
+ def _load_std(version: str, refresh: bool, cache_dir: str | None) -> WasmStd:
79
+ sources = fetch_sources_tar(version, refresh=refresh, cache_dir=cache_dir)
80
+ return WasmStd(_vendor_wasm_bytes(), sources)
81
+
82
+
83
+ @functools.lru_cache(maxsize=4)
84
+ def _load_builtins(
85
+ version: str, refresh: bool, cache_dir: str | None
86
+ ) -> tuple[list[builtins_mod.BuiltinFunction], str]:
87
+ html = fetch_langref_html(version, refresh=refresh, cache_dir=cache_dir)
88
+ base = langref_url(version)
89
+ return builtins_mod.parse_builtin_functions_html(html, link_base_url=base), base
90
+
91
+
92
+ @_wrap_errors
93
+ def _cmd_search(args: argparse.Namespace) -> int:
94
+ if not args.query:
95
+ return _err("query cannot be empty", 1)
96
+ version = resolve_version(args.version)
97
+ std = _load_std(version, args.refresh, args.cache_dir)
98
+ sys.stdout.write(render_search(std, args.query, limit=args.limit))
99
+ sys.stdout.write("\n")
100
+ return 0
101
+
102
+
103
+ @_wrap_errors
104
+ def _cmd_get(args: argparse.Namespace) -> int:
105
+ if not args.fqn:
106
+ return _err("fully-qualified name cannot be empty", 1)
107
+ version = resolve_version(args.version)
108
+ std = _load_std(version, args.refresh, args.cache_dir)
109
+ md = render_get_item(std, args.fqn, get_source_file=args.source_file)
110
+ if md.startswith("# Error"):
111
+ print(md, file=sys.stderr)
112
+ return 1
113
+ sys.stdout.write(md)
114
+ sys.stdout.write("\n")
115
+ return 0
116
+
117
+
118
+ @_wrap_errors
119
+ def _cmd_builtins_list(args: argparse.Namespace) -> int:
120
+ version = resolve_version(args.version)
121
+ fns, base = _load_builtins(version, args.refresh, args.cache_dir)
122
+ lines = "\n".join(f"- {fn.signature}" for fn in fns)
123
+ sys.stdout.write(
124
+ f"Available {len(fns)} builtin functions "
125
+ f"(full docs: {base}):\n\n{lines}\n"
126
+ )
127
+ return 0
128
+
129
+
130
+ @_wrap_errors
131
+ def _cmd_builtins_get(args: argparse.Namespace) -> int:
132
+ if not args.query:
133
+ return _err("query cannot be empty", 1)
134
+ version = resolve_version(args.version)
135
+ fns, _ = _load_builtins(version, args.refresh, args.cache_dir)
136
+ ranked = builtins_mod.rank_builtin_functions(fns, args.query)
137
+ if not ranked:
138
+ return _err(
139
+ f'No builtin functions found matching "{args.query}". '
140
+ "Try `zigpeek builtins list` to see all functions.",
141
+ 1,
142
+ )
143
+ chunks = [
144
+ f"**{fn.func}**\n```zig\n{fn.signature}\n```\n\n{fn.docs}"
145
+ for fn in ranked
146
+ ]
147
+ body = "\n\n---\n\n".join(chunks)
148
+ if len(ranked) == 1:
149
+ sys.stdout.write(body + "\n")
150
+ else:
151
+ sys.stdout.write(f"Found {len(ranked)} matching functions:\n\n{body}\n")
152
+ return 0
153
+
154
+
155
+ @_wrap_errors
156
+ def _cmd_prefetch(args: argparse.Namespace) -> int:
157
+ version = resolve_version(args.version)
158
+ paths = fetch_prefetch(version, refresh=args.refresh, cache_dir=args.cache_dir)
159
+ sys.stdout.write(
160
+ f"Prefetched docs for Zig {version}:\n"
161
+ f" sources.tar → {paths['sources.tar']}\n"
162
+ f" langref.html → {paths['langref.html']}\n"
163
+ )
164
+ return 0
165
+
166
+
167
+ def _read_batch_lines(args: argparse.Namespace) -> list[str]:
168
+ if args.file:
169
+ text = args.file.read_text(encoding="utf-8")
170
+ else:
171
+ text = sys.stdin.read()
172
+ return text.splitlines()
173
+
174
+
175
+ def _run_captured(parser: argparse.ArgumentParser, tokens: list[str]) -> tuple[
176
+ int, str, str
177
+ ]:
178
+ """Parse + dispatch a batch line, capturing its stdout/stderr so the
179
+ parent batch loop can frame each command's output."""
180
+ out, err = io.StringIO(), io.StringIO()
181
+ old_out, old_err = sys.stdout, sys.stderr
182
+ sys.stdout, sys.stderr = out, err
183
+ try:
184
+ try:
185
+ sub_args = parser.parse_args(tokens)
186
+ except SystemExit as se:
187
+ # argparse exits 2 for usage errors; map to 1 (bad input) so the
188
+ # batch exit code reserves 2 for real cache/network failures.
189
+ code = 0 if se.code == 0 else 1
190
+ return code, out.getvalue(), err.getvalue()
191
+ code = sub_args.func(sub_args)
192
+ finally:
193
+ sys.stdout, sys.stderr = old_out, old_err
194
+ return code, out.getvalue(), err.getvalue()
195
+
196
+
197
+ def _cmd_batch(args: argparse.Namespace) -> int:
198
+ parser = build_parser()
199
+ worst = 0
200
+ first = True
201
+ for raw in _read_batch_lines(args):
202
+ line = raw.strip()
203
+ if not line or line.startswith("#"):
204
+ continue
205
+ try:
206
+ tokens = shlex.split(line)
207
+ except ValueError as e:
208
+ if not first:
209
+ sys.stdout.write("\n")
210
+ sys.stdout.write(f"===> {line}\n# Error\nunparseable line: {e}\n")
211
+ worst = max(worst, 1)
212
+ first = False
213
+ continue
214
+ if not tokens:
215
+ continue
216
+ if tokens[0] in _BATCH_DISALLOWED:
217
+ if not first:
218
+ sys.stdout.write("\n")
219
+ sys.stdout.write(
220
+ f"===> {line}\n# Error\n"
221
+ f"`{tokens[0]}` is not allowed inside batch.\n"
222
+ )
223
+ worst = max(worst, 1)
224
+ first = False
225
+ continue
226
+ code, out, err = _run_captured(parser, tokens)
227
+ if not first:
228
+ sys.stdout.write("\n")
229
+ sys.stdout.write(f"===> {line}\n")
230
+ sys.stdout.write(out)
231
+ if err:
232
+ sys.stdout.write(err)
233
+ worst = max(worst, code)
234
+ first = False
235
+ return worst
236
+
237
+
238
+ def _add_common(p: argparse.ArgumentParser) -> None:
239
+ p.add_argument("--version", default=None, help="Zig version (default: 0.16.0)")
240
+ p.add_argument(
241
+ "--refresh",
242
+ action="store_true",
243
+ help="Force re-download of cached resources",
244
+ )
245
+ p.add_argument(
246
+ "--cache-dir",
247
+ default=None,
248
+ help=(
249
+ "Cache directory root (overrides ZIGPEEK_CACHE_DIR; "
250
+ "default: /tmp/zigpeek-cache)"
251
+ ),
252
+ )
253
+
254
+
255
+ def build_parser() -> argparse.ArgumentParser:
256
+ parser = argparse.ArgumentParser(prog="zigpeek", description="Zig 0.16 docs CLI")
257
+ sub = parser.add_subparsers(dest="cmd", required=True)
258
+
259
+ p_search = sub.add_parser("search", help="Search the standard library")
260
+ p_search.add_argument("query")
261
+ p_search.add_argument("--limit", type=int, default=20)
262
+ _add_common(p_search)
263
+ p_search.set_defaults(func=_cmd_search)
264
+
265
+ p_get = sub.add_parser("get", help="Get docs for a fully-qualified stdlib name")
266
+ p_get.add_argument("fqn")
267
+ p_get.add_argument(
268
+ "--source-file",
269
+ action="store_true",
270
+ help="Return the entire source file for the item",
271
+ )
272
+ _add_common(p_get)
273
+ p_get.set_defaults(func=_cmd_get)
274
+
275
+ p_builtins = sub.add_parser("builtins", help="Builtin function lookups")
276
+ builtins_sub = p_builtins.add_subparsers(dest="builtins_cmd", required=True)
277
+
278
+ p_blist = builtins_sub.add_parser("list", help="List all builtin functions")
279
+ _add_common(p_blist)
280
+ p_blist.set_defaults(func=_cmd_builtins_list)
281
+
282
+ p_bget = builtins_sub.add_parser("get", help="Look up a builtin by name/keyword")
283
+ p_bget.add_argument("query")
284
+ _add_common(p_bget)
285
+ p_bget.set_defaults(func=_cmd_builtins_get)
286
+
287
+ p_pre = sub.add_parser(
288
+ "prefetch",
289
+ help="Download sources.tar + langref.html so other commands run offline",
290
+ )
291
+ _add_common(p_pre)
292
+ p_pre.set_defaults(func=_cmd_prefetch)
293
+
294
+ p_batch = sub.add_parser(
295
+ "batch",
296
+ help=(
297
+ "Run many lookups in one process; reads one command per line "
298
+ "from stdin (or -f FILE) and frames each output with `===> <cmd>`"
299
+ ),
300
+ )
301
+ p_batch.add_argument(
302
+ "-f",
303
+ "--file",
304
+ type=Path,
305
+ default=None,
306
+ help="Read commands from FILE instead of stdin",
307
+ )
308
+ p_batch.set_defaults(func=_cmd_batch)
309
+
310
+ return parser
311
+
312
+
313
+ def main(argv: list[str] | None = None) -> int:
314
+ parser = build_parser()
315
+ args = parser.parse_args(argv)
316
+ return args.func(args)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ sys.exit(main())
zigpeek/fetch.py ADDED
@@ -0,0 +1,103 @@
1
+ import os
2
+ from importlib.resources import files
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ _CACHE_ENV = "ZIGPEEK_CACHE_DIR"
8
+ _DEFAULT_CACHE_ROOT = Path("/tmp/zigpeek-cache")
9
+
10
+
11
+ def sources_tar_url(zig_version: str) -> str:
12
+ return f"https://ziglang.org/documentation/{zig_version}/std/sources.tar"
13
+
14
+
15
+ def langref_url(zig_version: str) -> str:
16
+ return f"https://ziglang.org/documentation/{zig_version}/"
17
+
18
+
19
+ def cache_dir_for(zig_version: str, override: Path | str | None = None) -> Path:
20
+ if override is not None:
21
+ return Path(override) / zig_version
22
+ root = os.environ.get(_CACHE_ENV)
23
+ base = Path(root) if root else _DEFAULT_CACHE_ROOT
24
+ return base / zig_version
25
+
26
+
27
+ def bundled_path_for(zig_version: str, filename: str) -> Path:
28
+ """Where a pre-bundled snapshot would live for a given version."""
29
+ return Path(str(files("zigpeek").joinpath("_data", zig_version, filename)))
30
+
31
+
32
+ def _http_get_bytes(url: str) -> bytes:
33
+ with httpx.Client(follow_redirects=True, timeout=60.0) as client:
34
+ r = client.get(url)
35
+ r.raise_for_status()
36
+ return r.content
37
+
38
+
39
+ def _read_or_fetch(
40
+ url: str,
41
+ cache_path: Path,
42
+ refresh: bool,
43
+ bundled: Path | None = None,
44
+ ) -> bytes:
45
+ if not refresh and bundled is not None and bundled.exists():
46
+ return bundled.read_bytes()
47
+ if not refresh and cache_path.exists():
48
+ return cache_path.read_bytes()
49
+ data = _http_get_bytes(url)
50
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
51
+ cache_path.write_bytes(data)
52
+ return data
53
+
54
+
55
+ def fetch_sources_tar(
56
+ zig_version: str,
57
+ refresh: bool = False,
58
+ cache_dir: Path | str | None = None,
59
+ ) -> bytes:
60
+ return _read_or_fetch(
61
+ sources_tar_url(zig_version),
62
+ cache_dir_for(zig_version, cache_dir) / "sources.tar",
63
+ refresh,
64
+ bundled=bundled_path_for(zig_version, "sources.tar"),
65
+ )
66
+
67
+
68
+ def fetch_langref_html(
69
+ zig_version: str,
70
+ refresh: bool = False,
71
+ cache_dir: Path | str | None = None,
72
+ ) -> str:
73
+ data = _read_or_fetch(
74
+ langref_url(zig_version),
75
+ cache_dir_for(zig_version, cache_dir) / "langref.html",
76
+ refresh,
77
+ bundled=bundled_path_for(zig_version, "langref.html"),
78
+ )
79
+ return data.decode("utf-8")
80
+
81
+
82
+ def prefetch(
83
+ zig_version: str,
84
+ refresh: bool = False,
85
+ cache_dir: Path | str | None = None,
86
+ ) -> dict[str, Path]:
87
+ """Populate the cache so subsequent reads are network-free.
88
+
89
+ Returns a mapping of {"sources.tar": path, "langref.html": path}
90
+ pointing at whichever location the read path will resolve to next
91
+ (bundled snapshot if present, otherwise cache).
92
+ """
93
+ fetch_sources_tar(zig_version, refresh=refresh, cache_dir=cache_dir)
94
+ fetch_langref_html(zig_version, refresh=refresh, cache_dir=cache_dir)
95
+ bundled_src = bundled_path_for(zig_version, "sources.tar")
96
+ bundled_lang = bundled_path_for(zig_version, "langref.html")
97
+ cache = cache_dir_for(zig_version, cache_dir)
98
+ return {
99
+ "sources.tar": bundled_src if bundled_src.exists() else cache / "sources.tar",
100
+ "langref.html": (
101
+ bundled_lang if bundled_lang.exists() else cache / "langref.html"
102
+ ),
103
+ }
zigpeek/stdlib.py ADDED
@@ -0,0 +1,324 @@
1
+ """Markdown rendering — port of ~/Documents/GitHub/zig-mcp/mcp/std.ts.
2
+
3
+ Each function below mirrors a renderer in std.ts. Smoke tests catch
4
+ structural drift; cosmetic whitespace differences are acceptable.
5
+ """
6
+
7
+ from zigpeek.wasm import WasmStd
8
+
9
+ CAT_NAMESPACE = 0
10
+ CAT_CONTAINER = 1
11
+ CAT_GLOBAL_VARIABLE = 2
12
+ CAT_FUNCTION = 3
13
+ CAT_PRIMITIVE = 4
14
+ CAT_ERROR_SET = 5
15
+ CAT_GLOBAL_CONST = 6
16
+ CAT_ALIAS = 7
17
+ CAT_TYPE = 8
18
+ CAT_TYPE_TYPE = 9
19
+ CAT_TYPE_FUNCTION = 10
20
+
21
+
22
+ def render_search(std: WasmStd, query: str, limit: int = 20) -> str:
23
+ ignore_case = query.lower() == query
24
+ results = std.execute_query(query, ignore_case)
25
+
26
+ md = f'# Search Results\n\nQuery: "{query}"\n\n'
27
+ if results:
28
+ limited = results[:limit]
29
+ md += f"Found {len(results)} results (showing {len(limited)}):\n\n"
30
+ for match in limited:
31
+ md += f"- {std.fully_qualified_name(match)}\n"
32
+ else:
33
+ md += "No results found."
34
+ return md
35
+
36
+
37
+ def render_get_item(std: WasmStd, name: str, get_source_file: bool = False) -> str:
38
+ decl_index = std.find_decl(name)
39
+ if decl_index is None:
40
+ return f'# Error\n\nDeclaration "{name}" not found.'
41
+
42
+ if get_source_file:
43
+ cur = decl_index
44
+ seen: set[int] = set()
45
+ while True:
46
+ cat = std.categorize_decl(cur, 0)
47
+ if cat != CAT_ALIAS or cur in seen:
48
+ break
49
+ seen.add(cur)
50
+ nxt = std.get_aliasee()
51
+ if nxt is None or nxt == cur:
52
+ break
53
+ cur = nxt
54
+
55
+ file_path = std.decl_file_path(cur)
56
+ if file_path:
57
+ file_decl = std.find_file_root(file_path)
58
+ if file_decl is not None:
59
+ return f"# {file_path}\n\n{std.decl_source_html(file_decl)}"
60
+ return f'# Error\n\nCould not find source file for "{name}".'
61
+
62
+ return _render_decl(std, decl_index)
63
+
64
+
65
+ def _render_decl(std: WasmStd, decl_index: int) -> str:
66
+ current = decl_index
67
+ seen: set[int] = set()
68
+ while True:
69
+ category = std.categorize_decl(current, 0)
70
+ if category in (CAT_NAMESPACE, CAT_CONTAINER):
71
+ return _render_namespace(std, current)
72
+ if category in (
73
+ CAT_GLOBAL_VARIABLE,
74
+ CAT_PRIMITIVE,
75
+ CAT_GLOBAL_CONST,
76
+ CAT_TYPE,
77
+ CAT_TYPE_TYPE,
78
+ ):
79
+ return _render_global(std, current)
80
+ if category == CAT_FUNCTION:
81
+ return _render_function(std, current)
82
+ if category == CAT_TYPE_FUNCTION:
83
+ return _render_type_function(std, current)
84
+ if category == CAT_ERROR_SET:
85
+ return _render_error_set(std, current)
86
+ if category == CAT_ALIAS:
87
+ if current in seen:
88
+ return _render_not_found()
89
+ seen.add(current)
90
+ aliasee = std.get_aliasee()
91
+ if aliasee is None:
92
+ return _render_not_found()
93
+ current = aliasee
94
+ continue
95
+ raise RuntimeError(f"unrecognized category {category}")
96
+
97
+
98
+ def _render_namespace(std: WasmStd, decl_index: int) -> str:
99
+ name = std.decl_category_name(decl_index)
100
+ md = f"# {name}\n\n"
101
+
102
+ docs = std.decl_docs_html(decl_index, False)
103
+ if docs:
104
+ md += docs + "\n\n"
105
+
106
+ members = std.namespace_members(decl_index, False)
107
+ fields = std.decl_fields(decl_index)
108
+ md += _render_namespace_md(std, decl_index, members, fields)
109
+ return md
110
+
111
+
112
+ def _render_function(std: WasmStd, decl_index: int) -> str:
113
+ name = std.decl_category_name(decl_index)
114
+ md = f"# {name}\n"
115
+
116
+ docs = std.decl_docs_html(decl_index, False)
117
+ if docs:
118
+ md += "\n" + docs
119
+
120
+ proto = std.decl_fn_proto_html(decl_index, False)
121
+ if proto:
122
+ md += "\n\n## Function Signature\n\n" + proto
123
+
124
+ params = std.decl_params(decl_index)
125
+ if params:
126
+ md += "\n\n## Parameters\n"
127
+ for p in params:
128
+ md += "\n" + std.decl_param_html(decl_index, p)
129
+
130
+ err_set_node = std.fn_error_set(decl_index)
131
+ if err_set_node is not None:
132
+ base_decl = std.fn_error_set_decl(decl_index, err_set_node)
133
+ error_list = std.error_set_node_list(decl_index, err_set_node)
134
+ if error_list:
135
+ md += "\n\n## Errors\n"
136
+ for e in error_list:
137
+ md += "\n" + std.error_html(base_decl, e)
138
+
139
+ doctest = std.decl_doctest_html(decl_index)
140
+ if doctest:
141
+ md += "\n\n## Example Usage\n\n" + doctest
142
+
143
+ source = std.decl_source_html(decl_index)
144
+ if source:
145
+ md += "\n\n## Source Code\n\n" + source
146
+
147
+ return md
148
+
149
+
150
+ def _render_global(std: WasmStd, decl_index: int) -> str:
151
+ name = std.decl_category_name(decl_index)
152
+ md = f"# {name}\n\n"
153
+
154
+ docs = std.decl_docs_html(decl_index, True)
155
+ if docs:
156
+ md += docs + "\n\n"
157
+
158
+ source = std.decl_source_html(decl_index)
159
+ if source:
160
+ md += "## Source Code\n\n" + source + "\n\n"
161
+
162
+ return md
163
+
164
+
165
+ def _render_type_function(std: WasmStd, decl_index: int) -> str:
166
+ name = std.decl_category_name(decl_index)
167
+ md = f"# {name}\n\n"
168
+
169
+ docs = std.decl_docs_html(decl_index, False)
170
+ if docs:
171
+ md += docs + "\n\n"
172
+
173
+ params = std.decl_params(decl_index)
174
+ if params:
175
+ md += "## Parameters\n\n"
176
+ for p in params:
177
+ md += std.decl_param_html(decl_index, p) + "\n\n"
178
+
179
+ doctest = std.decl_doctest_html(decl_index)
180
+ if doctest:
181
+ md += "## Example Usage\n\n" + doctest + "\n\n"
182
+
183
+ members = std.type_fn_members(decl_index, False)
184
+ fields = std.type_fn_fields(decl_index)
185
+ if members or fields:
186
+ md += _render_namespace_md(std, decl_index, members, fields)
187
+ else:
188
+ source = std.decl_source_html(decl_index)
189
+ if source:
190
+ md += "## Source Code\n\n" + source + "\n\n"
191
+
192
+ return md
193
+
194
+
195
+ def _render_error_set(std: WasmStd, decl_index: int) -> str:
196
+ name = std.decl_category_name(decl_index)
197
+ md = f"# {name}\n\n"
198
+
199
+ docs = std.decl_docs_html(decl_index, False)
200
+ if docs:
201
+ md += docs + "\n\n"
202
+
203
+ error_list = std.decl_error_set(decl_index)
204
+ if error_list:
205
+ md += "## Errors\n\n"
206
+ for e in error_list:
207
+ md += std.error_html(decl_index, e) + "\n\n"
208
+
209
+ return md
210
+
211
+
212
+ def _render_not_found() -> str:
213
+ return "# Error\n\nDeclaration not found."
214
+
215
+
216
+ def _render_namespace_md(
217
+ std: WasmStd,
218
+ base_decl: int,
219
+ members: list[int],
220
+ fields: list[int],
221
+ ) -> str:
222
+ types_list: list[tuple[int, int]] = []
223
+ namespaces_list: list[tuple[int, int]] = []
224
+ err_sets_list: list[tuple[int, int]] = []
225
+ fns_list: list[int] = []
226
+ vars_list: list[int] = []
227
+ vals_list: list[tuple[int, int]] = []
228
+
229
+ for original in members:
230
+ member = original
231
+ seen: set[int] = set()
232
+ while True:
233
+ cat = std.categorize_decl(member, 0)
234
+ if cat == CAT_NAMESPACE:
235
+ namespaces_list.append((original, member))
236
+ elif cat == CAT_CONTAINER:
237
+ types_list.append((original, member))
238
+ elif cat == CAT_GLOBAL_VARIABLE:
239
+ vars_list.append(member)
240
+ elif cat == CAT_FUNCTION:
241
+ fns_list.append(member)
242
+ elif cat in (CAT_TYPE, CAT_TYPE_TYPE, CAT_TYPE_FUNCTION):
243
+ types_list.append((original, member))
244
+ elif cat == CAT_ERROR_SET:
245
+ err_sets_list.append((original, member))
246
+ elif cat in (CAT_GLOBAL_CONST, CAT_PRIMITIVE):
247
+ vals_list.append((original, member))
248
+ elif cat == CAT_ALIAS:
249
+ if member in seen:
250
+ vals_list.append((original, member))
251
+ break
252
+ seen.add(member)
253
+ nxt = std.get_aliasee()
254
+ if nxt is None:
255
+ vals_list.append((original, member))
256
+ break
257
+ member = nxt
258
+ continue
259
+ else:
260
+ raise RuntimeError(f"unknown category: {cat}")
261
+ break
262
+
263
+ md = ""
264
+
265
+ if types_list:
266
+ md += "## Types\n\n"
267
+ for original, _ in types_list:
268
+ md += f"- {std.decl_name(original)}\n"
269
+ md += "\n"
270
+
271
+ if namespaces_list:
272
+ md += "## Namespaces\n\n"
273
+ for original, _ in namespaces_list:
274
+ md += f"- {std.decl_name(original)}\n"
275
+ md += "\n"
276
+
277
+ if err_sets_list:
278
+ md += "## Error Sets\n\n"
279
+ for original, _ in err_sets_list:
280
+ md += f"- {std.decl_name(original)}\n"
281
+ md += "\n"
282
+
283
+ if fns_list:
284
+ md += "## Functions\n\n"
285
+ for decl in fns_list:
286
+ name = std.decl_name(decl)
287
+ proto = std.decl_fn_proto_html(decl, True)
288
+ docs = std.decl_docs_html(decl, True)
289
+ md += f"### {name}\n\n"
290
+ if proto:
291
+ md += proto + "\n\n"
292
+ if docs:
293
+ md += docs + "\n\n"
294
+
295
+ if fields:
296
+ md += "## Fields\n\n"
297
+ for f in fields:
298
+ md += std.decl_field_html(base_decl, f) + "\n\n"
299
+
300
+ if vars_list:
301
+ md += "## Global Variables\n\n"
302
+ for decl in vars_list:
303
+ name = std.decl_name(decl)
304
+ type_html = std.decl_type_html(decl)
305
+ docs = std.decl_docs_html(decl, True)
306
+ md += f"### {name}\n\n"
307
+ if type_html:
308
+ md += f"Type: {type_html}\n\n"
309
+ if docs:
310
+ md += docs + "\n\n"
311
+
312
+ if vals_list:
313
+ md += "## Values\n\n"
314
+ for original, member in vals_list:
315
+ name = std.decl_name(original)
316
+ type_html = std.decl_type_html(member)
317
+ docs = std.decl_docs_html(member, True)
318
+ md += f"### {name}\n\n"
319
+ if type_html:
320
+ md += f"Type: {type_html}\n\n"
321
+ if docs:
322
+ md += docs + "\n\n"
323
+
324
+ return md
zigpeek/version.py ADDED
@@ -0,0 +1,15 @@
1
+ import os
2
+
3
+ DEFAULT_ZIG_VERSION = "0.16.0"
4
+ _ENV_VAR = "ZIGPEEK_VERSION"
5
+
6
+
7
+ def resolve_version(cli_value: str | None) -> str:
8
+ if cli_value is not None:
9
+ if cli_value == "":
10
+ raise ValueError("--version cannot be empty")
11
+ return cli_value
12
+ env_value = os.environ.get(_ENV_VAR)
13
+ if env_value:
14
+ return env_value
15
+ return DEFAULT_ZIG_VERSION
zigpeek/wasm.py ADDED
@@ -0,0 +1,210 @@
1
+ import struct
2
+
3
+ import wasmtime
4
+
5
+ _INVALID_INDEX = 0xFFFF_FFFF
6
+
7
+
8
+ def unpack_string(memory: bytes, ptr: int, length: int) -> str:
9
+ if length == 0:
10
+ return ""
11
+ return memory[ptr : ptr + length].decode("utf-8", errors="replace")
12
+
13
+
14
+ def unpack_slice32(memory: bytes, ptr: int, length: int) -> list[int]:
15
+ if length == 0:
16
+ return []
17
+ return list(struct.unpack_from(f"<{length}I", memory, ptr))
18
+
19
+
20
+ def unpack_slice64(memory: bytes, ptr: int, length: int) -> list[int]:
21
+ if length == 0:
22
+ return []
23
+ return list(struct.unpack_from(f"<{length}Q", memory, ptr))
24
+
25
+
26
+ def split_packed(packed: int) -> tuple[int, int]:
27
+ """Decode the JS BigInt packing: low32 = ptr, high32 = length."""
28
+ return packed & 0xFFFFFFFF, packed >> 32
29
+
30
+
31
+ class WasmStd:
32
+ """Drive the autodoc WASM module loaded from sources.tar.
33
+
34
+ Mirrors the JS interface in ~/Documents/GitHub/zig-mcp/mcp/std.ts.
35
+ The WASM imports a single js.log function (level, ptr, len) — we
36
+ forward errors as exceptions and discard the rest.
37
+ """
38
+
39
+ def __init__(self, wasm_bytes: bytes, sources_tar: bytes) -> None:
40
+ self._engine = wasmtime.Engine()
41
+ self._store = wasmtime.Store(self._engine)
42
+ module = wasmtime.Module(self._engine, wasm_bytes)
43
+
44
+ log_type = wasmtime.FuncType(
45
+ [wasmtime.ValType.i32(), wasmtime.ValType.i32(), wasmtime.ValType.i32()],
46
+ [],
47
+ )
48
+ log_func = wasmtime.Func(self._store, log_type, self._on_log)
49
+ instance = wasmtime.Instance(self._store, module, [log_func])
50
+
51
+ self._exports = instance.exports(self._store)
52
+ self._memory: wasmtime.Memory = self._exports["memory"]
53
+
54
+ ptr = self._call("alloc", len(sources_tar))
55
+ self._memory.write(self._store, sources_tar, ptr)
56
+ self._call("unpack", ptr, len(sources_tar))
57
+
58
+ def _on_log(self, level: int, ptr: int, length: int) -> None:
59
+ if level == 0:
60
+ raise RuntimeError(f"WASM log error: {self._read_string(ptr, length)}")
61
+
62
+ def _call(self, name: str, *args: int) -> int:
63
+ result = self._exports[name](self._store, *args)
64
+ return int(result) if result is not None else 0
65
+
66
+ def _call_optional(self, name: str, *args: int) -> int | None:
67
+ result = self._call(name, *args)
68
+ return None if result in (-1, _INVALID_INDEX) else result
69
+
70
+ def _read_string(self, ptr: int, length: int) -> str:
71
+ if length == 0:
72
+ return ""
73
+ return self._memory.read(self._store, ptr, ptr + length).decode(
74
+ "utf-8", errors="replace"
75
+ )
76
+
77
+ def _read_slice32(self, ptr: int, length: int) -> list[int]:
78
+ if length == 0:
79
+ return []
80
+ raw = self._memory.read(self._store, ptr, ptr + length * 4)
81
+ return list(struct.unpack_from(f"<{length}I", raw))
82
+
83
+ def _read_slice64(self, ptr: int, length: int) -> list[int]:
84
+ if length == 0:
85
+ return []
86
+ raw = self._memory.read(self._store, ptr, ptr + length * 8)
87
+ return list(struct.unpack_from(f"<{length}Q", raw))
88
+
89
+ def _packed_string(self, packed: int) -> str:
90
+ return self._read_string(*split_packed(packed))
91
+
92
+ def _packed_slice32(self, packed: int) -> list[int]:
93
+ return self._read_slice32(*split_packed(packed))
94
+
95
+ def _packed_slice64(self, packed: int) -> list[int]:
96
+ return self._read_slice64(*split_packed(packed))
97
+
98
+ def _set_string(self, export: str, s: str) -> None:
99
+ encoded = s.encode("utf-8")
100
+ ptr = self._call(export, len(encoded))
101
+ self._memory.write(self._store, encoded, ptr)
102
+
103
+ def list_modules(self) -> list[str]:
104
+ out: list[str] = []
105
+ i = 0
106
+ while True:
107
+ name = self._packed_string(self._call("module_name", i))
108
+ if not name:
109
+ break
110
+ out.append(name)
111
+ i += 1
112
+ return out
113
+
114
+ def find_decl(self, fqn: str) -> int | None:
115
+ self._set_string("set_input_string", fqn)
116
+ return self._call_optional("find_decl")
117
+
118
+ def find_file_root(self, path: str) -> int | None:
119
+ self._set_string("set_input_string", path)
120
+ return self._call_optional("find_file_root")
121
+
122
+ def find_module_root(self, pkg_index: int) -> int:
123
+ return self._call("find_module_root", pkg_index)
124
+
125
+ def categorize_decl(self, decl_index: int, resolve_alias_to: int = 0) -> int:
126
+ return self._call("categorize_decl", decl_index, resolve_alias_to)
127
+
128
+ def get_aliasee(self) -> int | None:
129
+ return self._call_optional("get_aliasee")
130
+
131
+ def decl_parent(self, decl_index: int) -> int | None:
132
+ return self._call_optional("decl_parent", decl_index)
133
+
134
+ def fully_qualified_name(self, decl_index: int) -> str:
135
+ return self._packed_string(self._call("decl_fqn", decl_index))
136
+
137
+ def decl_name(self, decl_index: int) -> str:
138
+ return self._packed_string(self._call("decl_name", decl_index))
139
+
140
+ def decl_category_name(self, decl_index: int) -> str:
141
+ return self._packed_string(self._call("decl_category_name", decl_index))
142
+
143
+ def decl_docs_html(self, decl_index: int, short: bool) -> str:
144
+ return self._packed_string(self._call("decl_docs_html", decl_index, int(short)))
145
+
146
+ def decl_fn_proto_html(self, decl_index: int, linkify: bool) -> str:
147
+ return self._packed_string(
148
+ self._call("decl_fn_proto_html", decl_index, int(linkify))
149
+ )
150
+
151
+ def decl_param_html(self, decl_index: int, param: int) -> str:
152
+ return self._packed_string(self._call("decl_param_html", decl_index, param))
153
+
154
+ def decl_doctest_html(self, decl_index: int) -> str:
155
+ return self._packed_string(self._call("decl_doctest_html", decl_index))
156
+
157
+ def decl_source_html(self, decl_index: int) -> str:
158
+ return self._packed_string(self._call("decl_source_html", decl_index))
159
+
160
+ def decl_field_html(self, base_decl: int, field: int) -> str:
161
+ return self._packed_string(self._call("decl_field_html", base_decl, field))
162
+
163
+ def decl_type_html(self, decl_index: int) -> str:
164
+ return self._packed_string(self._call("decl_type_html", decl_index))
165
+
166
+ def decl_file_path(self, decl_index: int) -> str:
167
+ return self._packed_string(self._call("decl_file_path", decl_index))
168
+
169
+ def decl_params(self, decl_index: int) -> list[int]:
170
+ return self._packed_slice32(self._call("decl_params", decl_index))
171
+
172
+ def decl_fields(self, decl_index: int) -> list[int]:
173
+ return self._packed_slice32(self._call("decl_fields", decl_index))
174
+
175
+ def decl_error_set(self, decl_index: int) -> list[int]:
176
+ return self._packed_slice64(self._call("decl_error_set", decl_index))
177
+
178
+ def namespace_members(self, decl_index: int, include_private: bool) -> list[int]:
179
+ return self._packed_slice32(
180
+ self._call("namespace_members", decl_index, int(include_private))
181
+ )
182
+
183
+ def type_fn_members(self, decl_index: int, include_private: bool) -> list[int]:
184
+ return self._packed_slice32(
185
+ self._call("type_fn_members", decl_index, int(include_private))
186
+ )
187
+
188
+ def type_fn_fields(self, decl_index: int) -> list[int]:
189
+ return self._packed_slice32(self._call("type_fn_fields", decl_index))
190
+
191
+ def fn_error_set(self, decl_index: int) -> int | None:
192
+ result = self._call("fn_error_set", decl_index)
193
+ return None if result == 0 else result
194
+
195
+ def fn_error_set_decl(self, decl_index: int, err_set_node: int) -> int:
196
+ return self._call("fn_error_set_decl", decl_index, err_set_node)
197
+
198
+ def error_set_node_list(self, base_decl: int, err_set_node: int) -> list[int]:
199
+ return self._packed_slice64(
200
+ self._call("error_set_node_list", base_decl, err_set_node)
201
+ )
202
+
203
+ def error_html(self, base_decl: int, err: int) -> str:
204
+ return self._packed_string(self._call("error_html", base_decl, err))
205
+
206
+ def execute_query(self, query: str, ignore_case: bool) -> list[int]:
207
+ self._set_string("query_begin", query)
208
+ ptr = self._call("query_exec", int(ignore_case))
209
+ length = self._read_slice32(ptr, 1)[0]
210
+ return self._read_slice32(ptr + 4, length)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: zigpeek
3
+ Version: 0.3.0
4
+ Summary: Fast CLI for Zig 0.16 stdlib + Skill for coding agents
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: beautifulsoup4>=4.12.0
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: lxml>=5.0.0
10
+ Requires-Dist: wasmtime>=25.0.0
@@ -0,0 +1,13 @@
1
+ zigpeek/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ zigpeek/builtins.py,sha256=bObngeM-ECoxYy6W_GK-gK0XddftRULZVoSyv5u9xvw,5209
3
+ zigpeek/cli.py,sha256=d54AG3Zoc05ZT8K5G2Ov5biRSnyAruqtETWdCkpkaxg,10139
4
+ zigpeek/fetch.py,sha256=NEv5Ro0y3rMebjsDUoLobFxRrcjkRLt3bIcwinmaQcg,3183
5
+ zigpeek/stdlib.py,sha256=8--XHGvI-lt4aFQpEYMohUcW_siyZ1njIe9lzvjiZxw,9805
6
+ zigpeek/version.py,sha256=Jgu1ea8iDxoaOuzFiloUfyKZyBZE-jXBSKyBNVALfCU,380
7
+ zigpeek/wasm.py,sha256=YpdZqiXcbUCzfwarjXQ1hSPxl5C3Epe9ojL5YDpYvSo,8141
8
+ zigpeek/_vendor/main.wasm,sha256=p0uEHkPeJKd_SaGAsxSObqX1HvOvg-zIRtqCV8xGBbU,198076
9
+ zigpeek-0.3.0.dist-info/METADATA,sha256=yMS1-Xb_YoaXgH6IIzTvhDfNwhb7hnAPGLaZJW7CtWc,287
10
+ zigpeek-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ zigpeek-0.3.0.dist-info/entry_points.txt,sha256=pgEKNRrTppa0T5zXRA9VYU_wKK1pe_hvCTdmQWf3Ivo,45
12
+ zigpeek-0.3.0.dist-info/licenses/LICENSE,sha256=uNuWcR4sJ7681Zgk8CdW_6u2WLeeVqScvIOLuQoh8z8,1298
13
+ zigpeek-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zigpeek = zigpeek.cli:main
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tanuj Vasudeva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This project bundles `src/zigpeek/_vendor/main.wasm`, built from the
26
+ `zig-mcp` project (https://github.com/loonghao/zig-mcp), which is also
27
+ distributed under the MIT License. See `vendor/PROVENANCE.md` for build
28
+ details.