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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|
xrefkit/mcp/ownership.py
ADDED
|
@@ -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)]
|