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
@@ -0,0 +1,199 @@
1
+ """List-first source target/finding catalog and safe candidate promotion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from datetime import date, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+
14
+ SCHEMA = "xrefkit.source_structure_catalog/v1"
15
+ RECEIPT_SCHEMA = "xrefkit.source_structure_candidate/v1"
16
+ XID_RE = re.compile(r"^[A-F0-9]{12}$")
17
+
18
+
19
+ def load_catalog(path: str | Path) -> dict[str, Any]:
20
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
21
+ if not isinstance(data, dict) or data.get("schema") != SCHEMA:
22
+ raise ValueError("unsupported source structure catalog")
23
+ return data
24
+
25
+
26
+ def save_catalog(path: str | Path, catalog: dict[str, Any]) -> None:
27
+ Path(path).write_text(yaml.safe_dump(catalog, sort_keys=False, allow_unicode=True), encoding="utf-8")
28
+
29
+
30
+ def _safe_value(value: Any) -> Any:
31
+ if isinstance(value, (date, datetime)):
32
+ return value.isoformat()
33
+ if isinstance(value, list):
34
+ return [_safe_value(item) for item in value]
35
+ if isinstance(value, dict):
36
+ return {key: _safe_value(item) for key, item in value.items()}
37
+ return value
38
+
39
+
40
+ def list_targets(catalog: dict[str, Any]) -> list[dict[str, Any]]:
41
+ return [_safe_value(dict(item)) for item in catalog.get("targets", [])]
42
+
43
+
44
+ def list_findings(catalog: dict[str, Any], target_xid: str) -> list[dict[str, Any]]:
45
+ key = target_xid.upper()
46
+ return [_safe_value(dict(item)) for item in catalog.get("findings", []) if str(item.get("target_xid", "")).upper() == key]
47
+
48
+
49
+ def get_entry(catalog: dict[str, Any], xid: str) -> dict[str, Any]:
50
+ key = xid.upper()
51
+ for collection, field in (("targets", "target_xid"), ("findings", "finding_xid")):
52
+ for item in catalog.get(collection, []):
53
+ if str(item.get(field, "")).upper() == key:
54
+ return _safe_value({"kind": collection[:-1], **dict(item)})
55
+ raise KeyError(f"unknown catalog XID: {xid}")
56
+
57
+
58
+ def _target_identity(item: dict[str, Any]) -> tuple[str, str, str]:
59
+ return (
60
+ str(item.get("repository_identity", "")).strip(),
61
+ str(item.get("source_scope", "")).strip(),
62
+ str(item.get("target_kind", "")).strip(),
63
+ )
64
+
65
+
66
+ def _derived_xid(*parts: str) -> str:
67
+ return hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()[:12].upper()
68
+
69
+
70
+ def _validate_receipt(receipt: dict[str, Any], root: Path) -> list[str]:
71
+ errors: list[str] = []
72
+ if receipt.get("schema") != RECEIPT_SCHEMA:
73
+ errors.append("unsupported receipt schema")
74
+ required = (
75
+ "candidate_xid", "artifact_path", "repository_identity", "source_scope",
76
+ "target_kind", "source_revision", "producer_skill", "analyzed_at",
77
+ )
78
+ for field in required:
79
+ if not str(receipt.get(field, "")).strip():
80
+ errors.append(f"missing {field}")
81
+ xid = str(receipt.get("candidate_xid", "")).upper()
82
+ if xid and not XID_RE.fullmatch(xid):
83
+ errors.append("candidate_xid must be 12 hexadecimal characters")
84
+ artifact = root / str(receipt.get("artifact_path", ""))
85
+ if not artifact.is_file():
86
+ errors.append(f"artifact not found: {artifact}")
87
+ return errors
88
+
89
+
90
+ def maintain_catalog(
91
+ root: str | Path,
92
+ catalog_path: str | Path,
93
+ inbox: str | Path,
94
+ *,
95
+ apply_safe: bool,
96
+ ) -> dict[str, Any]:
97
+ root_path = Path(root).resolve()
98
+ catalog_file = root_path / catalog_path
99
+ catalog = load_catalog(catalog_file)
100
+ results: list[dict[str, Any]] = []
101
+ changed = False
102
+ for receipt_path in sorted((root_path / inbox).glob("*.yaml")):
103
+ receipt = yaml.safe_load(receipt_path.read_text(encoding="utf-8"))
104
+ if not isinstance(receipt, dict):
105
+ results.append({"receipt": str(receipt_path), "status": "invalid", "errors": ["receipt is not a mapping"]})
106
+ continue
107
+ errors = _validate_receipt(receipt, root_path)
108
+ identity = _target_identity(receipt)
109
+ matches = [item for item in catalog.get("targets", []) if _target_identity(item) == identity]
110
+ if len(matches) > 1:
111
+ errors.append("multiple targets match receipt identity")
112
+ finding_xid = str(receipt.get("candidate_xid", "")).upper()
113
+ if any(str(item.get("finding_xid", "")).upper() == finding_xid for item in catalog.get("findings", [])):
114
+ results.append({"receipt": str(receipt_path), "status": "already_registered", "finding_xid": finding_xid})
115
+ continue
116
+ if errors or not apply_safe:
117
+ results.append({
118
+ "receipt": str(receipt_path),
119
+ "status": "pending_review" if not errors else "invalid",
120
+ "errors": errors,
121
+ })
122
+ continue
123
+ if matches:
124
+ target = matches[0]
125
+ else:
126
+ target_xid = str(receipt.get("target_xid", "")).upper() or _derived_xid(*identity)
127
+ target = {
128
+ "target_xid": target_xid,
129
+ "name": str(receipt.get("target_name") or receipt.get("target_hint") or identity[1]),
130
+ "repository_identity": identity[0],
131
+ "source_scope": identity[1],
132
+ "target_kind": identity[2],
133
+ "aliases": list(receipt.get("aliases") or []),
134
+ }
135
+ catalog.setdefault("targets", []).append(target)
136
+ artifact = root_path / str(receipt["artifact_path"])
137
+ slug = re.sub(r"[^a-z0-9]+", "_", str(target["name"]).lower()).strip("_") or "target"
138
+ detail_relative = Path("knowledge/source_analysis") / f"{finding_xid.lower()}_{slug}_structure_findings.md"
139
+ detail = root_path / detail_relative
140
+ body = artifact.read_text(encoding="utf-8")
141
+ if f"xid: {finding_xid}" not in body:
142
+ body = f'<!-- xid: {finding_xid} -->\n<a id="xid-{finding_xid}"></a>\n\n' + body
143
+ detail.parent.mkdir(parents=True, exist_ok=True)
144
+ detail.write_text(body, encoding="utf-8")
145
+ finding = {
146
+ "finding_xid": finding_xid,
147
+ "target_xid": target["target_xid"],
148
+ "title": str(receipt.get("title") or f"{target['name']} structure findings"),
149
+ "analysis_kinds": list(receipt.get("analysis_kinds") or ["source_structure"]),
150
+ "status": "current",
151
+ "source_revision": str(receipt["source_revision"]),
152
+ "last_verified": str(receipt["analyzed_at"]),
153
+ "coverage": str(receipt.get("coverage") or "See canonical finding detail."),
154
+ "unresolved_verification": str(receipt.get("unresolved_verification") or "None recorded."),
155
+ "detail_path": detail_relative.as_posix(),
156
+ }
157
+ catalog.setdefault("findings", []).append(finding)
158
+ receipt["status"] = "registered"
159
+ receipt["target_xid"] = target["target_xid"]
160
+ receipt["canonical_detail_path"] = detail_relative.as_posix()
161
+ receipt_path.write_text(yaml.safe_dump(receipt, sort_keys=False, allow_unicode=True), encoding="utf-8")
162
+ results.append({"receipt": str(receipt_path), **finding, "processing_status": "registered"})
163
+ changed = True
164
+ if changed:
165
+ save_catalog(catalog_file, catalog)
166
+ return {
167
+ "ok": all(item["status"] not in {"invalid"} for item in results),
168
+ "changed": changed,
169
+ "results": results,
170
+ "registered": sum(item.get("processing_status") == "registered" for item in results),
171
+ "pending_review": sum(item["status"] == "pending_review" for item in results),
172
+ }
173
+
174
+
175
+ def reconcile_receipts(root: str | Path, reports: str | Path, inbox: str | Path) -> dict[str, Any]:
176
+ root_path = Path(root).resolve()
177
+ inbox_path = root_path / inbox
178
+ inbox_path.mkdir(parents=True, exist_ok=True)
179
+ existing = {
180
+ str(data.get("artifact_path"))
181
+ for path in inbox_path.glob("*.yaml")
182
+ if isinstance((data := yaml.safe_load(path.read_text(encoding="utf-8"))), dict)
183
+ }
184
+ created: list[str] = []
185
+ pattern = re.compile(r"^candidate_xid:\s*([A-Fa-f0-9]{12})\s*$", re.MULTILINE)
186
+ for report in sorted((root_path / reports).glob("*.md")):
187
+ relative = report.relative_to(root_path).as_posix()
188
+ if relative in existing:
189
+ continue
190
+ text = report.read_text(encoding="utf-8")
191
+ match = pattern.search(text[:2000])
192
+ if not match:
193
+ continue
194
+ xid = match.group(1).upper()
195
+ receipt = {"schema": RECEIPT_SCHEMA, "candidate_xid": xid, "artifact_path": relative, "status": "incomplete"}
196
+ target = inbox_path / f"{date.today().isoformat()}_{xid}.yaml"
197
+ target.write_text(yaml.safe_dump(receipt, sort_keys=False), encoding="utf-8")
198
+ created.append(str(target))
199
+ return {"ok": True, "created": created, "count": len(created)}
@@ -0,0 +1,119 @@
1
+ """Stable tool-ID registry and client-side execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ToolContract:
19
+ tool_id: str
20
+ xid: str
21
+ path: str
22
+ execution_location: str
23
+ side_effects: str
24
+ content_hash: str
25
+
26
+ def to_dict(self) -> dict[str, str]:
27
+ return {
28
+ "tool_id": self.tool_id,
29
+ "xid": self.xid,
30
+ "path": self.path,
31
+ "execution_location": self.execution_location,
32
+ "side_effects": self.side_effects,
33
+ "content_hash": self.content_hash,
34
+ }
35
+
36
+
37
+ def load_tool_contracts(root: str | Path = ".", manifest: str = "tools/contracts.yaml") -> list[ToolContract]:
38
+ root_path = Path(root).resolve()
39
+ data = yaml.safe_load((root_path / manifest).read_text(encoding="utf-8"))
40
+ if not isinstance(data, dict) or data.get("schema") != "xrefkit.tool_contracts/v1":
41
+ raise ValueError("unsupported tool contract manifest")
42
+ contracts: list[ToolContract] = []
43
+ ids: set[str] = set()
44
+ xids: set[str] = set()
45
+ for item in data.get("tools", []):
46
+ tool_id = str(item["tool_id"])
47
+ xid = str(item["xid"]).upper()
48
+ if tool_id in ids:
49
+ raise ValueError(f"duplicate tool_id: {tool_id}")
50
+ if xid in xids:
51
+ raise ValueError(f"duplicate tool XID: {xid}")
52
+ path = root_path / str(item["path"])
53
+ if not path.is_file():
54
+ raise ValueError(f"tool path not found: {path}")
55
+ ids.add(tool_id)
56
+ xids.add(xid)
57
+ contracts.append(
58
+ ToolContract(
59
+ tool_id=tool_id,
60
+ xid=xid,
61
+ path=str(item["path"]),
62
+ execution_location=str(item["execution_location"]),
63
+ side_effects=str(item["side_effects"]),
64
+ content_hash=hashlib.sha256(path.read_bytes()).hexdigest(),
65
+ )
66
+ )
67
+ return contracts
68
+
69
+
70
+ def _find(contracts: list[ToolContract], identity: str) -> ToolContract:
71
+ key = identity.upper()
72
+ for contract in contracts:
73
+ if contract.tool_id == identity or contract.xid == key:
74
+ return contract
75
+ raise KeyError(f"unknown tool: {identity}")
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ parser = argparse.ArgumentParser(prog="xrefkit tools")
80
+ parser.add_argument("--root", default=".")
81
+ parser.add_argument("--manifest", default="tools/contracts.yaml")
82
+ sub = parser.add_subparsers(dest="command", required=True)
83
+ list_parser = sub.add_parser("list")
84
+ list_parser.add_argument("--json", action="store_true")
85
+ show = sub.add_parser("show")
86
+ show.add_argument("tool")
87
+ show.add_argument("--json", action="store_true")
88
+ run = sub.add_parser("run")
89
+ run.add_argument("tool")
90
+ run.add_argument("tool_args", nargs=argparse.REMAINDER)
91
+ args = parser.parse_args(argv)
92
+ try:
93
+ contracts = load_tool_contracts(args.root, args.manifest)
94
+ if args.command == "list":
95
+ payload: Any = [contract.to_dict() for contract in contracts]
96
+ else:
97
+ contract = _find(contracts, args.tool)
98
+ if args.command == "show":
99
+ payload = contract.to_dict()
100
+ else:
101
+ if contract.execution_location != "client":
102
+ raise ValueError(f"tool is not client executable: {contract.tool_id}")
103
+ command = [sys.executable, str(Path(args.root) / contract.path), *args.tool_args]
104
+ return subprocess.run(command, check=False).returncode
105
+ except (OSError, KeyError, ValueError, yaml.YAMLError) as exc:
106
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
107
+ return 1
108
+ as_json = bool(getattr(args, "json", False))
109
+ if as_json:
110
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
111
+ elif isinstance(payload, list):
112
+ for item in payload:
113
+ print(f"{item['tool_id']}\t{item['xid']}\t{item['execution_location']}")
114
+ else:
115
+ print(f"{payload['tool_id']}\t{payload['xid']}\t{payload['path']}")
116
+ return 0
117
+
118
+
119
+ __all__ = ["ToolContract", "load_tool_contracts", "main"]
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+
4
+ raise SystemExit(main())
xrefkit/v2_cli.py ADDED
@@ -0,0 +1,130 @@
1
+ """Minimal CLI for XRefKit v2 MVP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from typing import Sequence
8
+
9
+ from .discovery import discover_skill_packages, package_list_rows
10
+ from .loaders import load_server_config
11
+ from .resolver import EffectiveSkillResolver
12
+ from .workspace import build_registry
13
+
14
+
15
+ def _add_workspace_args(parser: argparse.ArgumentParser) -> None:
16
+ parser.add_argument("--package-manifest", action="append", default=[], help="Path to package_manifest.yaml")
17
+ parser.add_argument("--local-manifest", help="Path to xrefkit.local/local_manifest.yaml")
18
+ parser.add_argument("--enable-entry-point-discovery", action="store_true", help="Load enabled packages from Python entry points")
19
+ parser.add_argument("--enabled-package", action="append", default=[], help="Package id enabled for resolver use")
20
+
21
+
22
+ def _enabled_packages_from_args(args: argparse.Namespace) -> set[str]:
23
+ enabled = set(args.enabled_package or [])
24
+ server_config = getattr(args, "server_config", None)
25
+ if server_config:
26
+ config = load_server_config(server_config)
27
+ enabled.update(config.packages.enabled)
28
+ return enabled
29
+
30
+
31
+ def _bundle_tree(bundle: object) -> str:
32
+ # Deliberately use model_dump so this function stays stable if the model
33
+ # gains computed properties later.
34
+ data = bundle.model_dump(mode="json") # type: ignore[attr-defined]
35
+ lines = [f"effective_skill: {data['effective_skill_id']}", f"mode: {data['resolution_mode']}"]
36
+ lines.append("loaded:")
37
+ for group, entries in data["loaded_texts"].items():
38
+ if not entries:
39
+ continue
40
+ lines.append(f" {group}:")
41
+ for entry in entries:
42
+ lines.append(f" - {entry['xid']} ({entry['load_reason']})")
43
+ lines.append("references:")
44
+ for group, entries in data["references"].items():
45
+ if entries:
46
+ lines.append(f" {group}: {', '.join(entries)}")
47
+ lines.append("required_outputs:")
48
+ for output in data["required_outputs"]:
49
+ lines.append(f" - {output}")
50
+ return "\n".join(lines)
51
+
52
+
53
+ def cmd_show_effective_skill(args: argparse.Namespace) -> int:
54
+ registry = build_registry(
55
+ package_manifests=args.package_manifest,
56
+ local_manifest_path=args.local_manifest,
57
+ discover_entry_points=args.enable_entry_point_discovery,
58
+ enabled_package_ids=_enabled_packages_from_args(args),
59
+ )
60
+ bundle = EffectiveSkillResolver(registry).resolve_entry(args.skill_id)
61
+ if args.mode == "tree":
62
+ print(_bundle_tree(bundle))
63
+ elif args.mode == "resolved-json":
64
+ print(json.dumps(bundle.model_dump(mode="json"), ensure_ascii=False, indent=2))
65
+ else:
66
+ raise NotImplementedError("true full materialize is not implemented in the MVP CLI")
67
+ return 0
68
+
69
+
70
+ def cmd_package_discover(args: argparse.Namespace) -> int:
71
+ rows = [
72
+ {
73
+ "entry_point": package.entry_point_name,
74
+ "package_id": package.package_id,
75
+ "version": package.version,
76
+ "manifest_path": str(package.manifest_path),
77
+ }
78
+ for package in discover_skill_packages()
79
+ ]
80
+ if args.json:
81
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
82
+ else:
83
+ for row in rows:
84
+ print(f"{row['package_id']}\t{row['version']}\t{row['manifest_path']}")
85
+ return 0
86
+
87
+
88
+ def cmd_package_list(args: argparse.Namespace) -> int:
89
+ rows = package_list_rows(enabled_package_ids=_enabled_packages_from_args(args))
90
+ if args.json:
91
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
92
+ else:
93
+ for row in rows:
94
+ status = "enabled" if row["enabled"] else "disabled"
95
+ print(f"{row['package_id']}\t{row['version']}\t{status}\t{row['manifest_path']}")
96
+ return 0
97
+
98
+
99
+ def build_parser() -> argparse.ArgumentParser:
100
+ parser = argparse.ArgumentParser(prog="xrefkit")
101
+ subparsers = parser.add_subparsers(dest="command", required=True)
102
+
103
+ package = subparsers.add_parser("package")
104
+ package_sub = package.add_subparsers(dest="package_command", required=True)
105
+ discover = package_sub.add_parser("discover")
106
+ discover.add_argument("--json", action="store_true")
107
+ discover.set_defaults(func=cmd_package_discover)
108
+
109
+ package_list = package_sub.add_parser("list")
110
+ package_list.add_argument("--json", action="store_true")
111
+ package_list.add_argument("--server-config", help="Path to xrefkit.server.toml with enabled packages")
112
+ package_list.add_argument("--enabled-package", action="append", default=[], help="Package id enabled for resolver use")
113
+ package_list.set_defaults(func=cmd_package_list)
114
+
115
+ show = subparsers.add_parser("show")
116
+ show_sub = show.add_subparsers(dest="show_command", required=True)
117
+ effective = show_sub.add_parser("effective-skill")
118
+ effective.add_argument("skill_id")
119
+ effective.add_argument("--mode", choices=["tree", "resolved-json"], default="tree")
120
+ effective.add_argument("--server-config", help="Path to xrefkit.server.toml with enabled packages")
121
+ _add_workspace_args(effective)
122
+ effective.set_defaults(func=cmd_show_effective_skill)
123
+
124
+ return parser
125
+
126
+
127
+ def main(argv: Sequence[str] | None = None) -> int:
128
+ parser = build_parser()
129
+ args = parser.parse_args(argv)
130
+ return args.func(args)
xrefkit/workspace.py ADDED
@@ -0,0 +1,117 @@
1
+ """Workspace assembly helpers for XRefKit v2 MVP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .discovery import enabled_discovered_packages
8
+ from .loaders import load_local_manifest
9
+ from .loaders import load_local_domain_skill, load_package_manifest, load_skill_definition
10
+ from .registry import XRefKitRegistry
11
+ from .models.common import version_satisfies
12
+
13
+
14
+ CORE_PROTOCOL_VERSION = "2.0.0"
15
+
16
+
17
+ def _contained_path(root: Path, relative_path: str, *, label: str) -> Path:
18
+ root = root.resolve()
19
+ candidate = (root / relative_path).resolve()
20
+ try:
21
+ candidate.relative_to(root)
22
+ except ValueError as exc:
23
+ raise ValueError(f"{label} escapes root: {relative_path}") from exc
24
+ return candidate
25
+
26
+
27
+ def _validate_skill_paths(root: Path, skill) -> None:
28
+ _contained_path(root, skill.entry.path, label="skill entry")
29
+ for fragment in skill.required_fragments:
30
+ _contained_path(root, fragment.path, label=f"skill fragment {fragment.id}")
31
+ for branch in skill.branches:
32
+ _contained_path(root, branch.path, label=f"skill branch {branch.id}")
33
+
34
+
35
+ def build_registry(
36
+ *,
37
+ package_manifests: list[str | Path],
38
+ local_manifest_path: str | Path | None = None,
39
+ discover_entry_points: bool = False,
40
+ enabled_package_ids: set[str] | None = None,
41
+ ) -> XRefKitRegistry:
42
+ registry = XRefKitRegistry()
43
+ manifest_paths = [Path(path) for path in package_manifests]
44
+ if discover_entry_points:
45
+ enabled = enabled_package_ids or set()
46
+ manifest_paths.extend(candidate.manifest_path for candidate in enabled_discovered_packages(enabled_package_ids=enabled))
47
+
48
+ for manifest_path in manifest_paths:
49
+ manifest_path = Path(manifest_path).resolve()
50
+ manifest = load_package_manifest(manifest_path)
51
+ package_root = manifest_path.parent.resolve()
52
+ if not version_satisfies(CORE_PROTOCOL_VERSION, manifest.requires.xrefkit_core):
53
+ raise ValueError(
54
+ f"package {manifest.package_id} requires XRefKit core {manifest.requires.xrefkit_core}, "
55
+ f"current core is {CORE_PROTOCOL_VERSION}"
56
+ )
57
+ for asset_group in (
58
+ manifest.provides.skills,
59
+ manifest.provides.fragments,
60
+ manifest.provides.knowledge,
61
+ manifest.provides.review_axes,
62
+ manifest.provides.schemas,
63
+ manifest.provides.templates,
64
+ ):
65
+ for asset in asset_group:
66
+ _contained_path(package_root, asset.path, label=f"package asset {asset.id}")
67
+ registry.add_package(manifest, package_root)
68
+ for provided in manifest.provides.skills:
69
+ skill_path = _contained_path(package_root, provided.path, label=f"package skill {provided.id}")
70
+ skill = load_skill_definition(skill_path)
71
+ _validate_skill_paths(package_root, skill)
72
+ registry.add_package_skill(
73
+ package_id=manifest.package_id,
74
+ skill=skill,
75
+ path=skill_path,
76
+ root=package_root,
77
+ )
78
+ registry.add_package_knowledge_from_manifest(manifest, package_root)
79
+ if local_manifest_path is not None:
80
+ local_manifest_path = Path(local_manifest_path).resolve()
81
+ local_manifest = load_local_manifest(local_manifest_path)
82
+ local_root = local_manifest_path.parent.resolve()
83
+ if not version_satisfies(CORE_PROTOCOL_VERSION, local_manifest.requires.xrefkit_core):
84
+ raise ValueError(
85
+ f"local manifest requires XRefKit core {local_manifest.requires.xrefkit_core}, "
86
+ f"current core is {CORE_PROTOCOL_VERSION}"
87
+ )
88
+ for asset_group in (
89
+ local_manifest.mounts.knowledge,
90
+ local_manifest.mounts.templates,
91
+ local_manifest.mounts.schemas,
92
+ local_manifest.mounts.review_axes,
93
+ ):
94
+ for asset in asset_group:
95
+ _contained_path(local_root, asset.path, label=f"local asset {asset.id}")
96
+ for mount in local_manifest.mounts.skills:
97
+ _contained_path(local_root, mount.path, label="local skill")
98
+ for requirement in local_manifest.requires.skill_packages:
99
+ package = registry.packages.get(requirement.package_id)
100
+ if package is None:
101
+ raise ValueError(f"required package is not loaded: {requirement.package_id}")
102
+ if not version_satisfies(package.manifest.version, requirement.version):
103
+ raise ValueError(
104
+ f"package {requirement.package_id} version {package.manifest.version} "
105
+ f"does not satisfy local requirement {requirement.version}"
106
+ )
107
+ registry.add_local_manifest_assets(local_root, local_manifest)
108
+ for mount in local_manifest.mounts.skills:
109
+ skill_path = _contained_path(local_root, mount.path, label="local skill")
110
+ skill = load_local_domain_skill(skill_path)
111
+ registry.add_local_skill(
112
+ skill=skill,
113
+ path=skill_path,
114
+ root=local_root,
115
+ local_id=local_manifest.local_id,
116
+ )
117
+ return registry