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/xref.py ADDED
@@ -0,0 +1,1048 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import dataclasses
5
+ import json
6
+ import os
7
+ import re
8
+ import secrets
9
+ import hashlib
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+
16
+ XID_COMMENT_RE = re.compile(r"<!--\s*xid\s*:\s*([A-Za-z0-9_-]{1,64})\s*-->", re.IGNORECASE)
17
+ SOURCE_XID_COMMENT_RE = re.compile(
18
+ r"^\s*(?://|#|--|'|/\*|<!--)\s*xid\s*:\s*([A-Za-z0-9_-]{1,64})\s*(?:\*/|-->)?\s*$",
19
+ re.IGNORECASE | re.MULTILINE,
20
+ )
21
+ CANONICAL_XID_RE = re.compile(r"^[A-F0-9]{12}$")
22
+ XID_ANCHOR_RE = re.compile(
23
+ r"""<a\s+[^>]*id=["']xid-([A-Za-z0-9_-]{1,64})["'][^>]*>\s*</a>""",
24
+ re.IGNORECASE,
25
+ )
26
+ WIKI_XREF_RE = re.compile(r"\[\[([A-Za-z0-9_-]{6,64})\]\]")
27
+ MANAGED_FRAGMENT_RE = re.compile(r"#xid-([A-Za-z0-9_-]{6,64})\b")
28
+ BARE_MANAGED_REF_RE = re.compile(
29
+ r"(?<![A-Za-z0-9_./-])"
30
+ r"(?P<path>(?:(?:\.\.?|[A-Za-z0-9_.-]+)/)*[A-Za-z0-9_.-]+\.md)"
31
+ r"#xid-(?P<xid>[A-Za-z0-9_-]{6,64})\b"
32
+ )
33
+ _PLACEHOLDER_XIDS = {"TBD", "TODO", "TEMP", "PLACEHOLDER"}
34
+ _XID_INDEX_CACHE_VERSION = 2
35
+ _XID_RELATION_SECTION_TITLE = "互換性(XID関係)"
36
+ XREF_SOURCE_SUFFIXES = {
37
+ ".py",
38
+ ".ps1",
39
+ ".sh",
40
+ ".cs",
41
+ ".fs",
42
+ ".vb",
43
+ ".js",
44
+ ".jsx",
45
+ ".ts",
46
+ ".tsx",
47
+ ".java",
48
+ ".go",
49
+ ".rs",
50
+ ".c",
51
+ ".cc",
52
+ ".cpp",
53
+ ".h",
54
+ ".hpp",
55
+ ".sql",
56
+ ".yaml",
57
+ ".yml",
58
+ ".toml",
59
+ ".ini",
60
+ ".cfg",
61
+ ".css",
62
+ ".scss",
63
+ ".html",
64
+ ".xml",
65
+ ".svg",
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class XrefConfig:
71
+ root: str = "."
72
+ include: list[str] | None = None
73
+ exclude: list[str] | None = None
74
+
75
+ def resolved_root(self) -> Path:
76
+ return Path(self.root).resolve()
77
+
78
+ def resolved_include(self) -> list[str]:
79
+ return self.include if self.include is not None else ["docs", "agent", "knowledge", "capabilities", "skills", "packs", "tools"]
80
+
81
+ def resolved_exclude(self) -> set[str]:
82
+ # NOTE: `ja/` is a translation/archive area and is intentionally excluded
83
+ # from the managed XID index to avoid duplicate XIDs across languages.
84
+ base = {".git", ".xref", "node_modules", ".venv", "venv", "__pycache__", "ja"}
85
+ if self.exclude is None:
86
+ return base
87
+ return base.union(set(self.exclude))
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class DocInfo:
92
+ path: Path
93
+ xid: str
94
+ title: str | None
95
+ content_hash: str | None = None
96
+
97
+
98
+ def _xid_index_cache_path(root: Path) -> Path:
99
+ return root / ".xref" / "xid-index.json"
100
+
101
+
102
+ def _safe_read_json(path: Path) -> object | None:
103
+ try:
104
+ return json.loads(path.read_text(encoding="utf-8"))
105
+ except Exception:
106
+ return None
107
+
108
+
109
+ def _collect_fingerprints(
110
+ *,
111
+ root: Path,
112
+ include: Iterable[str],
113
+ exclude_names: set[str],
114
+ ) -> list[dict[str, object]]:
115
+ fps: list[dict[str, object]] = []
116
+ for p in _iter_xref_files(root, include, exclude_names):
117
+ try:
118
+ st = p.stat()
119
+ except OSError:
120
+ continue
121
+ fps.append(
122
+ {
123
+ "path": str(p.relative_to(root)).replace("\\", "/"),
124
+ "mtime_ns": int(st.st_mtime_ns),
125
+ "size": int(st.st_size),
126
+ }
127
+ )
128
+ fps.sort(key=lambda d: str(d["path"]))
129
+ return fps
130
+
131
+
132
+ def _fingerprints_equal(a: list[dict[str, object]], b: list[dict[str, object]]) -> bool:
133
+ if len(a) != len(b):
134
+ return False
135
+ for da, db in zip(a, b, strict=True):
136
+ if da.get("path") != db.get("path"):
137
+ return False
138
+ if int(da.get("mtime_ns", -1)) != int(db.get("mtime_ns", -2)):
139
+ return False
140
+ if int(da.get("size", -1)) != int(db.get("size", -2)):
141
+ return False
142
+ return True
143
+
144
+
145
+ def _try_load_cached_index(
146
+ *,
147
+ root: Path,
148
+ include: list[str],
149
+ exclude_names: set[str],
150
+ fingerprints: list[dict[str, object]],
151
+ ) -> tuple[dict[str, DocInfo], list[dict[str, str]]] | None:
152
+ cache_path = _xid_index_cache_path(root)
153
+ payload = _safe_read_json(cache_path)
154
+ if not isinstance(payload, dict):
155
+ return None
156
+
157
+ if int(payload.get("version", 0)) != _XID_INDEX_CACHE_VERSION:
158
+ return None
159
+ if payload.get("include") != include:
160
+ return None
161
+ if payload.get("exclude_names") != sorted(exclude_names):
162
+ return None
163
+
164
+ cached_fps = payload.get("fingerprints")
165
+ if not isinstance(cached_fps, list):
166
+ return None
167
+ if not _fingerprints_equal(fingerprints, cached_fps):
168
+ return None
169
+
170
+ cached_index = payload.get("index")
171
+ if not isinstance(cached_index, dict):
172
+ return None
173
+ cached_issues = payload.get("issues", [])
174
+ if not isinstance(cached_issues, list):
175
+ return None
176
+
177
+ index: dict[str, DocInfo] = {}
178
+ for xid, info in cached_index.items():
179
+ if not isinstance(xid, str) or not isinstance(info, dict):
180
+ return None
181
+ rel = info.get("path")
182
+ if not isinstance(rel, str):
183
+ return None
184
+ title = info.get("title")
185
+ if title is not None and not isinstance(title, str):
186
+ return None
187
+ content_hash = info.get("content_hash")
188
+ if content_hash is not None and not isinstance(content_hash, str):
189
+ return None
190
+ index[xid] = DocInfo(path=(root / rel), xid=xid, title=title, content_hash=content_hash)
191
+
192
+ issues: list[dict[str, str]] = []
193
+ for it in cached_issues:
194
+ if isinstance(it, dict):
195
+ issues.append({str(k): str(v) for k, v in it.items()})
196
+ return index, issues
197
+
198
+
199
+ def _write_index_cache(
200
+ *,
201
+ root: Path,
202
+ include: list[str],
203
+ exclude_names: set[str],
204
+ fingerprints: list[dict[str, object]],
205
+ index: dict[str, DocInfo],
206
+ issues: list[dict[str, str]],
207
+ ) -> None:
208
+ cache_path = _xid_index_cache_path(root)
209
+ payload = {
210
+ "version": _XID_INDEX_CACHE_VERSION,
211
+ "generated_at": int(time.time()),
212
+ "include": include,
213
+ "exclude_names": sorted(exclude_names),
214
+ "fingerprints": fingerprints,
215
+ "index": {
216
+ xid: {
217
+ "path": str(info.path.relative_to(root)).replace("\\", "/"),
218
+ "title": info.title,
219
+ "content_hash": info.content_hash,
220
+ }
221
+ for xid, info in index.items()
222
+ },
223
+ "issues": issues,
224
+ }
225
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
226
+ cache_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
227
+
228
+
229
+ def _iter_markdown_files(root: Path, include: Iterable[str], exclude_names: set[str]) -> Iterable[Path]:
230
+ for top in include:
231
+ base = (root / top)
232
+ if not base.exists():
233
+ continue
234
+ for p in base.rglob("*"):
235
+ if not p.is_file():
236
+ continue
237
+ if p.suffix.lower() not in {".md", ".mdx"}:
238
+ continue
239
+ if any(part in exclude_names for part in p.parts):
240
+ continue
241
+ yield p
242
+
243
+
244
+ def _iter_xref_files(root: Path, include: Iterable[str], exclude_names: set[str]) -> Iterable[Path]:
245
+ for top in include:
246
+ base = (root / top)
247
+ if not base.exists():
248
+ continue
249
+ for p in base.rglob("*"):
250
+ if not p.is_file():
251
+ continue
252
+ suffix = p.suffix.lower()
253
+ if suffix not in {".md", ".mdx"} and suffix not in XREF_SOURCE_SUFFIXES:
254
+ continue
255
+ if any(part in exclude_names for part in p.parts):
256
+ continue
257
+ yield p
258
+
259
+
260
+ def _read_text(path: Path) -> str:
261
+ return path.read_text(encoding="utf-8")
262
+
263
+
264
+ def _write_text(path: Path, text: str) -> None:
265
+ path.parent.mkdir(parents=True, exist_ok=True)
266
+ path.write_text(text, encoding="utf-8", newline="\n")
267
+
268
+
269
+ def _extract_xid(text: str) -> str | None:
270
+ m = XID_COMMENT_RE.search(text)
271
+ if m:
272
+ xid = m.group(1).strip()
273
+ if xid.upper() in _PLACEHOLDER_XIDS:
274
+ return None
275
+ return xid
276
+ m = XID_ANCHOR_RE.search(text)
277
+ if m:
278
+ xid = m.group(1).strip()
279
+ if xid.upper() in _PLACEHOLDER_XIDS:
280
+ return None
281
+ return xid
282
+ m = SOURCE_XID_COMMENT_RE.search(text)
283
+ if m:
284
+ xid = m.group(1).strip()
285
+ if xid.upper() in _PLACEHOLDER_XIDS:
286
+ return None
287
+ return xid
288
+ return None
289
+
290
+
291
+ def _extract_title(text: str) -> str | None:
292
+ in_fence = False
293
+ for line in text.splitlines():
294
+ stripped = line.strip()
295
+ if stripped.startswith("```"):
296
+ in_fence = not in_fence
297
+ continue
298
+ if in_fence:
299
+ continue
300
+ if stripped.startswith("# "):
301
+ return stripped[2:].strip()
302
+ return None
303
+
304
+
305
+ def _gen_xid() -> str:
306
+ # 12 hex chars (48 bits) is enough for local doc IDs with extremely low collision risk.
307
+ return secrets.token_hex(6).upper()
308
+
309
+ def _has_any_xid_marker(text: str) -> bool:
310
+ return (
311
+ XID_COMMENT_RE.search(text) is not None
312
+ or XID_ANCHOR_RE.search(text) is not None
313
+ or SOURCE_XID_COMMENT_RE.search(text) is not None
314
+ )
315
+
316
+
317
+ def _normalize_for_hash(text: str) -> str:
318
+ """
319
+ Produce a stable-ish representation for "has this doc meaningfully changed?" hints.
320
+ We intentionally ignore the XID block itself, and normalize line endings/whitespace.
321
+ """
322
+ out = text.replace("\r\n", "\n").replace("\r", "\n")
323
+ out = XID_COMMENT_RE.sub("", out, count=1)
324
+ out = XID_ANCHOR_RE.sub("", out, count=1)
325
+ out = SOURCE_XID_COMMENT_RE.sub("", out, count=1)
326
+ out = re.sub(r"\n{3,}", "\n\n", out)
327
+ return out.strip() + "\n"
328
+
329
+
330
+ def _content_hash(text: str) -> str:
331
+ blob = _normalize_for_hash(text).encode("utf-8")
332
+ return hashlib.sha256(blob).hexdigest()[:12]
333
+
334
+
335
+ def _load_any_cache_index(root: Path) -> dict[str, DocInfo] | None:
336
+ """
337
+ Load whatever is in the cache file without validating fingerprints.
338
+ Used to compute "review hints" as a best-effort before overwriting cache.
339
+ """
340
+ payload = _safe_read_json(_xid_index_cache_path(root))
341
+ if not isinstance(payload, dict):
342
+ return None
343
+ cached_index = payload.get("index")
344
+ if not isinstance(cached_index, dict):
345
+ return None
346
+ out: dict[str, DocInfo] = {}
347
+ for xid, info in cached_index.items():
348
+ if not isinstance(xid, str) or not isinstance(info, dict):
349
+ continue
350
+ rel = info.get("path")
351
+ if not isinstance(rel, str):
352
+ continue
353
+ title = info.get("title")
354
+ if title is not None and not isinstance(title, str):
355
+ title = None
356
+ content_hash = info.get("content_hash")
357
+ if content_hash is not None and not isinstance(content_hash, str):
358
+ content_hash = None
359
+ out[xid] = DocInfo(path=(root / rel), xid=xid, title=title, content_hash=content_hash)
360
+ return out
361
+
362
+
363
+ def _replace_or_insert_xid_block(text: str, xid: str) -> str:
364
+ """
365
+ If an xid marker exists (including placeholders like TBD), replace it.
366
+ Otherwise insert a new xid block near the top.
367
+ """
368
+ if not _has_any_xid_marker(text):
369
+ return _ensure_xid_block(text, xid)
370
+
371
+ new_text = text
372
+ if XID_COMMENT_RE.search(new_text):
373
+ new_text = XID_COMMENT_RE.sub(f"<!-- xid: {xid} -->", new_text, count=1)
374
+ else:
375
+ # Anchor exists but comment missing; insert comment just before the first anchor.
376
+ new_text = XID_ANCHOR_RE.sub(f"<!-- xid: {xid} -->\n\\g<0>", new_text, count=1)
377
+
378
+ if XID_ANCHOR_RE.search(new_text):
379
+ new_text = XID_ANCHOR_RE.sub(f'<a id="xid-{xid}"></a>', new_text, count=1)
380
+ else:
381
+ # Comment exists but anchor missing; insert anchor right after the comment.
382
+ new_text = XID_COMMENT_RE.sub(f"<!-- xid: {xid} -->\n<a id=\"xid-{xid}\"></a>", new_text, count=1)
383
+
384
+ return new_text
385
+
386
+
387
+ def _ensure_xid_block(text: str, xid: str) -> str:
388
+ if _extract_xid(text) is not None:
389
+ return text
390
+ lines = text.splitlines()
391
+ insert_at = 0
392
+ if lines and lines[0].strip().startswith("---"):
393
+ # Skip YAML frontmatter if present.
394
+ insert_at = 1
395
+ while insert_at < len(lines):
396
+ if lines[insert_at].strip() == "---":
397
+ insert_at += 1
398
+ break
399
+ insert_at += 1
400
+ block = [
401
+ f"<!-- xid: {xid} -->",
402
+ f'<a id="xid-{xid}"></a>',
403
+ "",
404
+ ]
405
+ out = lines[:insert_at] + block + lines[insert_at:]
406
+ return "\n".join(out) + ("\n" if text.endswith("\n") else "")
407
+
408
+
409
+ def build_index(cfg: XrefConfig) -> tuple[dict[str, DocInfo], list[dict[str, str]]]:
410
+ root = cfg.resolved_root()
411
+ include = cfg.resolved_include()
412
+ exclude_names = cfg.resolved_exclude()
413
+
414
+ fingerprints = _collect_fingerprints(root=root, include=include, exclude_names=exclude_names)
415
+ cached = _try_load_cached_index(
416
+ root=root,
417
+ include=include,
418
+ exclude_names=exclude_names,
419
+ fingerprints=fingerprints,
420
+ )
421
+ if cached is not None:
422
+ return cached
423
+
424
+ index: dict[str, DocInfo] = {}
425
+ issues: list[dict[str, str]] = []
426
+ for path in _iter_xref_files(root, include, exclude_names):
427
+ text = _read_text(path)
428
+ xid = _extract_xid(text)
429
+ if xid is None:
430
+ continue
431
+ if CANONICAL_XID_RE.fullmatch(xid) is None:
432
+ issues.append(
433
+ {
434
+ "type": "invalid_xid",
435
+ "xid": xid,
436
+ "path": str(path.relative_to(root)),
437
+ "reason": "expected_12_upper_hex",
438
+ }
439
+ )
440
+ continue
441
+ title = _extract_title(text)
442
+ chash = _content_hash(text)
443
+ if xid in index:
444
+ issues.append(
445
+ {
446
+ "type": "duplicate_xid",
447
+ "xid": xid,
448
+ "path_a": str(index[xid].path.relative_to(root)),
449
+ "path_b": str(path.relative_to(root)),
450
+ }
451
+ )
452
+ continue
453
+ index[xid] = DocInfo(path=path, xid=xid, title=title, content_hash=chash)
454
+
455
+ _write_index_cache(
456
+ root=root,
457
+ include=include,
458
+ exclude_names=exclude_names,
459
+ fingerprints=fingerprints,
460
+ index=index,
461
+ issues=issues,
462
+ )
463
+ return index, issues
464
+
465
+
466
+ def _relative_url(from_path: Path, to_path: Path) -> str:
467
+ rel = os.path.relpath(to_path, start=from_path.parent)
468
+ # Markdown prefers forward slashes.
469
+ return Path(rel).as_posix()
470
+
471
+
472
+ def _managed_ref_path(
473
+ *,
474
+ source_path: Path,
475
+ target_path: Path,
476
+ root: Path,
477
+ original_path: str,
478
+ ) -> str:
479
+ normalized = original_path.replace("\\", "/")
480
+ if not normalized.startswith(("./", "../")):
481
+ top_level = normalized.split("/", 1)[0]
482
+ if (root / top_level).is_dir():
483
+ return target_path.relative_to(root).as_posix()
484
+ return _relative_url(source_path, target_path)
485
+
486
+
487
+ def _rewrite_managed_links_in_text(
488
+ *,
489
+ text: str,
490
+ source_path: Path,
491
+ root: Path,
492
+ index: dict[str, DocInfo],
493
+ issues: list[dict[str, str]],
494
+ ) -> str:
495
+ in_fence = False
496
+ out_lines: list[str] = []
497
+
498
+ # Inline link pattern; intentionally simple, but we only rewrite URLs that contain "#xid-...".
499
+ link_re = re.compile(r"(!?)\[[^\]]*\]\(([^)\s]+)([^)]*)\)")
500
+
501
+ for line in text.splitlines(keepends=False):
502
+ stripped = line.lstrip()
503
+ if stripped.startswith("```"):
504
+ in_fence = not in_fence
505
+ out_lines.append(line)
506
+ continue
507
+ if in_fence:
508
+ out_lines.append(line)
509
+ continue
510
+
511
+ def repl(m: re.Match[str]) -> str:
512
+ if m.group(1) == "!":
513
+ return m.group(0)
514
+ url = m.group(2)
515
+ rest = m.group(3)
516
+ frag = MANAGED_FRAGMENT_RE.search(url)
517
+ if not frag:
518
+ return m.group(0)
519
+ xid = frag.group(1)
520
+ if xid not in index:
521
+ issues.append(
522
+ {
523
+ "type": "broken_xref",
524
+ "xid": xid,
525
+ "from": str(source_path.relative_to(root)),
526
+ }
527
+ )
528
+ return m.group(0)
529
+ target_path = index[xid].path
530
+ if target_path.resolve() == source_path.resolve():
531
+ new_url = f"#xid-{xid}"
532
+ else:
533
+ new_url = _relative_url(source_path, target_path) + f"#xid-{xid}"
534
+ # Preserve any trailing title part: (url "title")
535
+ return m.group(0).replace(url + rest, new_url + rest, 1)
536
+
537
+ rewritten = link_re.sub(repl, line)
538
+
539
+ def repl_bare(m: re.Match[str]) -> str:
540
+ xid = m.group("xid")
541
+ if xid not in index:
542
+ issue = {
543
+ "type": "broken_xref",
544
+ "xid": xid,
545
+ "from": str(source_path.relative_to(root)),
546
+ }
547
+ if issue not in issues:
548
+ issues.append(issue)
549
+ return m.group(0)
550
+ target_path = index[xid].path
551
+ new_path = _managed_ref_path(
552
+ source_path=source_path,
553
+ target_path=target_path,
554
+ root=root,
555
+ original_path=m.group("path"),
556
+ )
557
+ return f"{new_path}#xid-{xid}"
558
+
559
+ rewritten = BARE_MANAGED_REF_RE.sub(repl_bare, rewritten)
560
+
561
+ # Convert wiki xrefs [[XID]] into standard links, so link checking works everywhere.
562
+ def repl_wiki(m: re.Match[str]) -> str:
563
+ xid = m.group(1)
564
+ if xid not in index:
565
+ issues.append(
566
+ {
567
+ "type": "broken_xref",
568
+ "xid": xid,
569
+ "from": str(source_path.relative_to(root)),
570
+ }
571
+ )
572
+ return m.group(0)
573
+ target = index[xid]
574
+ label = target.title or xid
575
+ if target.path.resolve() == source_path.resolve():
576
+ url = f"#xid-{xid}"
577
+ else:
578
+ url = _relative_url(source_path, target.path) + f"#xid-{xid}"
579
+ return f"[{label}]({url})"
580
+
581
+ rewritten = WIKI_XREF_RE.sub(repl_wiki, rewritten)
582
+ out_lines.append(rewritten)
583
+
584
+ return "\n".join(out_lines) + ("\n" if text.endswith("\n") else "")
585
+
586
+
587
+ def xref_init(cfg: XrefConfig, dry_run: bool) -> dict[str, object]:
588
+ root = cfg.resolved_root()
589
+ include = cfg.resolved_include()
590
+ exclude_names = cfg.resolved_exclude()
591
+
592
+ changed: list[str] = []
593
+ added: list[dict[str, str]] = []
594
+ for path in _iter_markdown_files(root, include, exclude_names):
595
+ text = _read_text(path)
596
+ if _extract_xid(text) is not None:
597
+ continue
598
+ xid = _gen_xid()
599
+ new_text = _replace_or_insert_xid_block(text, xid)
600
+ added.append({"path": str(path.relative_to(root)), "xid": xid})
601
+ if new_text != text:
602
+ changed.append(str(path.relative_to(root)))
603
+ if not dry_run:
604
+ _write_text(path, new_text)
605
+
606
+ return {"changed_files": changed, "added": added, "dry_run": dry_run}
607
+
608
+
609
+ def xref_rewrite(cfg: XrefConfig, dry_run: bool) -> dict[str, object]:
610
+ root = cfg.resolved_root()
611
+ include = cfg.resolved_include()
612
+ exclude_names = cfg.resolved_exclude()
613
+
614
+ index, index_issues = build_index(cfg)
615
+ issues: list[dict[str, str]] = list(index_issues)
616
+ changed: list[str] = []
617
+
618
+ for path in _iter_markdown_files(root, include, exclude_names):
619
+ text = _read_text(path)
620
+ new_text = _rewrite_managed_links_in_text(
621
+ text=text, source_path=path, root=root, index=index, issues=issues
622
+ )
623
+ if new_text != text:
624
+ changed.append(str(path.relative_to(root)))
625
+ if not dry_run:
626
+ _write_text(path, new_text)
627
+
628
+ return {"changed_files": changed, "issues": issues, "dry_run": dry_run}
629
+
630
+
631
+ def _build_review_hints(
632
+ *,
633
+ root: Path,
634
+ prev_index: dict[str, DocInfo] | None,
635
+ curr_index: dict[str, DocInfo],
636
+ ) -> list[dict[str, str]]:
637
+ if not prev_index:
638
+ return []
639
+
640
+ review: list[dict[str, str]] = []
641
+ for xid, curr in curr_index.items():
642
+ prev = prev_index.get(xid)
643
+ if prev is None:
644
+ continue
645
+ moved = str(prev.path.relative_to(root)).replace("\\", "/") != str(curr.path.relative_to(root)).replace(
646
+ "\\", "/"
647
+ )
648
+ title_changed = (prev.title or "") != (curr.title or "")
649
+ content_changed = (
650
+ prev.content_hash is not None
651
+ and curr.content_hash is not None
652
+ and prev.content_hash != curr.content_hash
653
+ )
654
+ if not (moved or title_changed or content_changed):
655
+ continue
656
+
657
+ item: dict[str, str] = {"xid": xid}
658
+ item["path_before"] = str(prev.path.relative_to(root)).replace("\\", "/")
659
+ item["path_after"] = str(curr.path.relative_to(root)).replace("\\", "/")
660
+ if prev.title:
661
+ item["title_before"] = prev.title
662
+ if curr.title:
663
+ item["title_after"] = curr.title
664
+ flags: list[str] = []
665
+ if moved:
666
+ flags.append("moved")
667
+ if title_changed:
668
+ flags.append("title_changed")
669
+ if content_changed:
670
+ flags.append("content_changed")
671
+ item["flags"] = ",".join(flags)
672
+ review.append(item)
673
+
674
+ review.sort(key=lambda d: (d.get("path_after", ""), d.get("xid", "")))
675
+ return review
676
+
677
+
678
+ def xref_check(cfg: XrefConfig, *, review: bool = False) -> dict[str, object]:
679
+ root = cfg.resolved_root()
680
+ include = cfg.resolved_include()
681
+ exclude_names = cfg.resolved_exclude()
682
+
683
+ prev_index = _load_any_cache_index(root) if review else None
684
+ index, issues = build_index(cfg)
685
+
686
+ missing_xid: list[str] = []
687
+ for path in _iter_markdown_files(root, include, exclude_names):
688
+ text = _read_text(path)
689
+ if _extract_xid(text) is None:
690
+ missing_xid.append(str(path.relative_to(root)))
691
+
692
+ # Validate managed links (with #xid-...)
693
+ for path in _iter_xref_files(root, include, exclude_names):
694
+ text = _read_text(path)
695
+ for m in MANAGED_FRAGMENT_RE.finditer(text):
696
+ xid = m.group(1)
697
+ if xid not in index:
698
+ issues.append(
699
+ {
700
+ "type": "broken_xref",
701
+ "xid": xid,
702
+ "from": str(path.relative_to(root)),
703
+ }
704
+ )
705
+ in_fence = False
706
+ for line in text.splitlines():
707
+ if line.lstrip().startswith("```"):
708
+ in_fence = not in_fence
709
+ continue
710
+ if in_fence:
711
+ continue
712
+ for m in BARE_MANAGED_REF_RE.finditer(line):
713
+ xid = m.group("xid")
714
+ target = index.get(xid)
715
+ if target is None:
716
+ continue
717
+ expected_path = _managed_ref_path(
718
+ source_path=path,
719
+ target_path=target.path,
720
+ root=root,
721
+ original_path=m.group("path"),
722
+ )
723
+ if m.group("path").replace("\\", "/") != expected_path:
724
+ issues.append(
725
+ {
726
+ "type": "stale_xref_path",
727
+ "xid": xid,
728
+ "from": str(path.relative_to(root)),
729
+ "path": m.group("path"),
730
+ "expected": expected_path,
731
+ }
732
+ )
733
+
734
+ payload: dict[str, object] = {"index_size": len(index), "missing_xid": missing_xid, "issues": issues}
735
+ if review:
736
+ payload["review"] = _build_review_hints(root=root, prev_index=prev_index, curr_index=index)
737
+ return payload
738
+
739
+
740
+ def cmd_xref(args: argparse.Namespace, cfg: XrefConfig) -> int:
741
+ if args.xref_cmd == "init":
742
+ result = xref_init(cfg, dry_run=bool(args.dry_run))
743
+ print(json.dumps(result, ensure_ascii=False, indent=2))
744
+ return 0
745
+
746
+ if args.xref_cmd == "rewrite":
747
+ result = xref_rewrite(cfg, dry_run=bool(args.dry_run))
748
+ print(json.dumps(result, ensure_ascii=False, indent=2))
749
+ return 0
750
+
751
+ if args.xref_cmd == "check":
752
+ result = xref_check(cfg, review=bool(getattr(args, "review", False)))
753
+ if args.json:
754
+ print(json.dumps(result, ensure_ascii=False, indent=2))
755
+ else:
756
+ missing = result["missing_xid"]
757
+ issues = result["issues"]
758
+ print(f"index_size: {result['index_size']}")
759
+ print(f"missing_xid: {len(missing)}")
760
+ for p in missing[:50]:
761
+ print(f" - {p}")
762
+ if len(missing) > 50:
763
+ print(f" ... +{len(missing) - 50} more")
764
+ print(f"issues: {len(issues)}")
765
+ for i in issues[:50]:
766
+ print(" - " + json.dumps(i, ensure_ascii=False))
767
+ if len(issues) > 50:
768
+ print(f" ... +{len(issues) - 50} more")
769
+ review_items = result.get("review") if isinstance(result, dict) else None
770
+ if isinstance(review_items, list) and review_items:
771
+ print(f"review: {len(review_items)}")
772
+ for r in review_items[:50]:
773
+ print(" - " + json.dumps(r, ensure_ascii=False))
774
+ if len(review_items) > 50:
775
+ print(f" ... +{len(review_items) - 50} more")
776
+ has_errors = bool(result["missing_xid"]) or bool(result["issues"])
777
+ return 1 if has_errors else 0
778
+
779
+ if args.xref_cmd == "fix":
780
+ dry_run = bool(getattr(args, "dry_run", False))
781
+ include_review = bool(getattr(args, "review", False))
782
+ as_json = bool(getattr(args, "json", False))
783
+
784
+ init_result = xref_init(cfg, dry_run=dry_run)
785
+ rewrite_result = xref_rewrite(cfg, dry_run=dry_run)
786
+ check_result = xref_check(cfg, review=include_review)
787
+ payload = {
788
+ "dry_run": dry_run,
789
+ "init": init_result,
790
+ "rewrite": rewrite_result,
791
+ "check": check_result,
792
+ }
793
+
794
+ if as_json:
795
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
796
+ else:
797
+ print("phase:init")
798
+ print(f" changed_files: {len(init_result['changed_files'])}")
799
+ print(f" added_xids: {len(init_result['added'])}")
800
+ print("phase:rewrite")
801
+ print(f" changed_files: {len(rewrite_result['changed_files'])}")
802
+ print(f" issues: {len(rewrite_result['issues'])}")
803
+ print("phase:check")
804
+ print(f" index_size: {check_result['index_size']}")
805
+ print(f" missing_xid: {len(check_result['missing_xid'])}")
806
+ print(f" issues: {len(check_result['issues'])}")
807
+ review_items = check_result.get("review")
808
+ if isinstance(review_items, list):
809
+ print(f" review: {len(review_items)}")
810
+
811
+ has_errors = bool(check_result["missing_xid"]) or bool(check_result["issues"])
812
+ return 1 if has_errors else 0
813
+
814
+ if args.xref_cmd == "index":
815
+ index, index_issues = build_index(cfg)
816
+ payload = {
817
+ "issues": index_issues,
818
+ "index": {xid: str(info.path.relative_to(cfg.resolved_root())) for xid, info in index.items()},
819
+ }
820
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
821
+ return 0
822
+
823
+ if args.xref_cmd == "show":
824
+ root = cfg.resolved_root()
825
+ index, issues = build_index(cfg)
826
+ xid = str(args.xid)
827
+ if xid not in index:
828
+ payload = {"ok": False, "xid": xid, "issues": issues, "error": "xid_not_found"}
829
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
830
+ return 1
831
+ path = index[xid].path
832
+ text = _read_text(path)
833
+ max_bytes = int(args.max_bytes)
834
+ blob = text.encode("utf-8")
835
+ shown = blob[:max_bytes].decode("utf-8", errors="ignore")
836
+ rel = str(path.relative_to(root)).replace("\\", "/")
837
+ if args.json:
838
+ payload = {
839
+ "ok": True,
840
+ "xid": xid,
841
+ "path": rel,
842
+ "title": index[xid].title,
843
+ "truncated": len(blob) > max_bytes,
844
+ "text": shown,
845
+ "issues": issues,
846
+ }
847
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
848
+ else:
849
+ print(f"# {index[xid].title or xid}")
850
+ print(f"- xid: {xid}")
851
+ print(f"- path: {rel}")
852
+ print("")
853
+ print(shown, end="" if shown.endswith("\n") else "\n")
854
+ if len(blob) > max_bytes:
855
+ print("\n... (truncated)")
856
+ return 0
857
+
858
+ if args.xref_cmd == "search":
859
+ root = cfg.resolved_root()
860
+ include = cfg.resolved_include()
861
+ exclude_names = cfg.resolved_exclude()
862
+ index, issues = build_index(cfg)
863
+
864
+ query = str(args.query)
865
+ limit = max(1, int(args.limit))
866
+
867
+ regex = None
868
+ if len(query) >= 3 and query.startswith("/") and query.endswith("/"):
869
+ try:
870
+ regex = re.compile(query[1:-1], re.IGNORECASE)
871
+ except re.error:
872
+ regex = None
873
+
874
+ results: list[dict[str, object]] = []
875
+ for path in _iter_markdown_files(root, include, exclude_names):
876
+ text = _read_text(path)
877
+ xid = _extract_xid(text)
878
+ if xid is None:
879
+ continue
880
+ hay = text
881
+ matched = False
882
+ if regex is not None:
883
+ matched = bool(regex.search(hay))
884
+ else:
885
+ matched = query.lower() in hay.lower()
886
+ if not matched:
887
+ continue
888
+
889
+ rel = str(path.relative_to(root)).replace("\\", "/")
890
+ title = _extract_title(text)
891
+ snippet = ""
892
+ # Cheap snippet: first line that matches.
893
+ for line in text.splitlines():
894
+ if (regex and regex.search(line)) or (not regex and query.lower() in line.lower()):
895
+ snippet = line.strip()
896
+ break
897
+ results.append({"xid": xid, "title": title, "path": rel, "snippet": snippet})
898
+ if len(results) >= limit:
899
+ break
900
+
901
+ payload = {"query": query, "results": results, "issues": issues}
902
+ if args.json:
903
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
904
+ else:
905
+ for r in results:
906
+ label = r["title"] or r["xid"]
907
+ print(f"- {label} ({r['path']}#xid-{r['xid']})")
908
+ if r["snippet"]:
909
+ print(f" {r['snippet']}")
910
+ return 0
911
+
912
+ if args.xref_cmd == "deprecate":
913
+ result = xref_deprecate(
914
+ cfg,
915
+ old_xid=str(args.old_xid),
916
+ new_xid=str(args.new_xid),
917
+ note=(str(args.note) if args.note is not None else None),
918
+ )
919
+ print(json.dumps(result, ensure_ascii=False, indent=2))
920
+ return 0
921
+
922
+ raise ValueError(f"Unknown xref command: {args.xref_cmd}")
923
+
924
+
925
+ def _ensure_relation_section(
926
+ *,
927
+ text: str,
928
+ role: str,
929
+ other_xid: str,
930
+ note: str | None,
931
+ ) -> str:
932
+ """
933
+ Add/update a human-facing section that records predecessor/successor relationship.
934
+ This is intentionally simple and idempotent-ish.
935
+ """
936
+ other_link = f"[[{other_xid}]]"
937
+ lines = text.splitlines()
938
+
939
+ header = f"## {_XID_RELATION_SECTION_TITLE}"
940
+ if any(l.strip() == header for l in lines):
941
+ # Update in-place: ensure required bullets exist somewhere after the header, before next "## ".
942
+ out: list[str] = []
943
+ i = 0
944
+ while i < len(lines):
945
+ out.append(lines[i])
946
+ if lines[i].strip() != header:
947
+ i += 1
948
+ continue
949
+ i += 1
950
+ # Consume section body.
951
+ body: list[str] = []
952
+ while i < len(lines) and not lines[i].startswith("## "):
953
+ body.append(lines[i])
954
+ i += 1
955
+
956
+ def has_prefix(prefix: str) -> bool:
957
+ return any(b.lstrip().startswith(prefix) for b in body)
958
+
959
+ # Minimal normalized bullets.
960
+ status_line = "- status: deprecated" if role == "old" else "- status: active"
961
+ rel_line = "- superseded_by: " + other_link if role == "old" else "- supersedes: " + other_link
962
+
963
+ if not has_prefix("- status:"):
964
+ body = [status_line, *body]
965
+ else:
966
+ # If status exists, keep as-is.
967
+ pass
968
+ if not (has_prefix("- superseded_by:") or has_prefix("- supersedes:")):
969
+ body = [*body, rel_line]
970
+ else:
971
+ # Ensure the right relationship line exists.
972
+ if role == "old" and not has_prefix("- superseded_by:"):
973
+ body = [*body, rel_line]
974
+ if role == "new" and not has_prefix("- supersedes:"):
975
+ body = [*body, rel_line]
976
+
977
+ if note and not has_prefix("- note:"):
978
+ body = [*body, f"- note: {note}"]
979
+
980
+ out.extend(body)
981
+ return "\n".join(out) + ("\n" if text.endswith("\n") else "")
982
+
983
+ # Append a new section near the top (after the title if possible), otherwise at end.
984
+ insert_at = len(lines)
985
+ # Try to insert after the first H1 section (after first blank line after "# ").
986
+ for idx, line in enumerate(lines):
987
+ if line.startswith("# "):
988
+ # find next blank line after heading block
989
+ j = idx + 1
990
+ while j < len(lines) and lines[j].strip() != "":
991
+ j += 1
992
+ # include that blank line
993
+ insert_at = min(len(lines), j + 1)
994
+ break
995
+
996
+ status_line = "- status: deprecated" if role == "old" else "- status: active"
997
+ rel_line = "- superseded_by: " + other_link if role == "old" else "- supersedes: " + other_link
998
+
999
+ block = [
1000
+ header,
1001
+ "",
1002
+ status_line,
1003
+ rel_line,
1004
+ ]
1005
+ if note:
1006
+ block.append(f"- note: {note}")
1007
+ block.extend(["", ""])
1008
+
1009
+ out_lines = lines[:insert_at] + block + lines[insert_at:]
1010
+ return "\n".join(out_lines) + ("\n" if text.endswith("\n") else "")
1011
+
1012
+
1013
+ def xref_deprecate(cfg: XrefConfig, *, old_xid: str, new_xid: str, note: str | None) -> dict[str, object]:
1014
+ root = cfg.resolved_root()
1015
+ index, issues = build_index(cfg)
1016
+
1017
+ if old_xid not in index:
1018
+ return {"ok": False, "error": "old_xid_not_found", "old_xid": old_xid, "issues": issues}
1019
+ if new_xid not in index:
1020
+ return {"ok": False, "error": "new_xid_not_found", "new_xid": new_xid, "issues": issues}
1021
+ if old_xid == new_xid:
1022
+ return {"ok": False, "error": "same_xid", "xid": old_xid, "issues": issues}
1023
+
1024
+ old_path = index[old_xid].path
1025
+ new_path = index[new_xid].path
1026
+
1027
+ old_text = _read_text(old_path)
1028
+ new_text = _read_text(new_path)
1029
+
1030
+ updated_old = _ensure_relation_section(text=old_text, role="old", other_xid=new_xid, note=note)
1031
+ updated_new = _ensure_relation_section(text=new_text, role="new", other_xid=old_xid, note=None)
1032
+
1033
+ changed: list[str] = []
1034
+ if updated_old != old_text:
1035
+ _write_text(old_path, updated_old)
1036
+ changed.append(str(old_path.relative_to(root)))
1037
+ if updated_new != new_text:
1038
+ _write_text(new_path, updated_new)
1039
+ changed.append(str(new_path.relative_to(root)))
1040
+
1041
+ return {
1042
+ "ok": True,
1043
+ "old_xid": old_xid,
1044
+ "new_xid": new_xid,
1045
+ "changed_files": changed,
1046
+ "issues": issues,
1047
+ }
1048
+