@@ -0,0 +1,20 @@
|
|||||||
|
## 改了什么
|
||||||
|
|
||||||
|
说明具体问题、修改后的使用方法或事实;纯文字修正可简写。
|
||||||
|
|
||||||
|
## 依据
|
||||||
|
|
||||||
|
关联源码 commit/PR、ticket 或带日期的维护者说明。若来自未提交工作区,明确标注。
|
||||||
|
|
||||||
|
## 验证与范围
|
||||||
|
|
||||||
|
填写本地检查/CI 结果。现场是否查询、验证了什么、哪些示例未执行?
|
||||||
|
没有现场验证时,不因编辑文字而刷新 last_verified。
|
||||||
|
|
||||||
|
## 文档同步
|
||||||
|
|
||||||
|
- [ ] 涉及的服务页已更新;新增/退役服务已同步服务总览。
|
||||||
|
- [ ] 入口或设计变化已更新相关任务导航/架构约束(不适用可注明)。
|
||||||
|
- [ ] 已注明来源与尚未完成的部分,未包含实际凭据。
|
||||||
|
|
||||||
|
原仓库 README 同步目前暂缓,不是本 PR 的必做项。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: docs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, pod]
|
||||||
|
container:
|
||||||
|
image: python:3.12-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Install checker dependencies
|
||||||
|
run: python3 -m pip install --disable-pip-version-check -r requirements-docs.txt
|
||||||
|
- name: Test checker
|
||||||
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
- name: Check documentation
|
||||||
|
run: python3 scripts/check_docs.py
|
||||||
|
- name: Check whitespace
|
||||||
|
run: git show --format= --check HEAD
|
||||||
@@ -6,3 +6,6 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -19,4 +19,5 @@
|
|||||||
- 每项当前事实注明来源;last_verified 只在完成所述现场验证后更新,不随文字编辑刷新。
|
- 每项当前事实注明来源;last_verified 只在完成所述现场验证后更新,不随文字编辑刷新。
|
||||||
- 凭据只记录取得方式和受管位置,不复制实际密码、token、私钥、state 或含敏感值的输出。
|
- 凭据只记录取得方式和受管位置,不复制实际密码、token、私钥、state 或含敏感值的输出。
|
||||||
- 不复制 apps/tailscale/helm.sh 的内容。迁移旧文档前先审查敏感内容,不能整库直接发布。
|
- 不复制 apps/tailscale/helm.sh 的内容。迁移旧文档前先审查敏感内容,不能整库直接发布。
|
||||||
|
- 提交前运行 `python3 scripts/check_docs.py`;修改检查器时运行 `python3 -m unittest discover -s tests -v`。依赖与本地环境见 CONTRIBUTING.md。
|
||||||
- 遵守 CONTRIBUTING.md;不把临时检查日志和个人 agent memory 当成正式文档。
|
- 遵守 CONTRIBUTING.md;不把临时检查日志和个人 agent memory 当成正式文档。
|
||||||
|
|||||||
@@ -52,3 +52,53 @@ accepted 不代表部署完成,implemented 必须附实现和验收依据。
|
|||||||
使用普通 Markdown 链接、相对附件路径和文字说明;关键事实直接写入正文。
|
使用普通 Markdown 链接、相对附件路径和文字说明;关键事实直接写入正文。
|
||||||
可选 Obsidian 编辑,个人布局、缓存、插件和同步配置不提交。对外分享前检查敏感内容。
|
可选 Obsidian 编辑,个人布局、缓存、插件和同步配置不提交。对外分享前检查敏感内容。
|
||||||
提交前检查相对链接、服务目录覆盖,以及新增内容是否混淆计划和运行事实。
|
提交前检查相对链接、服务目录覆盖,以及新增内容是否混淆计划和运行事实。
|
||||||
|
|
||||||
|
|
||||||
|
## 一次服务变更应更新哪里
|
||||||
|
|
||||||
|
| 变化 | 必须查看的文档 |
|
||||||
|
|---|---|
|
||||||
|
| 使用入口、认证、权限、客户端参数 | 对应 `services/` 页面;入口变化同时更新服务总览 |
|
||||||
|
| 新增或退役组件 | 服务页、`services/index.md`;任务入口变化再改 `guides/task-index.md` |
|
||||||
|
| 跨服务设计或边界 | `architecture/constraints.md` 及受影响指南 |
|
||||||
|
| 只有开发进度变化 | 原项目 ticket;wiki 仅在阶段摘要需要变化时更新并注明日期 |
|
||||||
|
| 取得新的验证结果 | 服务页说明日期与验证范围,据实更新 `last_verified` |
|
||||||
|
| 工作区来源已合并 | 核对实际内容后更新 `sources.md` 的固定链接及差异标记 |
|
||||||
|
|
||||||
|
先修改最接近事实的页面,再同步导航,避免把同一套操作复制到多份文档。
|
||||||
|
无需每次修改都更新首页、所有服务页或整个来源索引。
|
||||||
|
原仓库 README 同步按维护者要求暂缓,不阻塞 wiki 的维护。
|
||||||
|
|
||||||
|
PR 使用 [.gitea/PULL_REQUEST_TEMPLATE.md](.gitea/PULL_REQUEST_TEMPLATE.md),简述问题、最终变化、依据与验证。
|
||||||
|
直接提交也遵循相同的检查与证据规则,不为纯文案修改制造额外审批。
|
||||||
|
|
||||||
|
## 本地与 CI 检查
|
||||||
|
|
||||||
|
需要 Python 3.10 或更新版本;首次在 wiki 根目录准备环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install -r requirements-docs.txt
|
||||||
|
.venv/bin/python -m unittest discover -s tests -v
|
||||||
|
.venv/bin/python scripts/check_docs.py
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖版本固定在 [requirements-docs.txt](requirements-docs.txt)。安装依赖需要网络,检查器本身离线运行。
|
||||||
|
Gitea 工作流 [.gitea/workflows/docs.yml](.gitea/workflows/docs.yml) 在 main push、PR 和手动触发时运行,
|
||||||
|
使用 `[self-hosted, pod]` 与 Python 容器;无需业务秘密或集群权限。
|
||||||
|
CI 拉取容器、checkout action 和依赖仍需要对应网络可用。
|
||||||
|
|
||||||
|
检查范围:
|
||||||
|
|
||||||
|
- Markdown 的相对文件链接、图片、引用式链接及本地标题锚点;不探测远端 URL。
|
||||||
|
- frontmatter 的类型、重复键、状态枚举、日期与 `live-verified` 必须有验证日期的约束。
|
||||||
|
- `services/` 下的服务页必须有完整状态字段,并由服务总览链接;总览及外部消费者范围页除外。
|
||||||
|
- 已有 frontmatter 的其他页面校验 title 和审阅日期;`templates/` 允许日期占位为 null。
|
||||||
|
|
||||||
|
标题锚点按常见 Gitea/GitHub 规则处理中文、字母、数字、连字符和重复标题。
|
||||||
|
需要特殊字符锚点时可声明 HTML `id`,不要依赖 Obsidian 插件或非标准 heading 属性。
|
||||||
|
代码块和行内代码里的路径是示例或说明,不当链接执行或检查;源码路径的存在性由 `sources.md` 的明确核对维护。
|
||||||
|
检查错误带文件与行号,但不打印原始 frontmatter 内容。
|
||||||
|
|
||||||
|
检查器不证明命令正确、外链可达、事实最新或服务健康,也不自动获取凭据或执行文档中的示例。
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ LiteLLM、Tailscale、ps3netsrv、vlmcsd 与 k3s 的 wiki 指南已补;源码
|
|||||||
- 已核对 62 个主要来源文件,提供[固定版本与工作区差异](sources.md)。
|
- 已核对 62 个主要来源文件,提供[固定版本与工作区差异](sources.md)。
|
||||||
- 仍有差异或未收录的来源,等内容合并后再补对应版本;不自动提交原仓库工作区。
|
- 仍有差异或未收录的来源,等内容合并后再补对应版本;不自动提交原仓库工作区。
|
||||||
|
|
||||||
|
## 持续维护
|
||||||
|
|
||||||
|
- 已提供离线文档检查器、Gitea Actions 工作流与检查器测试。
|
||||||
|
- 已补 PR 模板及服务变更到文档的对应规则,见 [CONTRIBUTING](CONTRIBUTING.md)。
|
||||||
|
- Backstage 展示与 OpenViking 自动索引仍是独立后续工作,本轮未接入。
|
||||||
|
|
||||||
## 原仓库文档同步(按维护者要求暂缓)
|
## 原仓库文档同步(按维护者要求暂缓)
|
||||||
|
|
||||||
- 源码仓库中 Authelia OIDC、LAN DNS、SPIRE 的旧说明后续与知识库结论同步。
|
- 源码仓库中 Authelia OIDC、LAN DNS、SPIRE 的旧说明后续与知识库结论同步。
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
markdown-it-py==3.0.0
|
||||||
|
mdurl==0.1.2
|
||||||
|
PyYAML==6.0.1
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""离线检查 wiki 的元数据、链接、标题锚点与服务索引。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import date
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
LIFECYCLES = {"planned", "experimental", "active", "retired", "unknown"}
|
||||||
|
EVIDENCE = {"configuration", "documented", "live-verified"}
|
||||||
|
# These two pages are navigation/scope descriptions, not individual services.
|
||||||
|
SERVICE_INDEXES = {"services/index.md", "services/external-consumers.md"}
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueLoader(yaml.SafeLoader):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def unique_mapping(loader, node):
|
||||||
|
result = {}
|
||||||
|
for key_node, value_node in node.value:
|
||||||
|
key = loader.construct_object(key_node)
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise ValueError("frontmatter 键必须是字符串")
|
||||||
|
if key in result:
|
||||||
|
raise ValueError(f"重复 frontmatter 键:{key}")
|
||||||
|
result[key] = loader.construct_object(value_node)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
|
||||||
|
|
||||||
|
|
||||||
|
def split_frontmatter(text):
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
if not lines or lines[0].strip() != "---":
|
||||||
|
return None, text
|
||||||
|
for i in range(1, len(lines)):
|
||||||
|
if lines[i].strip() == "---":
|
||||||
|
metadata = yaml.load("".join(lines[1:i]), Loader=UniqueLoader)
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError("frontmatter 必须是映射")
|
||||||
|
# Preserve line positions for diagnostics.
|
||||||
|
return metadata, "\n" * (i + 1) + "".join(lines[i + 1:])
|
||||||
|
raise ValueError("frontmatter 缺少结束分隔符")
|
||||||
|
|
||||||
|
|
||||||
|
def as_date(value):
|
||||||
|
if type(value) is date:
|
||||||
|
return value
|
||||||
|
if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
raise ValueError("必须是 YYYY-MM-DD 日期")
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_errors(meta, required=False, template=False):
|
||||||
|
if meta is None:
|
||||||
|
return ["服务页缺少 frontmatter"] if required else []
|
||||||
|
errors = []
|
||||||
|
keys = {"title", "last_reviewed"}
|
||||||
|
if required:
|
||||||
|
keys |= {"lifecycle", "evidence", "last_verified"}
|
||||||
|
for key in sorted(keys - meta.keys()):
|
||||||
|
errors.append(f"缺少字段 {key}")
|
||||||
|
if not isinstance(meta.get("title"), str) or not meta["title"].strip():
|
||||||
|
errors.append("title 必须是非空字符串")
|
||||||
|
for key, choices in [("lifecycle", LIFECYCLES), ("evidence", EVIDENCE)]:
|
||||||
|
if key in meta and (not isinstance(meta[key], str) or meta[key] not in choices):
|
||||||
|
errors.append(f"{key} 不在允许值中")
|
||||||
|
dates = {}
|
||||||
|
for key in ["last_reviewed", "last_verified"]:
|
||||||
|
if key not in meta:
|
||||||
|
continue
|
||||||
|
if meta[key] is None:
|
||||||
|
if key == "last_reviewed" and not template:
|
||||||
|
errors.append("last_reviewed 不得为 null")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dates[key] = as_date(meta[key])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors.append(f"{key} 必须是 YYYY-MM-DD 日期或允许的 null")
|
||||||
|
if meta.get("evidence") == "live-verified" and "last_verified" not in dates:
|
||||||
|
errors.append("live-verified 必须提供 last_verified 日期")
|
||||||
|
if len(dates) == 2 and dates["last_verified"] > dates["last_reviewed"]:
|
||||||
|
errors.append("last_verified 不能晚于 last_reviewed")
|
||||||
|
if "sources" in meta and (not isinstance(meta["sources"], list) or
|
||||||
|
any(not isinstance(x, str) or not x.strip() for x in meta["sources"])):
|
||||||
|
errors.append("sources 必须是非空字符串组成的列表(可为空列表)")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def slug(text):
|
||||||
|
# Common Gitea/GitHub heading form; keep CJK, words, spaces and hyphens.
|
||||||
|
return "".join(c for c in text.lower() if c in " -_" or
|
||||||
|
unicodedata.category(c)[0] in "LN").replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLLinks(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.links = []
|
||||||
|
self.anchors = set()
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs = dict(attrs)
|
||||||
|
for key in ["href", "src"]:
|
||||||
|
if attrs.get(key):
|
||||||
|
self.links.append(attrs[key])
|
||||||
|
if attrs.get("id"):
|
||||||
|
self.anchors.add(attrs["id"])
|
||||||
|
if tag == "a" and attrs.get("name"):
|
||||||
|
self.anchors.add(attrs["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_body(body):
|
||||||
|
tokens = MarkdownIt("commonmark").enable("table").parse(body)
|
||||||
|
anchors, links = set(), []
|
||||||
|
for i, token in enumerate(tokens):
|
||||||
|
if token.type == "heading_open":
|
||||||
|
inline = tokens[i + 1]
|
||||||
|
text = "".join(t.content for t in inline.children or []
|
||||||
|
if t.type in {"text", "code_inline", "image"})
|
||||||
|
base = slug(text)
|
||||||
|
candidate, suffix = base, 0
|
||||||
|
while candidate in anchors:
|
||||||
|
suffix += 1
|
||||||
|
candidate = f"{base}-{suffix}"
|
||||||
|
anchors.add(candidate)
|
||||||
|
|
||||||
|
def visit(t, line):
|
||||||
|
line = t.map[0] + 1 if t.map else line
|
||||||
|
if t.type in {"link_open", "image"}:
|
||||||
|
url = t.attrGet("href" if t.type == "link_open" else "src")
|
||||||
|
if url is not None:
|
||||||
|
links.append((line, url))
|
||||||
|
if t.type in {"html_inline", "html_block"}:
|
||||||
|
html = HTMLLinks()
|
||||||
|
html.feed(t.content)
|
||||||
|
anchors.update(html.anchors)
|
||||||
|
links.extend((line, u) for u in html.links)
|
||||||
|
for child in t.children or []:
|
||||||
|
visit(child, line)
|
||||||
|
visit(token, 1)
|
||||||
|
return anchors, links
|
||||||
|
|
||||||
|
|
||||||
|
def check(root, files):
|
||||||
|
root = root.resolve()
|
||||||
|
errors, documents = [], {}
|
||||||
|
for relative in files:
|
||||||
|
path = root / relative
|
||||||
|
try:
|
||||||
|
if not path.resolve().is_relative_to(root):
|
||||||
|
raise ValueError("文件指向仓库外部")
|
||||||
|
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
|
||||||
|
required = relative.startswith("services/") and relative not in SERVICE_INDEXES
|
||||||
|
errors.extend(f"{relative}:1: {e}" for e in metadata_errors(
|
||||||
|
meta, required=required, template=relative.startswith("templates/")))
|
||||||
|
documents[relative] = parse_body(body)
|
||||||
|
except (ValueError, OSError, yaml.YAMLError) as exc:
|
||||||
|
# Do not print YAML source lines: malformed frontmatter may contain secrets.
|
||||||
|
errors.append(f"{relative}:1: 无法解析文件或 frontmatter({type(exc).__name__})")
|
||||||
|
indexed = set()
|
||||||
|
for relative, (_, links) in documents.items():
|
||||||
|
for line, url in links:
|
||||||
|
prefix = f"{relative}:{line}: "
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(prefix + "URL 格式无效")
|
||||||
|
continue
|
||||||
|
if parsed.scheme in {"http", "https", "mailto", "tel", "data"} or parsed.netloc:
|
||||||
|
continue
|
||||||
|
if parsed.scheme or parsed.path.startswith("/"):
|
||||||
|
errors.append(prefix + "链接必须使用仓库内相对路径或网页 URL")
|
||||||
|
continue
|
||||||
|
target = ((root / relative).parent / unquote(parsed.path)).resolve() if parsed.path else root / relative
|
||||||
|
if not target.is_relative_to(root):
|
||||||
|
errors.append(prefix + "链接越出仓库")
|
||||||
|
continue
|
||||||
|
if not target.exists():
|
||||||
|
errors.append(prefix + f"目标不存在:{unquote(parsed.path)}")
|
||||||
|
continue
|
||||||
|
dest = target.relative_to(root).as_posix()
|
||||||
|
if relative == "services/index.md":
|
||||||
|
indexed.add(dest)
|
||||||
|
anchor = unquote(parsed.fragment)
|
||||||
|
if anchor and dest in documents and anchor not in documents[dest][0]:
|
||||||
|
errors.append(prefix + f"标题锚点不存在:{dest}#{anchor}")
|
||||||
|
for relative in documents:
|
||||||
|
if relative.startswith("services/") and relative not in SERVICE_INDEXES and relative not in indexed:
|
||||||
|
errors.append(f"{relative}:1: 服务页未被 services/index.md 链接")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
|
args = parser.parse_args()
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["git", "-C", str(args.root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"])
|
||||||
|
files = sorted({p for p in output.decode().split("\0") if p.endswith(".md")})
|
||||||
|
errors = check(args.root, files)
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"文档检查通过:{len(files)} 个 Markdown 文件;未联网或执行文档示例。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||||
|
from check_docs import check, metadata_errors, split_frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentChecks(unittest.TestCase):
|
||||||
|
def run_check(self, documents):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for name, text in documents.items():
|
||||||
|
file = root / name
|
||||||
|
file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file.write_text(text)
|
||||||
|
return check(root, list(documents))
|
||||||
|
|
||||||
|
def test_commonmark_links_and_code_examples(self):
|
||||||
|
errors = self.run_check({
|
||||||
|
"README.md": "[引用][page]\n\n[page]: guide.md#中文-标题\n\n"
|
||||||
|
"\n\n`[假链接](missing.md)`\n\n"
|
||||||
|
"```md\n[示例](missing.md)\n```\n",
|
||||||
|
"guide.md": "# 中文 标题\n",
|
||||||
|
"asset.svg": "<svg/>",
|
||||||
|
})
|
||||||
|
self.assertEqual([], errors)
|
||||||
|
|
||||||
|
def test_broken_reference_and_heading(self):
|
||||||
|
errors = self.run_check({"README.md": "[x][r]\n\n[r]: missing.md\n\n[x](#absent)\n"})
|
||||||
|
self.assertTrue(any("目标不存在" in e for e in errors))
|
||||||
|
self.assertTrue(any("标题锚点不存在" in e for e in errors))
|
||||||
|
|
||||||
|
def test_duplicate_headings_and_html_anchor(self):
|
||||||
|
self.assertEqual([], self.run_check({"README.md":
|
||||||
|
'# Same\n# Same\n<a id="custom"></a>\n[x](#same-1) [y](#custom)\n'}))
|
||||||
|
|
||||||
|
def test_escaped_paths_tables_and_external_links(self):
|
||||||
|
self.assertEqual([], self.run_check({
|
||||||
|
"README.md": '| a | b |\n|---|---|\n| [x](a%20b.md) | [web](https://example.invalid) |\n',
|
||||||
|
"a b.md": '# Target\n',
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_outside_repository_rejected(self):
|
||||||
|
errors = self.run_check({"README.md": "[x](../outside.md)\n"})
|
||||||
|
self.assertTrue(any("越出仓库" in e for e in errors))
|
||||||
|
|
||||||
|
def test_frontmatter_dates_and_evidence(self):
|
||||||
|
meta, _ = split_frontmatter('---\ntitle: Example\nlifecycle: active\nevidence: live-verified\n'
|
||||||
|
'last_reviewed: 2026-09-16\nlast_verified: null\n---\n# Title\n')
|
||||||
|
self.assertTrue(any("live-verified" in e for e in metadata_errors(meta, required=True)))
|
||||||
|
meta['last_verified'] = '2026-09-17'
|
||||||
|
self.assertTrue(any("不能晚于" in e for e in metadata_errors(meta, required=True)))
|
||||||
|
meta['last_verified'] = '2026-02-30'
|
||||||
|
self.assertTrue(any("YYYY-MM-DD" in e for e in metadata_errors(meta, required=True)))
|
||||||
|
|
||||||
|
def test_duplicate_yaml_keys_rejected(self):
|
||||||
|
errors = self.run_check({"README.md": '---\ntitle: A\ntitle: B\n---\n'})
|
||||||
|
self.assertTrue(errors)
|
||||||
|
|
||||||
|
def test_new_service_requires_metadata_and_index(self):
|
||||||
|
errors = self.run_check({"services/new.md": "# New\n", "services/index.md": "# Services\n"})
|
||||||
|
self.assertTrue(any("缺少 frontmatter" in e for e in errors))
|
||||||
|
self.assertTrue(any("未被 services/index.md" in e for e in errors))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user