xrefkit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. xrefkit/__init__.py +5 -0
  2. xrefkit/__main__.py +5 -0
  3. xrefkit/catalog_cli.py +57 -0
  4. xrefkit/cli.py +71 -0
  5. xrefkit/contracts.py +297 -0
  6. xrefkit/ctx.py +160 -0
  7. xrefkit/dashboard.py +1220 -0
  8. xrefkit/discovery.py +85 -0
  9. xrefkit/gate.py +428 -0
  10. xrefkit/goalstate.py +555 -0
  11. xrefkit/hashing.py +18 -0
  12. xrefkit/import_skill.py +469 -0
  13. xrefkit/instance.py +133 -0
  14. xrefkit/loaders.py +77 -0
  15. xrefkit/mcp/__init__.py +26 -0
  16. xrefkit/mcp/audit.py +168 -0
  17. xrefkit/mcp/bootstrap.py +337 -0
  18. xrefkit/mcp/catalog.py +2638 -0
  19. xrefkit/mcp/cli.py +173 -0
  20. xrefkit/mcp/client_cache.py +356 -0
  21. xrefkit/mcp/context_registry.py +277 -0
  22. xrefkit/mcp/contracts.py +243 -0
  23. xrefkit/mcp/dist.py +234 -0
  24. xrefkit/mcp/ownership.py +246 -0
  25. xrefkit/mcp/repository.py +217 -0
  26. xrefkit/mcp/schemas.py +349 -0
  27. xrefkit/mcp/server.py +773 -0
  28. xrefkit/mcp/startup_contract_pack.py +154 -0
  29. xrefkit/mcp_tools.py +47 -0
  30. xrefkit/models/__init__.py +41 -0
  31. xrefkit/models/common.py +185 -0
  32. xrefkit/models/effective_bundle.py +131 -0
  33. xrefkit/models/local_manifest.py +217 -0
  34. xrefkit/models/package_manifest.py +126 -0
  35. xrefkit/models/run_log.py +276 -0
  36. xrefkit/models/server_config.py +160 -0
  37. xrefkit/models/skill_definition.py +131 -0
  38. xrefkit/operations_cli.py +670 -0
  39. xrefkit/ownership.py +276 -0
  40. xrefkit/packmeta.py +289 -0
  41. xrefkit/registry.py +334 -0
  42. xrefkit/resolver.py +252 -0
  43. xrefkit/resource_provider.py +187 -0
  44. xrefkit/resources/base/contracts.json +178 -0
  45. xrefkit/resources/base/current.json +6 -0
  46. xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
  47. xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
  48. xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
  49. xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
  50. xrefkit/resources/base/model_body.md +22 -0
  51. xrefkit/runlog.py +45 -0
  52. xrefkit/skillmeta.py +1034 -0
  53. xrefkit/skillrun.py +2381 -0
  54. xrefkit/structure_catalog.py +199 -0
  55. xrefkit/tools/__init__.py +119 -0
  56. xrefkit/tools/__main__.py +4 -0
  57. xrefkit/v2_cli.py +130 -0
  58. xrefkit/workspace.py +117 -0
  59. xrefkit/xref.py +1048 -0
  60. xrefkit-0.3.0.dist-info/METADATA +203 -0
  61. xrefkit-0.3.0.dist-info/RECORD +65 -0
  62. xrefkit-0.3.0.dist-info/WHEEL +5 -0
  63. xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
  64. xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  65. xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/mcp/dist.py ADDED
@@ -0,0 +1,234 @@
1
+ """Plain-HTTP artifact distribution routes served next to the MCP endpoint.
2
+
3
+ The MCP channel stays a context-distribution channel (small governance
4
+ text); executable artifacts (the xrefkit runtime package, client tools package,
5
+ optional third-party wheels, and the stdlib-only bootstrap script) are
6
+ served as ordinary HTTP downloads under ``/dist`` on the same server, so
7
+ package bytes never have to travel through an MCP tool result and therefore
8
+ never enter an AI client's model context.
9
+
10
+ Routes:
11
+
12
+ - ``GET /dist`` pip ``--find-links`` compatible HTML index
13
+ - ``GET /dist/index.json`` machine-readable manifest with sha256 hashes
14
+ - ``GET /dist/<filename>`` one artifact
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .catalog import XRefCatalog
23
+
24
+
25
+ DIST_ROUTE_PATH = "/dist"
26
+ BOOTSTRAP_FILENAME = "bootstrap.py"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class DistArtifact:
31
+ filename: str
32
+ kind: str # "pip_package" | "bootstrap_script" | "wheel" | "extra_file"
33
+ content: bytes
34
+ sha256: str
35
+ size_bytes: int
36
+ package_id: str | None = None
37
+ version: str | None = None
38
+ install_command: str | None = None
39
+
40
+ def manifest_entry(self, base_url: str) -> dict[str, object]:
41
+ return {
42
+ "filename": self.filename,
43
+ "url": f"{base_url.rstrip('/')}{DIST_ROUTE_PATH}/{self.filename}",
44
+ "kind": self.kind,
45
+ "sha256": self.sha256,
46
+ "size_bytes": self.size_bytes,
47
+ "package_id": self.package_id,
48
+ "version": self.version,
49
+ "install_command": self.install_command,
50
+ }
51
+
52
+
53
+ class ArtifactDistribution:
54
+ """Builds the artifact set live from the catalog's repository state."""
55
+
56
+ def __init__(self, catalog: XRefCatalog, extra_dir: Path | None = None) -> None:
57
+ self._catalog = catalog
58
+ self._extra_dir = extra_dir
59
+ self._bootstrap_source = (
60
+ Path(__file__).parent / BOOTSTRAP_FILENAME
61
+ ).read_text(encoding="utf-8")
62
+
63
+ def artifacts(self) -> list[DistArtifact]:
64
+ # Package artifacts are rebuilt from the live repository on each
65
+ # request; the fixed zip timestamps in the catalog builders keep the
66
+ # bytes (and therefore the sha256) stable for unchanged inputs.
67
+ artifacts = [
68
+ _package_artifact(self._catalog.get_xrefkit_runtime_pip_package()),
69
+ _package_artifact(self._catalog.get_client_tool_pip_package()),
70
+ _bootstrap_artifact(self._bootstrap_source),
71
+ ]
72
+ artifacts.extend(_extra_artifacts(self._extra_dir))
73
+ return artifacts
74
+
75
+ def get(self, filename: str) -> DistArtifact | None:
76
+ for artifact in self.artifacts():
77
+ if artifact.filename == filename:
78
+ return artifact
79
+ return None
80
+
81
+ def index_manifest(self, base_url: str) -> dict[str, object]:
82
+ return {
83
+ "server": "xrefkit-mcp",
84
+ "transport": "plain_http",
85
+ "hash_algorithm": "sha256",
86
+ "find_links_url": f"{base_url.rstrip('/')}{DIST_ROUTE_PATH}/",
87
+ "bootstrap_url": (
88
+ f"{base_url.rstrip('/')}{DIST_ROUTE_PATH}/{BOOTSTRAP_FILENAME}"
89
+ ),
90
+ "artifacts": [
91
+ artifact.manifest_entry(base_url) for artifact in self.artifacts()
92
+ ],
93
+ }
94
+
95
+ def index_html(self) -> str:
96
+ # pip --find-links page: bare anchors with a sha256 fragment.
97
+ links = "\n".join(
98
+ f'<a href="{artifact.filename}#sha256={artifact.sha256}">{artifact.filename}</a><br/>'
99
+ for artifact in self.artifacts()
100
+ )
101
+ return (
102
+ "<!DOCTYPE html>\n<html><head><title>xrefkit-mcp dist</title></head>"
103
+ f"<body>\n{links}\n</body></html>\n"
104
+ )
105
+
106
+ def describe_for_mcp(self, base_url: str) -> dict[str, object]:
107
+ """The artifact_distribution block attached to get_startup_context."""
108
+ base = base_url.rstrip("/")
109
+ return {
110
+ "transport": "plain_http",
111
+ "base_url": base,
112
+ "index_json_url": f"{base}{DIST_ROUTE_PATH}/index.json",
113
+ "find_links_url": f"{base}{DIST_ROUTE_PATH}/",
114
+ "bootstrap_url": f"{base}{DIST_ROUTE_PATH}/{BOOTSTRAP_FILENAME}",
115
+ "bootstrap_run": (
116
+ f"python {BOOTSTRAP_FILENAME} --base-url {base} --target ."
117
+ ),
118
+ "pip_install_example": (
119
+ "python -m pip install --no-index --no-build-isolation "
120
+ f"--find-links {base}{DIST_ROUTE_PATH}/ xrefkit"
121
+ ),
122
+ "artifacts": [
123
+ {
124
+ key: value
125
+ for key, value in artifact.manifest_entry(base).items()
126
+ }
127
+ for artifact in self.artifacts()
128
+ ],
129
+ "instructions": [
130
+ "Fetch artifacts out-of-band with plain HTTP GET (bootstrap "
131
+ "script, curl, or pip --find-links), never through the "
132
+ "get_*_bundle or get_*_pip_package MCP tools, so package "
133
+ "bytes do not enter the model context.",
134
+ "Verify each downloaded artifact against the sha256 in "
135
+ "index.json before materializing or installing it.",
136
+ f"First download {BOOTSTRAP_FILENAME} from bootstrap_url and "
137
+ "run bootstrap_run in the client-side target repository; it "
138
+ "verifies hashes and materializes xrefkit/ and tools/.",
139
+ "These HTTP routes are outside MCP session ordering; the "
140
+ "startup-context-first obligation still applies to the AI "
141
+ "session driving the download.",
142
+ ],
143
+ }
144
+
145
+
146
+ def _package_artifact(package: dict[str, object]) -> DistArtifact:
147
+ content = base64.b64decode(str(package["content_base64"]))
148
+ return DistArtifact(
149
+ filename=str(package["filename"]),
150
+ kind="pip_package",
151
+ content=content,
152
+ sha256=str(package["content_hash"]),
153
+ size_bytes=len(content),
154
+ package_id=str(package["package_id"]),
155
+ version=str(package["version"]),
156
+ install_command=str(package["install_command"]),
157
+ )
158
+
159
+
160
+ def _bootstrap_artifact(source: str) -> DistArtifact:
161
+ from .repository import stable_hash
162
+
163
+ content = source.encode("utf-8")
164
+ return DistArtifact(
165
+ filename=BOOTSTRAP_FILENAME,
166
+ kind="bootstrap_script",
167
+ content=content,
168
+ sha256=stable_hash(source),
169
+ size_bytes=len(content),
170
+ install_command=f"python {BOOTSTRAP_FILENAME} --base-url <base-url> --target .",
171
+ )
172
+
173
+
174
+ def _extra_artifacts(extra_dir: Path | None) -> list[DistArtifact]:
175
+ """Operator-provided files (for example PyYAML wheels) mirrored as-is."""
176
+ if extra_dir is None or not extra_dir.is_dir():
177
+ return []
178
+ from .catalog import hashlib_sha256_bytes
179
+
180
+ artifacts: list[DistArtifact] = []
181
+ for path in sorted(extra_dir.iterdir()):
182
+ if not path.is_file():
183
+ continue
184
+ content = path.read_bytes()
185
+ kind = "wheel" if path.suffix == ".whl" else "extra_file"
186
+ artifacts.append(
187
+ DistArtifact(
188
+ filename=path.name,
189
+ kind=kind,
190
+ content=content,
191
+ sha256=hashlib_sha256_bytes(content),
192
+ size_bytes=len(content),
193
+ install_command=(
194
+ f"python -m pip install --no-index {path.name}"
195
+ if kind == "wheel"
196
+ else None
197
+ ),
198
+ )
199
+ )
200
+ return artifacts
201
+
202
+
203
+ def add_dist_routes(starlette_app: object, dist: ArtifactDistribution, base_url: str) -> None:
204
+ from starlette.responses import HTMLResponse, JSONResponse, Response
205
+ from starlette.routing import Route
206
+
207
+ async def index_html(_request: object) -> HTMLResponse:
208
+ return HTMLResponse(dist.index_html())
209
+
210
+ async def artifact_or_index(request: object) -> Response:
211
+ filename = request.path_params["filename"]
212
+ if filename == "index.json":
213
+ return JSONResponse(dist.index_manifest(base_url))
214
+ artifact = dist.get(filename)
215
+ if artifact is None:
216
+ return JSONResponse({"error": f"unknown artifact: {filename}"}, status_code=404)
217
+ media_type = (
218
+ "text/x-python" if artifact.filename.endswith(".py") else "application/zip"
219
+ )
220
+ return Response(
221
+ artifact.content,
222
+ media_type=media_type,
223
+ headers={
224
+ "X-Content-SHA256": artifact.sha256,
225
+ "Content-Disposition": f'attachment; filename="{artifact.filename}"',
226
+ },
227
+ )
228
+
229
+ routes = getattr(getattr(starlette_app, "router"), "routes")
230
+ routes.append(Route(f"{DIST_ROUTE_PATH}", index_html, methods=["GET"]))
231
+ routes.append(Route(f"{DIST_ROUTE_PATH}/", index_html, methods=["GET"]))
232
+ routes.append(
233
+ Route(f"{DIST_ROUTE_PATH}/{{filename}}", artifact_or_index, methods=["GET"])
234
+ )
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import json
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any
8
+
9
+ from .repository import stable_hash
10
+
11
+
12
+ class OwnershipError(ValueError):
13
+ pass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Zone:
18
+ id: str
19
+ owner: str
20
+ paths: tuple[str, ...]
21
+ catalog: bool
22
+ distribution: bool
23
+ base_sync: bool
24
+ shadowing: bool
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ data = asdict(self)
28
+ data["paths"] = list(self.paths)
29
+ return data
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Ownership:
34
+ zones: tuple[Zone, ...]
35
+
36
+ @property
37
+ def content_hash(self) -> str:
38
+ return stable_hash(json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":")))
39
+
40
+ def zone_for(self, rel_path: str) -> Zone | None:
41
+ normalized = _normalize_rel_path(rel_path)
42
+ for zone in self.zones:
43
+ if any(_matches(pattern, normalized) for pattern in zone.paths):
44
+ return zone
45
+ return None
46
+
47
+ def catalog_enabled(self, rel_path: str) -> bool:
48
+ zone = self.zone_for(rel_path)
49
+ return True if zone is None else zone.catalog
50
+
51
+ def distribution_enabled(self, rel_path: str) -> bool:
52
+ zone = self.zone_for(rel_path)
53
+ return True if zone is None else zone.distribution
54
+
55
+ def metadata_for(self, rel_path: str) -> dict[str, Any]:
56
+ zone = self.zone_for(rel_path)
57
+ if zone is None:
58
+ return {
59
+ "zone": None,
60
+ "owner": None,
61
+ "pack_id": _pack_id(rel_path),
62
+ "local_only": False,
63
+ "catalog": True,
64
+ "distribution": True,
65
+ "shadowing": False,
66
+ }
67
+ return {
68
+ "zone": zone.id,
69
+ "owner": zone.owner,
70
+ "pack_id": _pack_id(rel_path),
71
+ "local_only": zone.owner == "local" or zone.id == "local-packs",
72
+ "catalog": zone.catalog,
73
+ "distribution": zone.distribution,
74
+ "shadowing": zone.shadowing,
75
+ }
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {"zones": [zone.to_dict() for zone in self.zones]}
79
+
80
+
81
+ def load_ownership(root: str | Path, filename: str = "ownership.yaml") -> Ownership | None:
82
+ path = Path(root) / filename
83
+ if not path.exists():
84
+ return None
85
+ parsed = _parse_simple_yaml(path.read_text(encoding="utf-8"))
86
+ zones = parsed.get("zones")
87
+ if not isinstance(zones, list):
88
+ raise OwnershipError("ownership.yaml must contain a zones list")
89
+ result: list[Zone] = []
90
+ seen: set[str] = set()
91
+ for index, raw in enumerate(zones):
92
+ if not isinstance(raw, dict):
93
+ raise OwnershipError(f"zone {index} must be a mapping")
94
+ zone = _zone_from_mapping(raw, index)
95
+ if zone.id in seen:
96
+ raise OwnershipError(f"duplicate zone id: {zone.id}")
97
+ seen.add(zone.id)
98
+ result.append(zone)
99
+ return Ownership(tuple(result))
100
+
101
+
102
+ def validate_ownership(root: str | Path, ownership: Ownership) -> list[str]:
103
+ errors: list[str] = []
104
+ repo = Path(root).resolve()
105
+ for zone in ownership.zones:
106
+ for pattern in zone.paths:
107
+ if not pattern or pattern.startswith("/") or "\\" in pattern:
108
+ errors.append(f"{zone.id}: invalid path pattern `{pattern}`")
109
+ continue
110
+ literal_prefix = pattern.split("*", 1)[0]
111
+ target = (repo / literal_prefix).resolve()
112
+ try:
113
+ target.relative_to(repo)
114
+ except ValueError:
115
+ errors.append(f"{zone.id}: path escapes repository `{pattern}`")
116
+ return errors
117
+
118
+
119
+ def _zone_from_mapping(raw: dict[str, Any], index: int) -> Zone:
120
+ required = ("id", "owner", "paths", "catalog", "distribution", "base_sync", "shadowing")
121
+ missing = [key for key in required if key not in raw]
122
+ if missing:
123
+ raise OwnershipError(f"zone {index} missing fields: {', '.join(missing)}")
124
+ paths = raw["paths"]
125
+ if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths):
126
+ raise OwnershipError(f"zone {raw.get('id', index)} paths must be a list of strings")
127
+ return Zone(
128
+ id=_as_str(raw["id"], "id"),
129
+ owner=_as_str(raw["owner"], "owner"),
130
+ paths=tuple(_normalize_pattern(path) for path in paths),
131
+ catalog=_as_bool(raw["catalog"], "catalog"),
132
+ distribution=_as_bool(raw["distribution"], "distribution"),
133
+ base_sync=_as_bool(raw["base_sync"], "base_sync"),
134
+ shadowing=_as_bool(raw["shadowing"], "shadowing"),
135
+ )
136
+
137
+
138
+ def _parse_simple_yaml(text: str) -> dict[str, Any]:
139
+ root: dict[str, Any] = {}
140
+ current_list_name: str | None = None
141
+ current_item: dict[str, Any] | None = None
142
+ current_item_list_name: str | None = None
143
+ for line_number, raw_line in enumerate(text.splitlines(), start=1):
144
+ line = raw_line.split("#", 1)[0].rstrip()
145
+ if not line.strip():
146
+ continue
147
+ indent = len(line) - len(line.lstrip(" "))
148
+ stripped = line.strip()
149
+ if indent == 0:
150
+ if not stripped.endswith(":"):
151
+ raise OwnershipError(f"line {line_number}: top-level value must be a mapping key")
152
+ current_list_name = stripped[:-1]
153
+ root[current_list_name] = []
154
+ current_item = None
155
+ current_item_list_name = None
156
+ continue
157
+ if current_list_name is None:
158
+ raise OwnershipError(f"line {line_number}: nested value before top-level key")
159
+ if indent == 2 and stripped.startswith("- "):
160
+ key, raw_value = _split_key_value(stripped[2:], line_number)
161
+ current_item = {key: _parse_scalar(raw_value)}
162
+ root[current_list_name].append(current_item)
163
+ current_item_list_name = None
164
+ continue
165
+ if indent == 4:
166
+ if current_item is None:
167
+ raise OwnershipError(f"line {line_number}: mapping value before list item")
168
+ key, raw_value = _split_key_value(stripped, line_number)
169
+ if raw_value == "":
170
+ current_item[key] = []
171
+ current_item_list_name = key
172
+ else:
173
+ current_item[key] = _parse_scalar(raw_value)
174
+ current_item_list_name = None
175
+ continue
176
+ if indent == 6 and stripped.startswith("- "):
177
+ if current_item is None or current_item_list_name is None:
178
+ raise OwnershipError(f"line {line_number}: list value without parent key")
179
+ current_item[current_item_list_name].append(_parse_scalar(stripped[2:]))
180
+ continue
181
+ raise OwnershipError(f"line {line_number}: unsupported indentation or syntax")
182
+ return root
183
+
184
+
185
+ def _split_key_value(value: str, line_number: int) -> tuple[str, str]:
186
+ if ":" not in value:
187
+ raise OwnershipError(f"line {line_number}: expected key: value")
188
+ key, raw = value.split(":", 1)
189
+ key = key.strip()
190
+ if not key:
191
+ raise OwnershipError(f"line {line_number}: empty key")
192
+ return key, raw.strip()
193
+
194
+
195
+ def _parse_scalar(value: str) -> str | bool:
196
+ if value == "true":
197
+ return True
198
+ if value == "false":
199
+ return False
200
+ return value.strip("'\"")
201
+
202
+
203
+ def _as_str(value: Any, field: str) -> str:
204
+ if not isinstance(value, str) or not value:
205
+ raise OwnershipError(f"{field} must be a non-empty string")
206
+ return value
207
+
208
+
209
+ def _as_bool(value: Any, field: str) -> bool:
210
+ if not isinstance(value, bool):
211
+ raise OwnershipError(f"{field} must be true or false")
212
+ return value
213
+
214
+
215
+ def _normalize_pattern(pattern: str) -> str:
216
+ normalized = pattern.replace("\\", "/")
217
+ if normalized.startswith("./"):
218
+ normalized = normalized[2:]
219
+ if normalized and not normalized.endswith("/"):
220
+ normalized += "/"
221
+ return normalized
222
+
223
+
224
+ def _normalize_rel_path(path: str) -> str:
225
+ normalized = path.replace("\\", "/")
226
+ if normalized.startswith("./"):
227
+ normalized = normalized[2:]
228
+ parts = PurePosixPath(normalized).parts
229
+ if any(part == ".." for part in parts):
230
+ return normalized
231
+ return normalized
232
+
233
+
234
+ def _matches(pattern: str, rel_path: str) -> bool:
235
+ if "*" not in pattern:
236
+ return rel_path == pattern.rstrip("/") or rel_path.startswith(pattern)
237
+ return fnmatch.fnmatch(rel_path, pattern + "*")
238
+
239
+
240
+ def _pack_id(rel_path: str) -> str | None:
241
+ parts = PurePosixPath(rel_path.replace("\\", "/")).parts
242
+ if len(parts) >= 2 and parts[0] == "packs":
243
+ if parts[1] == "local" and len(parts) >= 3:
244
+ return f"local/{parts[2]}"
245
+ return parts[1]
246
+ return None
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import subprocess
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+
10
+ OWN_XID_RE = re.compile(
11
+ r"""
12
+ ^\s*
13
+ (?:
14
+ <!--\s*xid\s*:\s*(?P<html>[A-Za-z0-9_-]+)\s*-->
15
+ | (?:\#|//|--|')\s*xid\s*:\s*(?P<comment>[A-Za-z0-9_-]+)\s*$
16
+ | xid\s*:\s*(?P<yaml>[A-Za-z0-9_-]+)\s*$
17
+ | <a\s+[^>]*id=["']xid-(?P<anchor>[A-Za-z0-9_-]+)["'][^>]*>\s*</a>
18
+ )
19
+ """,
20
+ re.IGNORECASE | re.MULTILINE | re.VERBOSE,
21
+ )
22
+ HEADING_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
23
+ MD_LINK_RE = re.compile(r"\]\((?P<target>[^)]+#xid-(?P<xid>[A-Za-z0-9]+))\)")
24
+ XID_TARGET_RE = re.compile(r"(?P<path>[A-Za-z0-9_.\-/]+\.md)#xid-(?P<xid>[A-Za-z0-9]+)")
25
+
26
+
27
+ def read_text(path: Path) -> str:
28
+ return path.read_text(encoding="utf-8")
29
+
30
+
31
+ def stable_hash(text: str) -> str:
32
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
33
+
34
+
35
+ def repository_identity(repo_root: Path) -> tuple[str, str]:
36
+ """Return (fingerprint, basis) identifying the repository's content lineage.
37
+
38
+ Basis preference:
39
+
40
+ 1. ``git_root_commits`` — the root commit(s) of HEAD's history. Every
41
+ full clone of the same repository shares them regardless of path,
42
+ host, or branch, so caches keyed by this fingerprint are shared
43
+ across machines and survive checkout moves. Multiple roots (merged
44
+ histories) are sorted and joined for determinism.
45
+ 2. ``resolved_repository_root`` — path fallback for non-git
46
+ directories, repositories without commits, and shallow clones
47
+ (whose grafted history would misreport the true root commit).
48
+ """
49
+ root_commits = _git_root_commits(repo_root)
50
+ if root_commits:
51
+ seed = "git-root-commits:" + ",".join(root_commits)
52
+ return stable_hash(seed)[:32], "git_root_commits"
53
+ normalized_root = repo_root.resolve().as_posix().casefold()
54
+ seed = f"resolved-repository-root:{normalized_root}"
55
+ return stable_hash(seed)[:32], "resolved_repository_root"
56
+
57
+
58
+ def repository_fingerprint(repo_root: Path) -> str:
59
+ return repository_identity(repo_root)[0]
60
+
61
+
62
+ def _git_root_commits(repo_root: Path) -> list[str]:
63
+ if _git_is_shallow(repo_root):
64
+ return []
65
+ output = _git_stdout(repo_root, ["rev-list", "--max-parents=0", "HEAD"])
66
+ if output is None:
67
+ return []
68
+ return sorted(line.strip() for line in output.splitlines() if line.strip())
69
+
70
+
71
+ def _git_is_shallow(repo_root: Path) -> bool:
72
+ output = _git_stdout(repo_root, ["rev-parse", "--is-shallow-repository"])
73
+ return output is not None and output.strip() == "true"
74
+
75
+
76
+ def _git_stdout(repo_root: Path, arguments: list[str]) -> str | None:
77
+ try:
78
+ result = subprocess.run(
79
+ ["git", *arguments],
80
+ cwd=repo_root,
81
+ text=True,
82
+ capture_output=True,
83
+ check=False,
84
+ )
85
+ except OSError:
86
+ return None
87
+ if result.returncode != 0:
88
+ return None
89
+ return result.stdout
90
+
91
+
92
+ def first_xid(text: str) -> str | None:
93
+ for match in OWN_XID_RE.finditer(text):
94
+ return (
95
+ match.group("html")
96
+ or match.group("comment")
97
+ or match.group("yaml")
98
+ or match.group("anchor")
99
+ )
100
+ return None
101
+
102
+
103
+ def first_heading(text: str, fallback: str) -> str:
104
+ match = HEADING_RE.search(text)
105
+ return match.group(1).strip() if match else fallback
106
+
107
+
108
+ def first_paragraph(text: str) -> str:
109
+ lines: list[str] = []
110
+ in_heading = False
111
+ for raw in text.splitlines():
112
+ line = raw.strip()
113
+ if not line or line.startswith("<!--") or line.startswith("<a "):
114
+ continue
115
+ if line.startswith("#"):
116
+ in_heading = True
117
+ continue
118
+ if in_heading and not line.startswith(("-", "|", "```")):
119
+ lines.append(line)
120
+ if len(" ".join(lines)) > 220:
121
+ break
122
+ elif lines:
123
+ break
124
+ return " ".join(lines)[:500]
125
+
126
+
127
+ def markdown_xid_links(text: str) -> list[str]:
128
+ return list(dict.fromkeys(match.group("xid") for match in MD_LINK_RE.finditer(text)))
129
+
130
+
131
+ def markdown_xid_link_targets(text: str) -> list[dict[str, str]]:
132
+ links: list[dict[str, str]] = []
133
+ seen: set[str] = set()
134
+ for match in MD_LINK_RE.finditer(text):
135
+ xid = match.group("xid")
136
+ if xid in seen:
137
+ continue
138
+ seen.add(xid)
139
+ links.append(
140
+ {
141
+ "xid": xid,
142
+ "resolver_tool": "get_document_by_xid",
143
+ "resolver_argument": "xid",
144
+ }
145
+ )
146
+ return links
147
+
148
+
149
+ def markdown_xid_only_text(text: str) -> str:
150
+ return XID_TARGET_RE.sub(lambda match: f"#xid-{match.group('xid')}", text)
151
+
152
+
153
+ def relative_to_repo(path: Path, repo_root: Path) -> str:
154
+ return path.resolve().relative_to(repo_root.resolve()).as_posix()
155
+
156
+
157
+ def file_last_modified(path: Path) -> str | None:
158
+ """UTC ISO timestamp of the file's last modification on this checkout.
159
+
160
+ Derived from the filesystem, not from git history: catalog entries are
161
+ rebuilt from the live repository inside MCP request handlers, and
162
+ spawning a git subprocess there hangs the stdio transport on Windows
163
+ (the tool result is computed but the response never reaches the
164
+ client). revised_at is advisory metadata, so the checkout's mtime is an
165
+ acceptable and subprocess-free source.
166
+ """
167
+ try:
168
+ stat = path.stat()
169
+ except OSError:
170
+ return None
171
+ return (
172
+ datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
173
+ .isoformat(timespec="seconds")
174
+ )
175
+
176
+
177
+ def parse_meta_bullets(text: str) -> dict[str, object]:
178
+ result: dict[str, object] = {}
179
+ current_key: str | None = None
180
+ for raw in text.splitlines():
181
+ line = raw.rstrip()
182
+ top = re.match(r"^- ([A-Za-z0-9_\-]+):\s*(.*)$", line)
183
+ if top:
184
+ current_key = top.group(1)
185
+ value = _clean_scalar(top.group(2))
186
+ result[current_key] = value if value else []
187
+ continue
188
+ child = re.match(r"^\s+-\s+(.+)$", line)
189
+ if child and current_key:
190
+ existing = result.setdefault(current_key, [])
191
+ if not isinstance(existing, list):
192
+ existing = [existing]
193
+ result[current_key] = existing
194
+ existing.append(_clean_scalar(child.group(1)))
195
+ return result
196
+
197
+
198
+ def _clean_scalar(value: str) -> str:
199
+ value = value.strip()
200
+ if value.startswith("`") and value.endswith("`"):
201
+ value = value[1:-1]
202
+ return value
203
+
204
+
205
+ def scalar_list(meta: dict[str, object], key: str) -> list[str]:
206
+ value = meta.get(key)
207
+ if isinstance(value, list):
208
+ return [piece for item in value for piece in _split_scalar(str(item))]
209
+ if isinstance(value, str) and value:
210
+ return _split_scalar(value)
211
+ return []
212
+
213
+
214
+ def _split_scalar(value: str) -> list[str]:
215
+ if "," not in value:
216
+ return [value] if value else []
217
+ return [_clean_scalar(piece) for piece in value.split(",") if _clean_scalar(piece)]