@tw93/waza 3.25.0

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +35 -0
  4. package/rules/anti-patterns.md +38 -0
  5. package/rules/chinese.md +18 -0
  6. package/rules/durable-context.md +27 -0
  7. package/rules/english.md +14 -0
  8. package/scripts/build_metadata.py +360 -0
  9. package/scripts/check_routing_drift.py +82 -0
  10. package/scripts/dispatcher-template.md +43 -0
  11. package/scripts/dispatcher.md +53 -0
  12. package/scripts/package-skill.sh +71 -0
  13. package/scripts/packaging_filter.py +55 -0
  14. package/scripts/setup-rule.sh +109 -0
  15. package/scripts/setup-statusline.sh +127 -0
  16. package/scripts/skill_checks.py +483 -0
  17. package/scripts/skill_frontmatter.py +110 -0
  18. package/scripts/statusline.sh +321 -0
  19. package/scripts/validate_package.py +66 -0
  20. package/scripts/verify_skills.py +100 -0
  21. package/skills/RESOLVER.md +91 -0
  22. package/skills/check/SKILL.md +338 -0
  23. package/skills/check/agents/reviewer-architecture.md +39 -0
  24. package/skills/check/agents/reviewer-security.md +39 -0
  25. package/skills/check/references/persona-catalog.md +56 -0
  26. package/skills/check/references/project-context.md +107 -0
  27. package/skills/check/references/public-reply.md +14 -0
  28. package/skills/check/scripts/audit_signals.py +485 -0
  29. package/skills/check/scripts/run-tests.sh +19 -0
  30. package/skills/design/SKILL.md +134 -0
  31. package/skills/design/references/design-aesthetic-quality.md +67 -0
  32. package/skills/design/references/design-data-viz.md +34 -0
  33. package/skills/design/references/design-reference.md +278 -0
  34. package/skills/design/references/design-tokens.md +53 -0
  35. package/skills/design/references/design-traps.md +43 -0
  36. package/skills/health/SKILL.md +231 -0
  37. package/skills/health/agents/inspector-context.md +119 -0
  38. package/skills/health/agents/inspector-control.md +84 -0
  39. package/skills/health/agents/inspector-maintainability.md +55 -0
  40. package/skills/health/scripts/check-agent-context.sh +5 -0
  41. package/skills/health/scripts/check-doc-refs.sh +8 -0
  42. package/skills/health/scripts/check-maintainability.sh +8 -0
  43. package/skills/health/scripts/check-verifier-output.sh +5 -0
  44. package/skills/health/scripts/check_agent_context.py +407 -0
  45. package/skills/health/scripts/check_doc_refs.py +110 -0
  46. package/skills/health/scripts/check_maintainability.py +629 -0
  47. package/skills/health/scripts/check_verifier_output.py +116 -0
  48. package/skills/health/scripts/collect-data.sh +760 -0
  49. package/skills/hunt/SKILL.md +197 -0
  50. package/skills/hunt/references/failure-patterns.md +75 -0
  51. package/skills/hunt/references/ime-unicode.md +58 -0
  52. package/skills/hunt/references/logging-techniques.md +72 -0
  53. package/skills/hunt/references/rendering-debug.md +34 -0
  54. package/skills/learn/SKILL.md +128 -0
  55. package/skills/read/SKILL.md +108 -0
  56. package/skills/read/references/read-methods.md +110 -0
  57. package/skills/read/references/save-paths.md +33 -0
  58. package/skills/read/scripts/fetch.sh +105 -0
  59. package/skills/read/scripts/fetch_feishu.py +246 -0
  60. package/skills/read/scripts/fetch_local.py +218 -0
  61. package/skills/read/scripts/fetch_weixin.py +107 -0
  62. package/skills/think/SKILL.md +155 -0
  63. package/skills/write/SKILL.md +129 -0
  64. package/skills/write/references/write-en.md +197 -0
  65. package/skills/write/references/write-zh-bilingual.md +60 -0
  66. package/skills/write/references/write-zh-prose.md +48 -0
  67. package/skills/write/references/write-zh-release-notes.md +38 -0
  68. package/skills/write/references/write-zh.md +645 -0
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env python3
2
+ """Summarize the agent-instruction surface for a project.
3
+
4
+ Inventories AGENTS.md / CLAUDE.md / Codex / Copilot / Gemini instruction files,
5
+ parses Codex config.toml for project trust + plugin/feature state (with sensitive
6
+ values redacted), and flags drift between Claude and Codex surfaces.
7
+
8
+ Run as: python3 check_agent_context.py [ROOT] [summary|deep]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ SENSITIVE_RE = re.compile(r"(api[_-]?key|token|secret|password|credential)", re.IGNORECASE)
21
+ PROJECT_RE = re.compile(r'^\[projects\."(.+)"\]\s*$')
22
+ TABLE_RE = re.compile(r'^\[([A-Za-z0-9_.@"\-/]+)\]\s*$')
23
+
24
+
25
+ def rel(path: Path, root: Path) -> str:
26
+ try:
27
+ return path.resolve().relative_to(root).as_posix()
28
+ except ValueError:
29
+ return path.as_posix()
30
+
31
+
32
+ def read(path: Path, limit: int | None = None) -> str:
33
+ try:
34
+ data = path.read_text(encoding="utf-8", errors="replace")
35
+ except OSError:
36
+ return ""
37
+ return data[:limit] if limit else data
38
+
39
+
40
+ def yes(path: Path) -> str:
41
+ return "yes" if path.exists() else "no"
42
+
43
+
44
+ def print_list(title: str, items: list[str], empty: str = "(none)", limit: int | None = None) -> None:
45
+ print(f"{title}:")
46
+ shown = items if limit is None else items[:limit]
47
+ if not shown:
48
+ print(f" {empty}")
49
+ return
50
+ for item in shown:
51
+ print(f" {item}")
52
+ if limit is not None and len(items) > limit:
53
+ print(f" ... {len(items) - limit} more")
54
+
55
+
56
+ def load_json(path: Path) -> tuple[object | None, str | None]:
57
+ if not path.is_file():
58
+ return None, None
59
+ try:
60
+ return json.loads(read(path)), None
61
+ except json.JSONDecodeError as exc:
62
+ return None, f"{path.name}: invalid JSON at line {exc.lineno}"
63
+
64
+
65
+ def redact_sensitive_entries(value: object, prefix: str = "") -> list[str]:
66
+ entries: list[str] = []
67
+ if isinstance(value, dict):
68
+ for key, child in value.items():
69
+ child_prefix = f"{prefix}.{key}" if prefix else str(key)
70
+ if SENSITIVE_RE.search(str(key)):
71
+ entries.append(f"{child_prefix}=[REDACTED]")
72
+ continue
73
+ entries.extend(redact_sensitive_entries(child, child_prefix))
74
+ elif isinstance(value, list):
75
+ for index, child in enumerate(value):
76
+ entries.extend(redact_sensitive_entries(child, f"{prefix}[{index}]"))
77
+ return entries
78
+
79
+
80
+ def string_list(value: object) -> list[str]:
81
+ if isinstance(value, list):
82
+ return [str(item) if not SENSITIVE_RE.search(str(item)) else "[REDACTED]" for item in value]
83
+ if isinstance(value, dict):
84
+ return sorted(str(key) for key in value)
85
+ if isinstance(value, str):
86
+ return ["[REDACTED]" if SENSITIVE_RE.search(value) else value]
87
+ return []
88
+
89
+
90
+ def skill_root_count(path: Path, include_root_md: bool) -> int:
91
+ if not path.is_dir():
92
+ return 0
93
+ count = len(list(path.rglob("SKILL.md")))
94
+ if include_root_md:
95
+ count += len([p for p in path.glob("*.md") if p.name != "SKILL.md"])
96
+ return count
97
+
98
+
99
+ def project_instruction_files(root: Path) -> list[Path]:
100
+ files = [
101
+ root / "AGENTS.md",
102
+ root / "CLAUDE.md",
103
+ root / ".github" / "copilot-instructions.md",
104
+ root / "GEMINI.md",
105
+ ]
106
+ instructions_dir = root / ".github" / "instructions"
107
+ if instructions_dir.is_dir():
108
+ files.extend(sorted(instructions_dir.glob("*.md")))
109
+ return [path for path in files if path.is_file()]
110
+
111
+
112
+ def claude_delegates_to_agents(path: Path) -> bool:
113
+ text = read(path, 20_000)
114
+ if not text:
115
+ return False
116
+ meaningful = [
117
+ line.strip()
118
+ for line in text.splitlines()
119
+ if line.strip() and not line.strip().startswith("#")
120
+ ]
121
+ return any("AGENTS.md" in line for line in meaningful)
122
+
123
+
124
+ def parse_codex_config(
125
+ path: Path,
126
+ ) -> tuple[dict[str, str], list[str], list[str], list[str], list[str]]:
127
+ projects: dict[str, str] = {}
128
+ features: list[str] = []
129
+ plugins: list[str] = []
130
+ marketplaces: list[str] = []
131
+ redacted: list[str] = []
132
+ if not path.is_file():
133
+ return projects, features, plugins, marketplaces, redacted
134
+
135
+ section = ""
136
+ for raw in read(path).splitlines():
137
+ line = raw.strip()
138
+ if not line or line.startswith("#"):
139
+ continue
140
+ project_match = PROJECT_RE.match(line)
141
+ if project_match:
142
+ section = f'projects."{project_match.group(1)}"'
143
+ projects.setdefault(project_match.group(1), "")
144
+ continue
145
+ table_match = TABLE_RE.match(line)
146
+ if table_match:
147
+ section = table_match.group(1)
148
+ marketplace_match = re.match(r'marketplaces\.([A-Za-z0-9_.@-]+)$', section)
149
+ plugin_match = re.match(r'plugins\."?([^"]+)"?$', section)
150
+ if marketplace_match:
151
+ marketplaces.append(marketplace_match.group(1))
152
+ if plugin_match:
153
+ plugins.append(plugin_match.group(1))
154
+ continue
155
+
156
+ if SENSITIVE_RE.search(line):
157
+ key = line.split("=", 1)[0].strip() if "=" in line else "sensitive"
158
+ redacted.append(f"{key}=[REDACTED]")
159
+ continue
160
+
161
+ if "=" not in line:
162
+ continue
163
+ key, value = [part.strip() for part in line.split("=", 1)]
164
+ if section == "features" and value.lower() == "true":
165
+ features.append(key)
166
+ elif section.startswith('projects."') and key == "trust_level":
167
+ project = section[len('projects."'): -1]
168
+ projects[project] = value.strip('"')
169
+
170
+ return (
171
+ projects,
172
+ sorted(set(features)),
173
+ sorted(set(plugins)),
174
+ sorted(set(marketplaces)),
175
+ sorted(set(redacted)),
176
+ )
177
+
178
+
179
+ def project_trust(projects: dict[str, str], root: Path) -> str:
180
+ root_text = root.as_posix()
181
+ if root_text in projects:
182
+ return f"exact:{projects[root_text] or 'configured'}"
183
+ candidates = []
184
+ for project, level in projects.items():
185
+ try:
186
+ project_path = Path(project).expanduser().resolve()
187
+ except OSError:
188
+ continue
189
+ if project_path == root:
190
+ return f"exact:{level or 'configured'}"
191
+ try:
192
+ root.relative_to(project_path)
193
+ except ValueError:
194
+ continue
195
+ candidates.append(
196
+ (len(project_path.as_posix()), level or "configured", project_path.as_posix())
197
+ )
198
+ if candidates:
199
+ _, level, project = sorted(candidates, reverse=True)[0]
200
+ return f"inherited:{level} from {project}"
201
+ return "missing"
202
+
203
+
204
+ def summarize_pi_surface(root: Path, home: Path) -> tuple[str, list[str]]:
205
+ global_settings = home / ".pi" / "agent" / "settings.json"
206
+ project_settings = root / ".pi" / "settings.json"
207
+ settings_sources = [
208
+ ("global_settings", global_settings),
209
+ ("project_settings", project_settings),
210
+ ]
211
+
212
+ configured_skills: list[str] = []
213
+ configured_packages: list[str] = []
214
+ redacted_entries: list[str] = []
215
+ findings: list[str] = []
216
+ malformed = False
217
+
218
+ for label, path in settings_sources:
219
+ data, error = load_json(path)
220
+ if error:
221
+ malformed = True
222
+ findings.append(error)
223
+ continue
224
+ if not isinstance(data, dict):
225
+ continue
226
+ configured_skills.extend(
227
+ f"{label}.skills: {item}" for item in string_list(data.get("skills"))
228
+ )
229
+ configured_packages.extend(
230
+ f"{label}.packages: {item}" for item in string_list(data.get("packages"))
231
+ )
232
+ redacted_entries.extend(
233
+ f"{label}.{item}" for item in redact_sensitive_entries(data)
234
+ )
235
+
236
+ package_path = root / "package.json"
237
+ package_pi_skills: list[str] = []
238
+ data, error = load_json(package_path)
239
+ if error:
240
+ findings.append(error)
241
+ elif isinstance(data, dict):
242
+ pi_manifest = data.get("pi")
243
+ if isinstance(pi_manifest, dict):
244
+ package_pi_skills = string_list(pi_manifest.get("skills"))
245
+
246
+ pi_skill_dirs = [
247
+ ("global_pi_skill_roots", home / ".pi" / "agent" / "skills", True),
248
+ ("project_pi_skill_roots", root / ".pi" / "skills", True),
249
+ ("global_agents_skill_roots", home / ".agents" / "skills", False),
250
+ ("project_agents_skill_roots", root / ".agents" / "skills", False),
251
+ ]
252
+ skill_counts = [
253
+ f"{label}: {skill_root_count(path, include_root_md)}"
254
+ for label, path, include_root_md in pi_skill_dirs
255
+ ]
256
+
257
+ has_pi_surface = (
258
+ global_settings.is_file()
259
+ or project_settings.is_file()
260
+ or bool(package_pi_skills)
261
+ or any(not line.endswith(": 0") for line in skill_counts)
262
+ or bool(configured_skills)
263
+ or bool(configured_packages)
264
+ )
265
+ if not has_pi_surface:
266
+ findings.append("no Pi settings, package manifest, or skill directories found")
267
+
268
+ status = "WARN" if malformed else "PASS"
269
+ lines = [
270
+ "=== PI SURFACE ===",
271
+ f"pi_status: {status}",
272
+ f"global_settings_json: {yes(global_settings)}",
273
+ f"project_settings_json: {yes(project_settings)}",
274
+ f"package_json: {yes(package_path)}",
275
+ ]
276
+ lines.extend(skill_counts)
277
+ lines.append("package_pi_skills:")
278
+ lines.extend(f" {item}" for item in (package_pi_skills or ["(none)"]))
279
+ lines.append("configured_skills:")
280
+ lines.extend(f" {item}" for item in (configured_skills or ["(none)"]))
281
+ lines.append("configured_packages:")
282
+ lines.extend(f" {item}" for item in (configured_packages or ["(none)"]))
283
+ lines.append("redacted_pi_entries:")
284
+ lines.extend(f" {item}" for item in (redacted_entries or ["(none)"]))
285
+ lines.append("pi_findings:")
286
+ lines.extend(f" {item}" for item in (findings or ["(none)"]))
287
+ return status, lines
288
+
289
+
290
+ def main() -> int:
291
+ parser = argparse.ArgumentParser(description=__doc__)
292
+ parser.add_argument("root", nargs="?", default=".", help="Repo root (default: cwd)")
293
+ parser.add_argument(
294
+ "mode", nargs="?", default="summary", choices=("summary", "deep"),
295
+ help="Output detail level",
296
+ )
297
+ args = parser.parse_args()
298
+ root = Path(args.root).resolve()
299
+ mode = args.mode
300
+ home = Path(os.environ.get("HOME", str(Path.home()))).expanduser()
301
+
302
+ if not root.is_dir():
303
+ print(f"Repo root not found: {root}", file=sys.stderr)
304
+ return 2
305
+
306
+ instruction_files = project_instruction_files(root)
307
+ agents = root / "AGENTS.md"
308
+ claude = root / "CLAUDE.md"
309
+ claude_delegates = claude_delegates_to_agents(claude)
310
+ github_instructions_dir = root / ".github" / "instructions"
311
+ github_instruction_count = (
312
+ len(list(github_instructions_dir.glob("*.md"))) if github_instructions_dir.is_dir() else 0
313
+ )
314
+
315
+ instruction_findings: list[str] = []
316
+ if not instruction_files:
317
+ instruction_findings.append("no project agent instruction files")
318
+ if agents.is_file() and claude.is_file() and not claude_delegates:
319
+ claude_lines = len(read(claude).splitlines())
320
+ agents_lines = len(read(agents).splitlines())
321
+ if claude_lines > 20 and agents_lines > 20:
322
+ instruction_findings.append(
323
+ "AGENTS.md and CLAUDE.md both contain substantial guidance without delegation"
324
+ )
325
+
326
+ global_codex_agents = home / ".codex" / "AGENTS.md"
327
+ codex_config = home / ".codex" / "config.toml"
328
+ projects, features, plugins, marketplaces, redacted = parse_codex_config(codex_config)
329
+ trust = project_trust(projects, root) if codex_config.is_file() else "unavailable"
330
+ codex_findings: list[str] = []
331
+ if not global_codex_agents.is_file() and not codex_config.is_file():
332
+ codex_findings.append("Codex surface not found")
333
+ elif codex_config.is_file() and trust == "missing":
334
+ codex_findings.append("current project is not configured in Codex trust table")
335
+
336
+ global_claude = home / ".claude" / "CLAUDE.md"
337
+ project_settings = root / ".claude" / "settings.local.json"
338
+ project_rules = root / ".claude" / "rules"
339
+ project_skills = root / ".claude" / "skills"
340
+ global_skills = home / ".claude" / "skills"
341
+ claude_findings: list[str] = []
342
+ if claude.is_file() and claude_delegates:
343
+ claude_findings.append("CLAUDE.md delegates to AGENTS.md")
344
+ if not global_claude.is_file() and not claude.is_file():
345
+ claude_findings.append("Claude instruction surface not found")
346
+
347
+ conflict_findings: list[str] = []
348
+ if agents.is_file() and claude.is_file() and not claude_delegates:
349
+ conflict_findings.append("AGENTS.md and CLAUDE.md both exist; verify they do not diverge")
350
+
351
+ instruction_status = "FAIL" if not instruction_files else ("WARN" if instruction_findings else "PASS")
352
+ codex_status = "WARN" if codex_findings else "PASS"
353
+ claude_status = (
354
+ "WARN"
355
+ if claude_findings and "surface not found" in " ".join(claude_findings)
356
+ else "PASS"
357
+ )
358
+ conflict_status = "WARN" if conflict_findings else "PASS"
359
+
360
+ print("=== AGENT INSTRUCTION SURFACE ===")
361
+ print(f"agent_instruction_status: {instruction_status}")
362
+ print(f"mode: {mode}")
363
+ print(f"AGENTS.md: {yes(agents)}")
364
+ print(f"CLAUDE.md: {yes(claude)}")
365
+ print(f"claude_delegates_to_agents: {'yes' if claude_delegates else 'no'}")
366
+ print(f".github/copilot-instructions.md: {yes(root / '.github' / 'copilot-instructions.md')}")
367
+ print(f".github/instructions/*.md: {github_instruction_count}")
368
+ print(f"GEMINI.md: {yes(root / 'GEMINI.md')}")
369
+ print_list("instruction_files", [rel(path, root) for path in instruction_files])
370
+ print_list("instruction_findings", instruction_findings)
371
+
372
+ print("=== CODEX SURFACE ===")
373
+ print(f"codex_status: {codex_status}")
374
+ print(f"global_agents_md: {yes(global_codex_agents)}")
375
+ print(f"global_config_toml: {yes(codex_config)}")
376
+ print(f"project_trust: {trust}")
377
+ print_list("features", features, limit=20 if mode == "summary" else None)
378
+ print_list("enabled_plugins", plugins, limit=20 if mode == "summary" else None)
379
+ print_list("marketplaces", marketplaces, limit=20 if mode == "summary" else None)
380
+ print_list("redacted_config_entries", redacted)
381
+ print_list("codex_findings", codex_findings)
382
+
383
+ print("=== CLAUDE SURFACE ===")
384
+ print(f"claude_status: {claude_status}")
385
+ print(f"global_claude_md: {yes(global_claude)}")
386
+ print(f"project_claude_md: {yes(claude)}")
387
+ print(f"settings_local_json: {yes(project_settings)}")
388
+ rule_count = len(list(project_rules.glob('*.md'))) if project_rules.is_dir() else 0
389
+ local_skill_count = len(list(project_skills.glob('*/SKILL.md'))) if project_skills.is_dir() else 0
390
+ global_skill_count = len(list(global_skills.glob('*/SKILL.md'))) if global_skills.is_dir() else 0
391
+ print(f"project_rules: {rule_count}")
392
+ print(f"project_skills: {local_skill_count}")
393
+ print(f"global_skills: {global_skill_count}")
394
+ print_list("claude_findings", claude_findings)
395
+
396
+ _, pi_lines = summarize_pi_surface(root, home)
397
+ for line in pi_lines:
398
+ print(line)
399
+
400
+ print("=== INSTRUCTION CONFLICTS ===")
401
+ print(f"conflict_status: {conflict_status}")
402
+ print_list("conflict_findings", conflict_findings)
403
+ return 0
404
+
405
+
406
+ if __name__ == "__main__":
407
+ sys.exit(main())
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Check that doc references (@path, ~/.claude/..., docs/..., references/...) in
3
+ AGENTS.md, CLAUDE.md, .claude/rules/*.md, and .claude/skills/*/SKILL.md resolve
4
+ to real files. Prints `doc references: ok` on success, otherwise lists every
5
+ MISSING reference with source location.
6
+
7
+ Run as: python3 check_doc_refs.py [ROOT]
8
+ ROOT defaults to the current working directory.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ REF_RE = re.compile(
21
+ r"(?<![\w/.-])("
22
+ r"@[A-Za-z0-9_~/.-]+(?:\.md|/)|"
23
+ r"~/\.claude/[A-Za-z0-9_/.-]+(?:\.md|/)|"
24
+ r"(?:docs|references)/[A-Za-z0-9_/.-]+\.md"
25
+ r")"
26
+ )
27
+
28
+
29
+ def resolve_ref(source: Path, raw: str, root: Path, home: Path) -> Path:
30
+ ref = raw[1:] if raw.startswith("@") else raw
31
+
32
+ if ref.startswith("~/"):
33
+ return (home / ref[2:]).resolve()
34
+
35
+ path = Path(ref)
36
+ if path.is_absolute():
37
+ return path.resolve()
38
+
39
+ if raw.startswith("@"):
40
+ return (root / ref).resolve()
41
+
42
+ if ref.startswith("docs/"):
43
+ return (root / ref).resolve()
44
+
45
+ if ref.startswith("references/"):
46
+ source_parts = source.relative_to(root).parts
47
+ if len(source_parts) >= 4 and source_parts[:2] == (".claude", "skills"):
48
+ skill_root = root.joinpath(*source_parts[:3])
49
+ return (skill_root / ref).resolve()
50
+ return (root / ref).resolve()
51
+
52
+ return (source.parent / ref).resolve()
53
+
54
+
55
+ def collect_scan_files(root: Path) -> list[Path]:
56
+ scan_files: list[Path] = []
57
+ for candidate in (root / "AGENTS.md", root / "CLAUDE.md"):
58
+ if candidate.is_file():
59
+ scan_files.append(candidate)
60
+ for pattern in (".claude/rules/*.md", ".claude/skills/*/SKILL.md"):
61
+ scan_files.extend(sorted(root.glob(pattern)))
62
+ return scan_files
63
+
64
+
65
+ def main() -> int:
66
+ parser = argparse.ArgumentParser(description=__doc__)
67
+ parser.add_argument("root", nargs="?", default=".", help="Project root (default: cwd)")
68
+ args = parser.parse_args()
69
+ root = Path(args.root).resolve()
70
+ home = Path(os.environ.get("HOME", "")).expanduser()
71
+
72
+ scan_files = collect_scan_files(root)
73
+
74
+ missing: list[str] = []
75
+ seen: set[tuple[Path, int, str]] = set()
76
+ for path in scan_files:
77
+ in_fence = False
78
+ for lineno, line in enumerate(
79
+ path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
80
+ ):
81
+ stripped = line.lstrip()
82
+ if stripped.startswith("```") or stripped.startswith("~~~"):
83
+ in_fence = not in_fence
84
+ continue
85
+ if in_fence:
86
+ continue
87
+
88
+ for match in REF_RE.finditer(line):
89
+ raw = match.group(1)
90
+ key = (path, lineno, raw)
91
+ if key in seen:
92
+ continue
93
+ seen.add(key)
94
+
95
+ target = resolve_ref(path, raw, root, home)
96
+ exists = target.is_dir() if raw.endswith("/") else target.is_file()
97
+ if not exists:
98
+ source = path.relative_to(root)
99
+ missing.append(f"MISSING: {source}:{lineno} -> {raw}")
100
+
101
+ if missing:
102
+ print("\n".join(missing))
103
+ return 1
104
+
105
+ print("doc references: ok")
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())