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/discovery.py ADDED
@@ -0,0 +1,85 @@
1
+ """Python entry point discovery for installed XRefKit Skill Packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Callable, Iterable
8
+
9
+ from importlib import metadata
10
+
11
+ from .loaders import load_package_manifest
12
+ from .models import PackageManifest
13
+
14
+
15
+ ENTRY_POINT_GROUP = "xrefkit.skill_packages"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DiscoveredSkillPackage:
20
+ entry_point_name: str
21
+ package_root: Path
22
+ manifest_path: Path
23
+ manifest: PackageManifest
24
+
25
+ @property
26
+ def package_id(self) -> str:
27
+ return self.manifest.package_id
28
+
29
+ @property
30
+ def version(self) -> str:
31
+ return self.manifest.version
32
+
33
+
34
+ def _select_entry_points(group: str) -> Iterable[metadata.EntryPoint]:
35
+ entry_points = metadata.entry_points()
36
+ if hasattr(entry_points, "select"):
37
+ return entry_points.select(group=group)
38
+ return entry_points.get(group, []) # type: ignore[union-attr]
39
+
40
+
41
+ def discover_skill_packages(group: str = ENTRY_POINT_GROUP) -> list[DiscoveredSkillPackage]:
42
+ discovered: list[DiscoveredSkillPackage] = []
43
+ for entry_point in _select_entry_points(group):
44
+ package_root_factory = entry_point.load()
45
+ if not callable(package_root_factory):
46
+ raise ValueError(f"entry point {entry_point.name} did not load a callable")
47
+ package_root = Path(package_root_factory())
48
+ manifest_path = package_root / "package_manifest.yaml"
49
+ manifest = load_package_manifest(manifest_path)
50
+ discovered.append(
51
+ DiscoveredSkillPackage(
52
+ entry_point_name=entry_point.name,
53
+ package_root=package_root,
54
+ manifest_path=manifest_path,
55
+ manifest=manifest,
56
+ )
57
+ )
58
+ return discovered
59
+
60
+
61
+ def enabled_discovered_packages(
62
+ *,
63
+ enabled_package_ids: set[str],
64
+ discovered: list[DiscoveredSkillPackage] | None = None,
65
+ ) -> list[DiscoveredSkillPackage]:
66
+ candidates = discovered if discovered is not None else discover_skill_packages()
67
+ return [candidate for candidate in candidates if candidate.package_id in enabled_package_ids]
68
+
69
+
70
+ def package_list_rows(
71
+ *,
72
+ enabled_package_ids: set[str],
73
+ discovered: list[DiscoveredSkillPackage] | None = None,
74
+ ) -> list[dict]:
75
+ candidates = discovered if discovered is not None else discover_skill_packages()
76
+ return [
77
+ {
78
+ "entry_point": candidate.entry_point_name,
79
+ "package_id": candidate.package_id,
80
+ "version": candidate.version,
81
+ "manifest_path": str(candidate.manifest_path),
82
+ "enabled": candidate.package_id in enabled_package_ids,
83
+ }
84
+ for candidate in candidates
85
+ ]
xrefkit/gate.py ADDED
@@ -0,0 +1,428 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import json
5
+ import re
6
+ import subprocess
7
+ import hashlib
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ # Deterministic, machine-only diff-content checks for the Agent Diff Review Gate.
12
+ # See knowledge/organization/180_agent_diff_review_gate_design.md (xid 7A2F4C8D1801).
13
+ #
14
+ # These checks are a forced-attention trigger, not a correctness judgment.
15
+ # They run with no LLM and are decoupled from the producer context: a clean
16
+ # result is a precondition for a `proceed` verdict, never a proof of correctness.
17
+
18
+ # block -> hard condition; gate verdict must be `blocked`
19
+ # review -> must be looked at; gate verdict must be at least `needs-review`
20
+ DISPOSITION_BLOCK = "block"
21
+ DISPOSITION_REVIEW = "review"
22
+
23
+ # Aggregate eval verdicts. `clean` is the only state that lets the gate reach
24
+ # `proceed`; it still does not prove correctness.
25
+ EVAL_CLEAN = "clean"
26
+ EVAL_NEEDS_REVIEW = "needs-review"
27
+ EVAL_BLOCKED = "blocked"
28
+
29
+
30
+ TEST_PATH_PATTERNS = (
31
+ "*test*.cs",
32
+ "*tests.cs",
33
+ "test_*.py",
34
+ "*_test.py",
35
+ "*.spec.ts",
36
+ "*.test.ts",
37
+ "*.spec.js",
38
+ "*.test.js",
39
+ )
40
+ TEST_PATH_SEGMENTS = ("test", "tests", "__tests__", "spec")
41
+
42
+ # Removed test declarations (on `-` lines) signal a deleted test.
43
+ TEST_DECL_RE = re.compile(
44
+ r"\[\s*Fact\b|\[\s*Theory\b|\[\s*Test\b|\[\s*TestMethod\b"
45
+ r"|\bdef\s+test_\w+|\bit\s*\(|\btest\s*\(|\bdescribe\s*\("
46
+ )
47
+
48
+ # Added lines that disable or neuter a test.
49
+ TEST_DISABLE_RES = (
50
+ re.compile(r"\[\s*Fact\s*\(\s*Skip\s*="),
51
+ re.compile(r"\[\s*Theory\s*\(\s*Skip\s*="),
52
+ re.compile(r"\[\s*Ignore\b"),
53
+ re.compile(r"@pytest\.mark\.skip\b"),
54
+ re.compile(r"@pytest\.mark\.xfail\b"),
55
+ re.compile(r"@unittest\.skip\b"),
56
+ re.compile(r"\b(?:xit|xdescribe)\s*\("),
57
+ re.compile(r"\b(?:it|describe|test)\.skip\s*\("),
58
+ re.compile(r"Assert\.True\s*\(\s*true\s*\)", re.IGNORECASE),
59
+ re.compile(r"Assert\.IsTrue\s*\(\s*true\s*\)", re.IGNORECASE),
60
+ )
61
+
62
+ # Schema / migration change signals: by path or by added DDL statements.
63
+ MIGRATION_PATH_PATTERNS = (
64
+ "*/migrations/*",
65
+ "*/migration/*",
66
+ "*.sql",
67
+ "*.edmx",
68
+ "*schema*.sql",
69
+ "*_migration.*",
70
+ )
71
+ DDL_RE = re.compile(
72
+ r"\b(?:ALTER\s+TABLE|CREATE\s+TABLE|DROP\s+TABLE|ADD\s+COLUMN|DROP\s+COLUMN"
73
+ r"|RENAME\s+COLUMN|ALTER\s+COLUMN|CREATE\s+INDEX|DROP\s+INDEX)\b",
74
+ re.IGNORECASE,
75
+ )
76
+
77
+ # Secret / credential leakage on added lines.
78
+ SECRET_RES = (
79
+ ("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----")),
80
+ ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
81
+ ("connection_string", re.compile(r"(?:Server|Data Source)\s*=.*?(?:Password|Pwd)\s*=", re.IGNORECASE)),
82
+ ("password_assignment", re.compile(r"\b(?:password|passwd|pwd)\s*[:=]\s*[\"']?[^\s\"']{6,}", re.IGNORECASE)),
83
+ ("api_token", re.compile(r"\b(?:api[_-]?key|api[_-]?token|access[_-]?token|secret[_-]?key)\s*[:=]\s*[\"']?[A-Za-z0-9_\-]{16,}", re.IGNORECASE)),
84
+ ("bearer_token", re.compile(r"\bBearer\s+[A-Za-z0-9_\-\.]{20,}")),
85
+ ("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}")),
86
+ ("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}")),
87
+ )
88
+
89
+ # Placeholder values that should not be flagged as real secrets.
90
+ SECRET_PLACEHOLDER_RE = re.compile(
91
+ r"(?:xxx+|\.\.\.|<[^>]+>|\$\{[^}]+\}|example|changeme|placeholder|dummy|your[_-]?\w+|redacted|\*\*\*)",
92
+ re.IGNORECASE,
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class DiffFile:
98
+ path: str
99
+ status: str # added | deleted | modified | renamed
100
+ added: list[tuple[int, str]] = field(default_factory=list) # (new line no, text)
101
+ removed: list[str] = field(default_factory=list)
102
+ old_path: str | None = None
103
+
104
+
105
+ @dataclass
106
+ class Finding:
107
+ check: str
108
+ disposition: str
109
+ path: str
110
+ line: int | None
111
+ evidence: str
112
+
113
+ def to_dict(self) -> dict[str, object]:
114
+ return {
115
+ "check": self.check,
116
+ "disposition": self.disposition,
117
+ "path": self.path,
118
+ "line": self.line,
119
+ "evidence": self.evidence,
120
+ }
121
+
122
+
123
+ def _read_diff_text(args) -> tuple[str | None, str | None]:
124
+ """Return (diff_text, error)."""
125
+ if args.diff:
126
+ if args.diff == "-":
127
+ import sys
128
+
129
+ return sys.stdin.read(), None
130
+ p = Path(args.diff)
131
+ if not p.is_file():
132
+ return None, f"diff file not found: {args.diff}"
133
+ return p.read_text(encoding="utf-8", errors="replace"), None
134
+
135
+ cmd = ["git", "-C", args.root, "diff", "--no-color", "--unified=3"]
136
+ if args.staged:
137
+ cmd.append("--cached")
138
+ if args.base:
139
+ cmd.append(args.base)
140
+ try:
141
+ out = subprocess.run(
142
+ cmd,
143
+ capture_output=True,
144
+ text=True,
145
+ encoding="utf-8",
146
+ errors="replace",
147
+ check=False,
148
+ )
149
+ except FileNotFoundError:
150
+ return None, "git not found; pass --diff to supply a unified diff file"
151
+ if out.returncode != 0:
152
+ return None, f"git diff failed: {out.stderr.strip()}"
153
+ return out.stdout, None
154
+
155
+
156
+ def parse_unified_diff(text: str) -> list[DiffFile]:
157
+ files: list[DiffFile] = []
158
+ cur: DiffFile | None = None
159
+ new_lineno = 0
160
+ pending_status = "modified"
161
+ old_path: str | None = None
162
+ rename_from: str | None = None
163
+ for raw in text.splitlines():
164
+ if raw.startswith("diff --git"):
165
+ cur = None
166
+ pending_status = "modified"
167
+ old_path = None
168
+ rename_from = None
169
+ continue
170
+ if raw.startswith("new file mode"):
171
+ pending_status = "added"
172
+ continue
173
+ if raw.startswith("deleted file mode"):
174
+ pending_status = "deleted"
175
+ continue
176
+ if raw.startswith("rename from "):
177
+ pending_status = "renamed"
178
+ rename_from = raw[len("rename from "):].strip()
179
+ continue
180
+ if raw.startswith("rename to "):
181
+ pending_status = "renamed"
182
+ rename_to = raw[len("rename to "):].strip()
183
+ cur = DiffFile(path=rename_to, status="renamed", old_path=rename_from)
184
+ files.append(cur)
185
+ continue
186
+ if raw.startswith("rename "):
187
+ pending_status = "renamed"
188
+ continue
189
+ if raw.startswith("--- "):
190
+ old_path = raw[4:].strip()
191
+ if old_path.startswith("a/"):
192
+ old_path = old_path[2:]
193
+ continue
194
+ if raw.startswith("+++ "):
195
+ path = raw[4:].strip()
196
+ if path.startswith("b/"):
197
+ path = path[2:]
198
+ if path == "/dev/null":
199
+ # Deleted files have no new path; retain the old path for gates.
200
+ pending_status = "deleted"
201
+ path = old_path or "(deleted)"
202
+ cur = DiffFile(path=path, status=pending_status, old_path=old_path if pending_status == "deleted" else None)
203
+ files.append(cur)
204
+ continue
205
+ if raw.startswith("@@"):
206
+ m = re.search(r"\+(\d+)", raw)
207
+ new_lineno = int(m.group(1)) if m else 0
208
+ continue
209
+ if cur is None:
210
+ continue
211
+ if raw.startswith("+") and not raw.startswith("+++"):
212
+ cur.added.append((new_lineno, raw[1:]))
213
+ new_lineno += 1
214
+ elif raw.startswith("-") and not raw.startswith("---"):
215
+ cur.removed.append(raw[1:])
216
+ elif raw.startswith(" "):
217
+ new_lineno += 1
218
+ return files
219
+
220
+
221
+ def _is_test_path(path: str) -> bool:
222
+ low = path.lower()
223
+ name = low.rsplit("/", 1)[-1]
224
+ if any(fnmatch.fnmatch(name, pat) for pat in TEST_PATH_PATTERNS):
225
+ return True
226
+ return any(seg in low.split("/") for seg in TEST_PATH_SEGMENTS)
227
+
228
+
229
+ def _matches_any(path: str, patterns: tuple[str, ...]) -> bool:
230
+ low = path.lower()
231
+ return any(fnmatch.fnmatch(low, pat) for pat in patterns)
232
+
233
+
234
+ def check_removed_tests(files: list[DiffFile]) -> list[Finding]:
235
+ out: list[Finding] = []
236
+ for f in files:
237
+ source_path = f.old_path or f.path
238
+ if f.status == "deleted" and _is_test_path(source_path):
239
+ out.append(Finding("test_removed", DISPOSITION_REVIEW, source_path, None, "test file deleted"))
240
+ continue
241
+ if f.status == "renamed" and _is_test_path(source_path) and not _is_test_path(f.path):
242
+ out.append(Finding("test_removed", DISPOSITION_REVIEW, source_path, None, f"test file renamed to {f.path}"))
243
+ continue
244
+ if not _is_test_path(f.path):
245
+ continue
246
+ for text in f.removed:
247
+ if TEST_DECL_RE.search(text):
248
+ out.append(Finding("test_removed", DISPOSITION_REVIEW, f.path, None, text.strip()[:160]))
249
+ return out
250
+
251
+
252
+ def check_disabled_tests(files: list[DiffFile]) -> list[Finding]:
253
+ out: list[Finding] = []
254
+ for f in files:
255
+ for lineno, text in f.added:
256
+ for rx in TEST_DISABLE_RES:
257
+ if rx.search(text):
258
+ out.append(Finding("test_disabled", DISPOSITION_REVIEW, f.path, lineno, text.strip()[:160]))
259
+ break
260
+ return out
261
+
262
+
263
+ def check_schema_migration(files: list[DiffFile]) -> list[Finding]:
264
+ out: list[Finding] = []
265
+ for f in files:
266
+ if _matches_any(f.path, MIGRATION_PATH_PATTERNS):
267
+ out.append(Finding("schema_migration", DISPOSITION_REVIEW, f.path, None, f"migration/schema path ({f.status})"))
268
+ continue
269
+ for lineno, text in f.added:
270
+ if DDL_RE.search(text):
271
+ out.append(Finding("schema_migration", DISPOSITION_REVIEW, f.path, lineno, text.strip()[:160]))
272
+ break
273
+ return out
274
+
275
+
276
+ def check_out_of_scope(files: list[DiffFile], scope: list[str]) -> list[Finding]:
277
+ if not scope:
278
+ return []
279
+ out: list[Finding] = []
280
+ for f in files:
281
+ low = f.path.lower()
282
+ if not any(fnmatch.fnmatch(low, pat.lower()) for pat in scope):
283
+ out.append(Finding("out_of_scope", DISPOSITION_REVIEW, f.path, None, "changed file outside declared scope"))
284
+ return out
285
+
286
+
287
+ def check_secret_leak(files: list[DiffFile]) -> list[Finding]:
288
+ out: list[Finding] = []
289
+ for f in files:
290
+ for lineno, text in f.added:
291
+ for name, rx in SECRET_RES:
292
+ if rx.search(text) and not SECRET_PLACEHOLDER_RE.search(text):
293
+ out.append(Finding(f"secret_leak:{name}", DISPOSITION_BLOCK, f.path, lineno, text.strip()[:120]))
294
+ break
295
+ return out
296
+
297
+
298
+ def run_evals(files: list[DiffFile], scope: list[str]) -> list[Finding]:
299
+ findings: list[Finding] = []
300
+ findings += check_removed_tests(files)
301
+ findings += check_disabled_tests(files)
302
+ findings += check_schema_migration(files)
303
+ findings += check_out_of_scope(files, scope)
304
+ findings += check_secret_leak(files)
305
+ return findings
306
+
307
+
308
+ def aggregate(findings: list[Finding]) -> str:
309
+ if any(f.disposition == DISPOSITION_BLOCK for f in findings):
310
+ return EVAL_BLOCKED
311
+ if findings:
312
+ return EVAL_NEEDS_REVIEW
313
+ return EVAL_CLEAN
314
+
315
+
316
+ def cmd_gate(args) -> int:
317
+ if args.gate_cmd != "eval":
318
+ return 2
319
+
320
+ if getattr(args, "profile", None) == "command-cutover-readiness":
321
+ return _command_cutover_readiness(args)
322
+
323
+ text, err = _read_diff_text(args)
324
+ if err is not None:
325
+ result = {"ok": False, "errors": [err], "eval_verdict": EVAL_BLOCKED, "findings": []}
326
+ if args.json:
327
+ print(json.dumps(result, ensure_ascii=False, indent=2))
328
+ else:
329
+ print(f"error: {err}")
330
+ return 1
331
+
332
+ files = parse_unified_diff(text or "")
333
+ scope = list(args.scope or [])
334
+ findings = run_evals(files, scope)
335
+ verdict = aggregate(findings)
336
+
337
+ result = {
338
+ "ok": True,
339
+ "errors": [],
340
+ "eval_verdict": verdict,
341
+ "files_changed": len(files),
342
+ "scope": scope,
343
+ "findings": [f.to_dict() for f in findings],
344
+ }
345
+
346
+ if args.json:
347
+ print(json.dumps(result, ensure_ascii=False, indent=2))
348
+ else:
349
+ print(f"eval_verdict: {verdict}")
350
+ print(f"files_changed: {len(files)}")
351
+ if scope:
352
+ print(f"scope: {' '.join(scope)}")
353
+ if not findings:
354
+ print("findings: none (clean; precondition for proceed met, not proof of correctness)")
355
+ else:
356
+ print(f"findings: {len(findings)}")
357
+ for f in findings:
358
+ loc = f"{f.path}:{f.line}" if f.line else f.path
359
+ print(f" [{f.disposition}] {f.check} @ {loc}")
360
+ print(f" {f.evidence}")
361
+
362
+ # Non-zero exit when the diff cannot proceed to CI, so the gate can run in
363
+ # scripts and pre-CI hooks.
364
+ if verdict != EVAL_CLEAN:
365
+ return 1
366
+ return 0
367
+
368
+
369
+ def _command_cutover_readiness(args) -> int:
370
+ root = Path(args.root).resolve()
371
+ checks = [
372
+ ("package_manifest", (root / "pyproject.toml").is_file()),
373
+ ("instance_manifest", (root / "xrefkit.toml").is_file()),
374
+ ("base_generation_pointer", (root / "xrefkit/resources/base/current.json").is_file()),
375
+ ("compiled_contract", (root / "xrefkit/resources/base/contracts.json").is_file()),
376
+ ("compiled_model_body", (root / "xrefkit/resources/base/model_body.md").is_file()),
377
+ ("tool_contracts", (root / "tools/contracts.yaml").is_file()),
378
+ ("target_catalog", (root / "knowledge/source_analysis/source_structure_catalog.yaml").is_file()),
379
+ ("integrated_mcp", (root / "xrefkit/mcp/server.py").is_file()),
380
+ ("site_builder", (root / "tools/site_build.py").is_file()),
381
+ ("site_manifest", (root / "site/source_manifest.json").is_file()),
382
+ ]
383
+ failed = [name for name, ok in checks if not ok]
384
+ authority = "missing"
385
+ instance_path = root / "xrefkit.toml"
386
+ if instance_path.is_file():
387
+ match = re.search(
388
+ r'^command_authority\s*=\s*"([^"]+)"',
389
+ instance_path.read_text(encoding="utf-8"),
390
+ re.MULTILINE,
391
+ )
392
+ authority = match.group(1) if match else "missing"
393
+ if authority != "legacy_authoritative":
394
+ failed.append("legacy_authority_entry_state")
395
+
396
+ inputs = sorted(
397
+ str(path.relative_to(root)).replace("\\", "/")
398
+ for path in root.rglob("*")
399
+ if path.is_file()
400
+ and not any(part in {".git", "__pycache__", ".pytest_cache"} for part in path.parts)
401
+ and (
402
+ path.name in {"pyproject.toml", "xrefkit.toml", "contracts.yaml", "source_manifest.json"}
403
+ or "xrefkit" in path.parts
404
+ or path.suffix in {".yaml", ".yml"}
405
+ )
406
+ )
407
+ digest = hashlib.sha256()
408
+ for relative in inputs:
409
+ digest.update(relative.encode("utf-8"))
410
+ digest.update((root / relative).read_bytes())
411
+ result = {
412
+ "ok": not failed,
413
+ "profile": "command-cutover-readiness",
414
+ "entry_authority": authority,
415
+ "checks": [{"id": name, "ok": ok} for name, ok in checks],
416
+ "failed": failed,
417
+ "evidence_hash": digest.hexdigest(),
418
+ "input_count": len(inputs),
419
+ }
420
+ if args.json:
421
+ print(json.dumps(result, ensure_ascii=False, indent=2))
422
+ else:
423
+ print(f"profile: {result['profile']}")
424
+ print(f"readiness: {'passed' if result['ok'] else 'failed'}")
425
+ print(f"evidence_hash: {result['evidence_hash']}")
426
+ for name in failed:
427
+ print(f"- failed: {name}")
428
+ return 0 if result["ok"] else 1