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/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
|
+
|