@snowcore8/dsh-global-user-constraints 0.8.1 → 0.8.3

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.
package/README.md CHANGED
@@ -1,152 +1,37 @@
1
- # AI 编码代理 system prompt
1
+ # 用户全局约束:DeepSeek Harness bundle
2
2
 
3
- 本仓库维护用户全局约束、十一个通用规范 skill,以及分别面向 Qoder、Qwen Code 与
4
- DeepSeek Harness(DSH)的三个插件包。`rules/`、`skills/` 与 `shared/` 是唯一真源,
5
- 三个包用相对软链引用它们,打包时物化成自包含目录。
3
+ 安装到 profile 后,以 skill 提供者形式提供十一个通用规范 skill。
6
4
 
7
- 维护者:SnowCore8,职责与决策规则见 [GOVERNANCE.md](GOVERNANCE.md)。
8
- 项目维护约定见 [CONTRIBUTING.md](CONTRIBUTING.md)(根 `AGENTS.md` 是它的软链入口)。
9
-
10
- ## 目录
5
+ ## 结构
11
6
 
12
7
  | 路径 | 作用 |
13
8
  |---|---|
14
- | `rules/global-user-constraints.md` | 用户全局约束正文(唯一权威来源) |
15
- | `skills/` | 十一个 skill 的真源 |
16
- | `shared/commands/` | 手动审查命令 |
17
- | `shared/scripts/` | 约束注入、时间注入脚本及其测试 |
18
- | `plugins/qoder/` | Qoder 插件包(`.qoder-plugin/plugin.json`) |
19
- | `plugins/qwen/` | Qwen Code 扩展包(`qwen-extension.json`) |
20
- | `package.json`(仓库根) | DSH bundle 清单与 git 源安装入口 |
21
- | `plugins/dsh/` | DSH bundle 的插件源码、patch 与说明 |
22
- | `plugins/marketplace.json` | Qoder 本地市场描述,指向 `plugins/qoder` |
23
- | `.qwen/rules`、`.qwen/skills/` | Qwen Code 项目级入口,软链到顶层真源 |
24
- | `.qoder/rules`、`.qoder/skills/` | Qoder 项目级入口,软链到顶层真源 |
25
- | `.dsh/skills/` | DSH 项目级 skill 入口,软链到顶层真源 |
26
- | `AGENTS.md` | 项目维护约定入口,软链指向 `CONTRIBUTING.md` |
27
- | `docs/agent-tooling-loading.md` | 三工具 rules/skills/plugins 加载规则调研 |
28
- | `docs/time-awareness.md` | Qoder/Qwen 时间注入设计 |
29
- | `docs/release.md` | 版本规则、tag 发布流程与回滚 |
30
- | `scripts/` | `check.sh` 全量校验、`dist.sh` 出包、`materialize.sh` 物化 |
31
- | `GLOSSARY.md` | 术语与写法约定 |
32
- | `CHANGELOG.md` | 按版本记录的可观察变更 |
33
- | `GOVERNANCE.md` | 维护者、决策、审查与发布规则 |
34
- | `THIRD-PARTY-NOTICES.md` | 开发依赖与第三方许可声明 |
35
- | `LICENSE`、`LICENSE-CC-BY-4.0.txt`、`LICENSE-MIT.txt` | 双许可证:文档 CC BY 4.0,代码 MIT |
36
- | `SECURITY.md` | 支持的版本与私下漏洞报告渠道 |
37
- | `.github/` | CI 与 Release 工作流、Dependabot、CODEOWNERS 与 PR 模板 |
38
-
39
- ## 编辑
40
-
41
- 1. 改约束:编辑 `rules/global-user-constraints.md`(唯一权威来源)。
42
- 2. 改 skill:编辑 `skills/` 下对应的 skill;三个包自动引用,不复制第二份。
43
- 3. 改命令或注入脚本:编辑 `shared/commands/` 或 `shared/scripts/`。
44
- 4. 改某一端的清单或 hook:编辑 `plugins/<包>/` 下对应文件,并同步三包版本。
45
- 5. 新增入口时按「软链约定」执行,改完运行 `scripts/check.sh`。
46
-
47
- ## 软链约定
48
-
49
- - 软链只做入口,不承载内容:目标必须在仓库内、用相对路径、指向真实文件或目录,且不指向另一软链。
50
- - 每个入口只指向唯一真源(`rules/`、`skills/` 或 `shared/`),不复制第二份文件。
51
- - 三个包内部的 `skills/`、`rules/`、`commands/`、`scripts/` 都是指向顶层真源的软链;
52
- Qoder 的 `plugins validate` 会拒绝解析到包外的路径,所以安装或校验前用
53
- `scripts/materialize.sh` 物化,或直接用 Release 里的 zip。
54
- - `scripts/check.sh` 的「软链」「插件清单」段校验未入库、缺失、绝对路径、目标出仓、指向 `.git`、
55
- 链式软链、目标不存在或未跟踪,以及三个包的软链是否指向正确真源。
56
- - Windows 检出需启用 `core.symlinks`;否则软链会落成普通文本文件。
57
-
58
- ## 三个包
59
-
60
- | 包 | 清单 | hook 形式 | 安装 |
61
- |---|---|---|---|
62
- | `plugins/qoder` | `.qoder-plugin/plugin.json` | `command` + `args`,`${QODER_PLUGIN_ROOT}` | `qoderclicn plugins install <zip> --scope user` |
63
- | `plugins/qwen` | `qwen-extension.json` | 单条 `command` 字符串,`${CLAUDE_PLUGIN_ROOT}` | `qwen extensions install <zip>` |
64
- | `plugins/dsh` | 仓库根 `package.json` 的 `dsh.bundle.patch` | 无 hook;注册 skill 提供者 | `dsh plugin --profile <名> add "github:SnowCore8/system_prompt#<tag>"` |
65
-
66
- 三包共用版本号,发布时一起出包;清单版本不一致会被 `scripts/check.sh`、`scripts/dist.sh`
67
- 与 release 工作流拒绝。
68
-
69
- ## DeepSeek Harness 适配
70
-
71
- - 项目级:`.dsh/skills/` 是 rank 100 的 skill 扫描根,软链到顶层 `skills/`;根 `AGENTS.md`
72
- 由 `dsh-agent-instructions` 作为指令链加载。
73
- - bundle:仓库根 `package.json` 声明 `dsh.bundle.patch`,`plugins/dsh/src/index.js` 注册
74
- `ctx.skills.registerProvider`,把十一个 skill 提供给任意 profile。
75
- - 安装:发布流水线把根包发到 npm 公共 registry,直接
76
- `pnpm dsh plugin --profile web add @snowcore8/dsh-global-user-constraints@<版本>`,
77
- 插件页能检测到更新。也可以从 GitHub 源安装(tag 方式不会检测更新):
78
- `pnpm dsh plugin --profile web add "github:SnowCore8/system_prompt#v0.8.0"`。
79
- 细节见 [plugins/dsh/README.md](plugins/dsh/README.md)。
80
- - 约束:DSH 的用户级指令链读取 `~/.dsh/AGENTS.md`,把 `rules/global-user-constraints.md`
81
- 复制到该位置即可;时间由 DSH 自身注入,不需要额外 hook。
82
-
83
- ## Qwen Code 适配
9
+ | `package.json` | npm 包与 `dsh.bundle.patch` 声明 |
10
+ | `plugins/dsh/cordis.patch.yml` | 插入 skill 提供者行 `global-user-constraints-skills` |
11
+ | `plugins/dsh/src/index.js` | Cordis 插件:`apply(ctx)` 注册 `ctx.skills.registerProvider` |
12
+ | `skills/` | 十一个通用规范 skill |
13
+ | `rules/global-user-constraints.md` | 用户全局约束正文 |
84
14
 
85
- - 项目级:`.qwen/rules/` 软链到 `rules/`(无 `paths:`,baseline,首请求生效);`.qwen/skills/`
86
- 软链到十一个 skill;`.qwen/AGENTS.md` 不被 Qwen 读取,仓库不提供。
87
- - 扩展:`plugins/qwen` 的 `qwen-extension.json` 指向 `hooks/qwen-hooks.json`,安装后
88
- `SessionStart` 注入约束全文与时间,`UserPromptSubmit` 刷新时间;已有同内容规则或
89
- `AGENTS.md` 时跳过。
90
- - 时间:Qwen 没有内建时间感知,注入格式与设计见 [docs/time-awareness.md](docs/time-awareness.md)。
15
+ ## 安装
91
16
 
92
- ## Qoder 适配
93
-
94
- - 项目级:`.qoder/rules` 软链到 `rules/`,Qoder 从当前目录向上查到 `.git` 逐层读取;
95
- `.qoder/skills/` 软链到十一个 skill。
96
- - 插件:`plugins/qoder` 安装后 `SessionStart` 注入约束全文与时间,`UserPromptSubmit` 刷新时间;
97
- 去重路径覆盖 `~/.qoder-cn/rules/`(国际版 `~/.qoder/rules/`)、`AGENTS.md`、`.qoder/rules/`。
98
- - 项目级不提供 `.qoder-cn` 与 `.qoder/plugins`:它们在项目级没有加载语义。
99
-
100
- ## 验证
17
+ 来自 npm 公共 registry(推荐,插件页可检测更新):
101
18
 
102
19
  ```bash
103
- python3 -m pip install -r requirements-dev.txt # 建议用虚拟环境
104
- scripts/check.sh
20
+ pnpm dsh plugin --profile web add @snowcore8/dsh-global-user-constraints@<版本>
105
21
  ```
106
22
 
107
- `scripts/check.sh` 覆盖 Markdown、skill 与 hook 测试、DSH bundle 的 `node --test`、shellcheck、
108
- ruff、JSON、敏感文件、软链、三工具项目级适配、三包清单一致性与物化后的 Qoder 插件校验。
109
- 缺 `shellcheck`、`node` 或 `qoderclicn` 时会打印「跳过」并在结尾计数。
110
-
111
- 本地安装 smoke test:
23
+ 也可以从 Git 源安装(tag 方式不会检测更新)或解压 Release 里的 dsh zip 后安装目录:
112
24
 
113
25
  ```bash
114
- scripts/materialize.sh qoder /tmp/qoder-package
115
- qoderclicn --cwd /tmp plugins install --scope local /tmp/qoder-package
116
- qoderclicn --cwd /tmp plugins list --json
26
+ pnpm dsh plugin --profile web add "github:SnowCore8/system_prompt#v<版本>"
27
+ pnpm dsh plugin --profile web add /path/to/global-user-constraints-dsh-<版本>
117
28
  ```
118
29
 
119
- ## 打包
120
-
121
- 版本采用 SemVer,三个清单的 `version` 必须一致;发布流程是「改三包版本与 CHANGELOG → 提交 →
122
- 打 `v<version>` 注释 tag → 推送」,由 release 工作流分别构建三个 zip,详见 [docs/release.md](docs/release.md)。
123
-
124
- 本地出包运行 `scripts/dist.sh`:默认要求工作区干净(`--allow-dirty` 放行),浅克隆直接拒绝,
125
- `--require-tag` 要求 HEAD 正好带有 `v<version>` tag。每个包物化后归一 mtime 与权限、按名称排序、
126
- 用 `zip -X` 构建,输出可复现 zip 与 SHA256,各保留最近三个制品。zip 与 `*.sha256` 被 `.gitignore`
127
- 忽略,不提交。
128
-
129
- ## CI
130
-
131
- - `check`:push 到 `master` 与 pull request 时运行 `scripts/check.sh`,最小 `contents: read` 权限,
132
- 并先确认 runner 自带 `shellcheck`。
133
- - `release`:推送 `v*` tag 时核对三包版本与 tag,构建三个 zip 与 SHA256 并挂到 Release;
134
- 该工作流没有 `qoderclicn`,用 `--no-validate` 换取可重复构建,插件校验在本地发布前完成。
135
- - 依赖更新由 Dependabot 每周检查 pip 与 GitHub Actions。
136
- - 分支保护在私有仓库免费计划下不可用(需 GitHub Pro),升级后按 GOVERNANCE 的保护项启用。
137
-
138
- ## 安全
139
-
140
- 漏洞报告首选 GitHub 私下漏洞报告,入口不可用时邮件联系维护者,不接受公开 issue 披露;支持版本、
141
- 处理流程、范围与渠道可用性见 [SECURITY.md](SECURITY.md)。仓库不保存任何密钥或个人数据。
142
-
143
- ## 许可证
144
-
145
- 双许可证:文档与规范正文(`rules/`、skill 正文、`docs/`、说明类 Markdown)采用
146
- [CC BY 4.0](LICENSE-CC-BY-4.0.txt),代码、脚本与配置(`scripts/`、`shared/`、`plugins/*/hooks/`、
147
- `.github/`)采用 [MIT](LICENSE-MIT.txt)。范围划分见 [LICENSE](LICENSE)。
30
+ 装完开新会话;没生效重启 `pnpm dsh web`。
148
31
 
149
- ## 变更日志
32
+ ## 行为
150
33
 
151
- 可观察变更按版本记录在 [CHANGELOG.md](CHANGELOG.md);版本采用 SemVer,发布点为 tag `v<version>`,
152
- 对应的 zip 与 SHA256 挂在 GitHub Release。
34
+ - 行 id 是 `global-user-constraints-skills`,可在 profile 的 `cordis.patch.yml` 里覆盖或禁用。
35
+ - 版本准入由 `engines.dsh` 声明(当前 `>=0.2.2-rc.1 <0.3.0`),不匹配会被跳过。
36
+ - 约束正文:把 `rules/global-user-constraints.md` 复制到 `~/.dsh/AGENTS.md` 即可由 DSH
37
+ 用户级指令链加载;时间由 DSH 自身注入,本包不重复。
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@snowcore8/dsh-global-user-constraints",
3
- "version": "0.8.1",
4
- "description": "DSH bundle(Git 源安装入口):以 skill 提供者形式暴露用户全局约束的十一个通用规范 skill。",
3
+ "version": "0.8.3",
4
+ "description": "DSH bundle:安装后以 skill 提供者形式提供十一个通用规范 skill;约束正文由 DSH 用户级 AGENTS.md 承担。",
5
5
  "type": "module",
6
6
  "main": "plugins/dsh/src/index.js",
7
7
  "files": [
8
8
  "plugins/dsh/src",
9
9
  "plugins/dsh/cordis.patch.yml",
10
- "plugins/dsh/README.md",
11
10
  "skills",
12
11
  "rules",
13
12
  "README.md",
@@ -1,65 +1,37 @@
1
1
  # 用户全局约束:DeepSeek Harness bundle
2
2
 
3
- DSH bundle。安装到 profile 后,以 skill 提供者形式暴露仓库的十一个通用规范 skill。
3
+ 安装到 profile 后,以 skill 提供者形式提供十一个通用规范 skill。
4
4
 
5
5
  ## 结构
6
6
 
7
7
  | 路径 | 作用 |
8
8
  |---|---|
9
- | `package.json`(仓库根) | npm 包与 `dsh.bundle.patch` 声明,版本与另外两包一致 |
9
+ | `package.json` | npm 包与 `dsh.bundle.patch` 声明 |
10
10
  | `plugins/dsh/cordis.patch.yml` | 插入 skill 提供者行 `global-user-constraints-skills` |
11
11
  | `plugins/dsh/src/index.js` | Cordis 插件:`apply(ctx)` 注册 `ctx.skills.registerProvider` |
12
- | `plugins/dsh/test/provider.test.mjs` | 提供者行为测试(`node --test`) |
13
- | `skills/`、`rules/`(仓库根) | 真源,同时被 npm 包、git 源安装与 zip 打包使用 |
12
+ | `skills/` | 十一个通用规范 skill |
13
+ | `rules/global-user-constraints.md` | 用户全局约束正文 |
14
14
 
15
15
  ## 安装
16
16
 
17
- 包发布在 npm 公共 registry,安装不需要额外配置:
17
+ 来自 npm 公共 registry(推荐,插件页可检测更新):
18
18
 
19
19
  ```bash
20
- cd /home/snow/deepseek-harness
21
- pnpm dsh plugin --profile web add @snowcore8/dsh-global-user-constraints@0.8.0
20
+ pnpm dsh plugin --profile web add @snowcore8/dsh-global-user-constraints@<版本>
22
21
  ```
23
22
 
24
- 装完开新会话;没生效重启 `pnpm dsh web`。
25
-
26
- ## 更新
27
-
28
- - npm 的 registry 元数据保留自定义字段,DSH 插件页能读到 `dsh.bundle`,打开时会列出可用版本并支持一键更新;
29
- - 命令行:`pnpm dsh plugin --profile web add @snowcore8/dsh-global-user-constraints@latest`;
30
- - 卸载:`pnpm dsh plugin --profile web remove @snowcore8/dsh-global-user-constraints`。
31
-
32
- ## 备选安装方式
33
-
34
- Git 源(不会检测更新;私有仓库需 `gh auth setup-git`):
23
+ 也可以从 Git 源安装(tag 方式不会检测更新)或解压 Release 里的 dsh zip 后安装目录:
35
24
 
36
25
  ```bash
37
- pnpm dsh plugin --profile web add "github:SnowCore8/system_prompt#v0.8.0"
26
+ pnpm dsh plugin --profile web add "github:SnowCore8/system_prompt#v<版本>"
27
+ pnpm dsh plugin --profile web add /path/to/global-user-constraints-dsh-<版本>
38
28
  ```
39
29
 
40
- 本地目录或 Release zip:
41
-
42
- ```bash
43
- scripts/materialize.sh dsh /tmp/dsh-bundle
44
- dsh plugin --profile web add /tmp/dsh-bundle
45
- ```
46
-
47
- ## 发布
48
-
49
- - 包内容(十一个 skill 与约束正文)随 npm 公开包发布,仓库本身仍可私有;不要把新增的敏感内容放进 `skills/` 或 `rules/`。
50
- - 发布流水线在 `v*` tag 上执行 `npm publish --access public`,使用仓库 secret `NPM_TOKEN`。
51
- - npm 账号或组织必须拥有 `@snowcore8` scope;已发布版本不能覆盖,必须换新版本号。
30
+ 装完开新会话;没生效重启 `pnpm dsh web`。
52
31
 
53
32
  ## 行为
54
33
 
55
34
  - 行 id 是 `global-user-constraints-skills`,可在 profile 的 `cordis.patch.yml` 里覆盖或禁用。
56
35
  - 版本准入由 `engines.dsh` 声明(当前 `>=0.2.2-rc.1 <0.3.0`),不匹配会被跳过。
57
- - 约束正文:DSH 的用户级指令链读取 `~/.dsh/AGENTS.md`,把 `rules/global-user-constraints.md`
58
- 复制到该位置即可生效;时间由 DSH 自身注入,本包不重复。
59
-
60
- ## 测试
61
-
62
- ```bash
63
- node --test plugins/dsh/test/provider.test.mjs
64
- scripts/check.sh
65
- ```
36
+ - 约束正文:把 `rules/global-user-constraints.md` 复制到 `~/.dsh/AGENTS.md` 即可由 DSH
37
+ 用户级指令链加载;时间由 DSH 自身注入,本包不重复。
@@ -1,903 +0,0 @@
1
- #!/usr/bin/env python3
2
- """check.py 的自测用例。
3
-
4
- 用标准库 unittest 编写,装了 pytest 也能直接跑:
5
- python3 -m unittest discover -s scripts
6
- python3 -m pytest scripts/test_check.py
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import contextlib
12
- import io
13
- import sys
14
- import tempfile
15
- import unittest
16
- from pathlib import Path
17
-
18
- sys.path.insert(0, str(Path(__file__).resolve().parent))
19
-
20
- import check # noqa: E402
21
-
22
- MD = check.load_markdown_it()
23
- BASE = Path("/nonexistent-base")
24
-
25
-
26
- def build(text: str) -> check.Doc:
27
- return check.Doc(text, MD)
28
-
29
-
30
- def all_findings(text: str, base: Path = BASE) -> list[check.Finding]:
31
- return check.check_format(build(text), base)
32
-
33
-
34
- def rules_of(text: str, base: Path = BASE) -> list[str]:
35
- return [finding.rule for finding in all_findings(text, base)]
36
-
37
-
38
- def run_main(*args: str) -> tuple[int, str, str]:
39
- old_argv = sys.argv
40
- out, err = io.StringIO(), io.StringIO()
41
- sys.argv = ["check.py", *args]
42
- try:
43
- with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
44
- code = check.main()
45
- finally:
46
- sys.argv = old_argv
47
- return code, out.getvalue(), err.getvalue()
48
-
49
-
50
- class DocParsingTest(unittest.TestCase):
51
- """Markdown 结构的基本解析。"""
52
-
53
- def test_setext_headings(self):
54
- text = "Role and Objective\n==================\n\nOutput Format\n-------------\n\n- 结果。\n"
55
- doc = build(text)
56
- self.assertEqual(
57
- [(h.level, h.text) for h in doc.headings],
58
- [(1, "Role and Objective"), (2, "Output Format")],
59
- )
60
-
61
- def test_atx_and_setext_mixed(self):
62
- text = "Role and Objective\n==================\n\n## 子节\n\n正文。\n"
63
- self.assertEqual([h.level for h in build(text).headings], [1, 2])
64
-
65
- def test_rich_heading_text(self):
66
- doc = build("# **角色** 与 [目标](https://example.invalid)\n\n正文。\n")
67
- self.assertEqual(doc.headings[0].text, "角色 与 目标")
68
-
69
- def test_inline_code_in_heading(self):
70
- doc = build("# 用 `pytest` 测试\n\n正文。\n")
71
- self.assertEqual(doc.headings[0].text, "用 pytest 测试")
72
-
73
- def test_bom_stripped(self):
74
- doc = build("\ufeff# 标题\n\n正文。\n")
75
- self.assertEqual([h.level for h in doc.headings], [1])
76
-
77
- def test_front_matter_skipped(self):
78
- text = "---\nname: demo\ndescription: 说明\n---\n\n# 标题\n\n正文。\n"
79
- doc = build(text)
80
- self.assertEqual(doc.front_matter, {1, 2, 3, 4})
81
- self.assertEqual([h.text for h in doc.headings], ["标题"])
82
-
83
- def test_blank_line_inside_delimiters_not_front_matter(self):
84
- text = "---\nname: demo\n\n# 标题\n---\n\n正文。\n"
85
- self.assertEqual(build(text).front_matter, set())
86
-
87
- def test_thematic_breaks_not_front_matter(self):
88
- text = "---\n\n# 标题\n\n---\n\n正文\n"
89
- self.assertEqual(build(text).front_matter, set())
90
-
91
- def test_quote_fence_closed(self):
92
- text = "> ```text\n> 引用里的围栏\n> ```\n"
93
- self.assertIsNone(build(text).unclosed)
94
-
95
- def test_indented_code_lines(self):
96
- text = "# 标题\n\n正文。\n\n code\n more\n"
97
- self.assertEqual(build(text).code_lines, {5, 6})
98
-
99
- def test_table_cells(self):
100
- text = "| 甲 | 乙 |\n|---|---|\n| 1 | 2 | 3 |\n"
101
- self.assertEqual([(t.start, t.cols) for t in build(text).tables], [(1, [2, 3])])
102
-
103
- def test_html_block_lines(self):
104
- text = "# 标题\n\n<div>\n正文\n</div>\n"
105
- self.assertEqual(build(text).html_lines, {3, 4, 5})
106
-
107
- def test_content_lines_and_blank_count(self):
108
- text = "# 标题\n\n```\ncode\n```\n\n正文。\n"
109
- doc = build(text)
110
- self.assertEqual(doc.blank_count, 2)
111
- self.assertEqual([line for _, line in doc.content_lines], ["# 标题", "正文。"])
112
-
113
-
114
- class HeadingTest(unittest.TestCase):
115
- def test_missing_space_reported(self):
116
- self.assertIn("MD-HEAD-SPACE", rules_of("# 标题\n\n##没有空格\n"))
117
-
118
- def test_closing_hashes_not_reported(self):
119
- self.assertNotIn("MD-HEAD-SPACE", rules_of("# 标题\n\n## 子节 ##\n"))
120
-
121
- def test_heading_space_in_fence_ignored(self):
122
- self.assertNotIn("MD-HEAD-SPACE", rules_of("# 标题\n\n```\n##没有空格\n```\n"))
123
-
124
- def test_first_heading_not_h1(self):
125
- self.assertIn("MD-HEAD-START", rules_of("## 标题\n\n正文。\n"))
126
-
127
- def test_first_heading_h1(self):
128
- self.assertNotIn("MD-HEAD-START", rules_of("# 标题\n\n## 子节\n"))
129
-
130
- def test_level_jump(self):
131
- self.assertIn("MD-HEAD-JUMP", rules_of("# 标题\n\n### 子节\n"))
132
-
133
- def test_normal_levels(self):
134
- self.assertNotIn("MD-HEAD-JUMP", rules_of("# 标题\n\n## 子节\n\n### 孙节\n"))
135
-
136
- def test_empty_heading(self):
137
- self.assertIn("MD-HEAD-EMPTY", rules_of("# 标题\n\n##\n\n正文。\n"))
138
-
139
- def test_duplicate_heading_case_insensitive(self):
140
- text = "# 标题\n\n## Example\n\n甲。\n\n## example\n\n乙。\n"
141
- self.assertIn("MD-HEAD-DUP", rules_of(text))
142
-
143
- def test_distinct_headings(self):
144
- self.assertNotIn("MD-HEAD-DUP", rules_of("# 标题\n\n## 甲\n\n## 乙\n"))
145
-
146
-
147
- class ListTest(unittest.TestCase):
148
- def test_missing_space_cjk(self):
149
- self.assertIn("MD-LIST-SPACE", rules_of("# 标题\n\n-项内容。\n"))
150
-
151
- def test_missing_space_latin(self):
152
- self.assertIn("MD-LIST-SPACE", rules_of("# 标题\n\n1.项内容。\n"))
153
-
154
- def test_glob_flag_negative_not_reported(self):
155
- for line in ("- 只改 *.py 文件。", "- 用 --flag 参数。", "- 温度 -5 度。"):
156
- with self.subTest(line=line):
157
- self.assertNotIn("MD-LIST-SPACE", rules_of(f"# 标题\n\n{line}\n"))
158
-
159
- def test_thematic_break_not_reported(self):
160
- self.assertNotIn(
161
- "MD-LIST-SPACE", rules_of("# 标题\n\n- 上一条。\n\n---\n\n- 下一条。\n")
162
- )
163
-
164
- def test_mixed_marker_same_run(self):
165
- self.assertIn("MD-LIST-MARKER", rules_of("# 标题\n\n- a\n* b\n"))
166
-
167
- def test_marker_change_between_runs_allowed(self):
168
- self.assertNotIn("MD-LIST-MARKER", rules_of("# 标题\n\n- a\n\n* b\n"))
169
-
170
- def test_nested_mixed_marker(self):
171
- self.assertIn("MD-LIST-MARKER", rules_of("# 标题\n\n- a\n - b\n * c\n"))
172
-
173
- def test_ordered_list_not_starting_at_one(self):
174
- self.assertIn("MD-LIST-START", rules_of("# 标题\n\n5. a\n6. b\n"))
175
-
176
- def test_ordered_list_from_one(self):
177
- self.assertNotIn("MD-LIST-START", rules_of("# 标题\n\n1. a\n2. b\n"))
178
-
179
- def test_empty_list_item(self):
180
- self.assertIn("MD-LIST-EMPTY", rules_of("# 标题\n\n- \n- b\n"))
181
-
182
- def test_sibling_indent_mismatch(self):
183
- self.assertIn("MD-LIST-INDENT", rules_of("# 标题\n\n- a\n - a1\n - a2\n"))
184
-
185
- def test_too_deep_loses_structure(self):
186
- self.assertIn("MD-LIST-INDENT", rules_of("# 标题\n\n- a\n - b\n"))
187
-
188
- def test_valid_indents(self):
189
- text = "# 标题\n\n- a\n - b\n\n- c\n - d\n\n- e\n - f\n"
190
- self.assertNotIn("MD-LIST-INDENT", rules_of(text))
191
-
192
- def test_separate_lists_do_not_conflict(self):
193
- self.assertNotIn("MD-LIST-INDENT", rules_of("# 标题\n\n- a\n - b\n\n- c\n - d\n"))
194
-
195
- def test_ordered_deep_indent_ok(self):
196
- self.assertNotIn("MD-LIST-INDENT", rules_of("# 标题\n\n1. a\n 1. b\n"))
197
-
198
- def test_indented_code_exempt(self):
199
- self.assertNotIn("MD-LIST-INDENT", rules_of("# 标题\n\n正文。\n\n - 代码里的\n"))
200
-
201
-
202
- class TableTest(unittest.TestCase):
203
- def test_extra_cell(self):
204
- text = "# 标题\n\n| 甲 | 乙 |\n|---|---|\n| 1 | 2 | 3 |\n"
205
- self.assertIn("MD-TABLE-COLS", rules_of(text))
206
-
207
- def test_escaped_pipe_not_counted(self):
208
- text = "# 标题\n\n| 甲 | 乙 |\n|---|---|\n| a \\| b | c |\n"
209
- self.assertNotIn("MD-TABLE-COLS", rules_of(text))
210
-
211
- def test_unescaped_pipe_in_code_span_is_counted(self):
212
- text = "# 标题\n\n| 甲 | 乙 |\n|---|---|\n| `x | y` | 2 |\n"
213
- self.assertIn("MD-TABLE-COLS", rules_of(text))
214
-
215
- def test_missing_delimiter_row(self):
216
- self.assertIn("MD-TABLE-BROKEN", rules_of("# 标题\n\n|a|b|\n|1|2|\n"))
217
-
218
- def test_short_delimiter_row(self):
219
- self.assertIn("MD-TABLE-BROKEN", rules_of("# 标题\n\n|a|b|\n|-|\n|1|2|\n"))
220
-
221
- def test_valid_table(self):
222
- self.assertNotIn("MD-TABLE-BROKEN", rules_of("# 标题\n\n|a|b|\n|-|-|\n|1|2|\n"))
223
-
224
- def test_single_pipe_line(self):
225
- self.assertNotIn("MD-TABLE-BROKEN", rules_of("# 标题\n\n| 命令 | 说明 |\n"))
226
-
227
- def test_pipe_rows_in_fence(self):
228
- self.assertNotIn("MD-TABLE-BROKEN", rules_of("# 标题\n\n```\n|a|b|\n|1|2|\n```\n"))
229
-
230
- def test_broken_table_in_quote(self):
231
- self.assertIn("MD-TABLE-BROKEN", rules_of("> # 标题\n>\n> |a|b|\n> |1|2|\n"))
232
-
233
- def test_valid_table_in_quote(self):
234
- self.assertNotIn(
235
- "MD-TABLE-BROKEN", rules_of("> # 标题\n>\n> |a|b|\n> |-|-|\n> |1|2|\n")
236
- )
237
-
238
-
239
- class LinkTest(unittest.TestCase):
240
- def test_undefined_reference(self):
241
- self.assertIn("MD-LINK-REF", rules_of("# 标题\n\n见 [文字][没有定义]。\n"))
242
-
243
- def test_defined_reference(self):
244
- text = "# 标题\n\n见 [文字][docs]。\n\n[docs]: https://example.invalid\n"
245
- self.assertNotIn("MD-LINK-REF", rules_of(text))
246
-
247
- def test_definition_in_fence_not_counted(self):
248
- text = "# 标题\n\n见 [文字][docs]。\n\n```\n[docs]: /x.md\n```\n"
249
- self.assertIn("MD-LINK-REF", rules_of(text))
250
-
251
- def test_duplicate_definition(self):
252
- text = "# 标题\n\n见 [文字][docs]。\n\n[docs]: /a.md\n[docs]: /b.md\n"
253
- self.assertIn("MD-LINK-DEF-DUP", rules_of(text))
254
-
255
- def test_inline_link_not_reference(self):
256
- self.assertNotIn(
257
- "MD-LINK-REF", rules_of("# 标题\n\n见 [文字](https://example.invalid)。\n")
258
- )
259
-
260
- def test_multiline_definition(self):
261
- text = "见 [文字][doc]。\n\n[doc]:\n https://a.invalid\n"
262
- self.assertNotIn("MD-LINK-REF", rules_of(text))
263
-
264
- def test_definition_inside_list(self):
265
- text = "见 [文字][doc]。\n\n- [doc]: https://a.invalid\n"
266
- self.assertNotIn("MD-LINK-REF", rules_of(text))
267
-
268
- def test_collapsed_reference_used(self):
269
- text = "见 [doc]。\n\n[doc]: https://a.invalid\n"
270
- self.assertNotIn("MD-LINK-DEF-UNUSED", rules_of(text))
271
-
272
- def test_uppercase_label_matches(self):
273
- text = "见 [文字][DOC]。\n\n[doc]: https://a.invalid\n"
274
- self.assertNotIn("MD-LINK-REF", rules_of(text))
275
-
276
- def test_unused_definition(self):
277
- self.assertIn("MD-LINK-DEF-UNUSED", rules_of("# 标题\n\n[a]: https://a.invalid\n"))
278
-
279
- def test_empty_label(self):
280
- self.assertIn("MD-LINK-EMPTY", rules_of("# 标题\n\n见 [](https://example.invalid)。\n"))
281
-
282
- def test_empty_target(self):
283
- self.assertIn("MD-LINK-EMPTY", rules_of("# 标题\n\n见 [文字]()。\n"))
284
-
285
- def test_empty_image_alt(self):
286
- self.assertIn("MD-LINK-EMPTY", rules_of("# 标题\n\n![](https://example.invalid/a.png)\n"))
287
-
288
- def test_image_wrapped_link_not_empty(self):
289
- text = "# 标题\n\n[![图](https://example.invalid/a.png)](https://example.invalid/b)\n"
290
- self.assertNotIn("MD-LINK-EMPTY", rules_of(text))
291
-
292
- def test_broken_link_syntax(self):
293
- self.assertIn("MD-LINK-SYNTAX", rules_of("# 标题\n\n- 读 [说明](a.md 再改。\n"))
294
-
295
- def test_space_in_target(self):
296
- self.assertIn("MD-LINK-SYNTAX", rules_of("# 标题\n\n- 读 [说明](my file.md) 再改。\n"))
297
-
298
- def test_valid_link(self):
299
- self.assertNotIn(
300
- "MD-LINK-SYNTAX", rules_of("# 标题\n\n- 读 [说明](https://a.invalid) 再改。\n")
301
- )
302
-
303
- def test_one_good_one_bad_on_same_line(self):
304
- text = "# 标题\n\n- 读 [好](https://a.invalid) 与 [坏](b.md。\n"
305
- self.assertIn("MD-LINK-SYNTAX", rules_of(text))
306
-
307
- def test_link_syntax_in_fence_ignored(self):
308
- text = "# 标题\n\n```\n- 读 [说明](a.md 再改。\n```\n"
309
- self.assertNotIn("MD-LINK-SYNTAX", rules_of(text))
310
-
311
- def test_valid_link_on_second_line_of_paragraph(self):
312
- text = "# 标题\n\n第一行。\n第二行 [说明](https://a.invalid)。\n"
313
- self.assertNotIn("MD-LINK-SYNTAX", rules_of(text))
314
-
315
- def test_broken_link_on_second_line_of_paragraph(self):
316
- text = "# 标题\n\n第一行。\n第二行 [说明](a.md 再改。\n"
317
- self.assertIn("MD-LINK-SYNTAX", rules_of(text))
318
-
319
- def test_mixed_links_across_paragraph_lines(self):
320
- text = "# 标题\n\n第一行 [好](https://a.invalid)。\n第二行 [坏](b.md。\n"
321
- self.assertIn("MD-LINK-SYNTAX", rules_of(text))
322
-
323
-
324
- class LinkTargetTest(unittest.TestCase):
325
- def setUp(self):
326
- self.tmp = tempfile.TemporaryDirectory()
327
- self.root = Path(self.tmp.name)
328
- (self.root / "docs").mkdir()
329
- (self.root / "docs" / "real.md").write_text("# x\n", encoding="utf-8")
330
- (self.root / "docs" / "我 的.md").write_text("# x\n", encoding="utf-8")
331
- (self.root / "img").mkdir()
332
- (self.root / "img" / "a.png").write_text("x", encoding="utf-8")
333
-
334
- def tearDown(self):
335
- self.tmp.cleanup()
336
-
337
- def _rules(self, text: str) -> list[str]:
338
- return rules_of(text, self.root)
339
-
340
- def test_missing_target(self):
341
- self.assertIn("MD-LINK-TARGET", self._rules("# 标题\n\n见 [文档](docs/nope.md)。\n"))
342
-
343
- def test_existing_target(self):
344
- self.assertNotIn("MD-LINK-TARGET", self._rules("# 标题\n\n见 [文档](docs/real.md)。\n"))
345
-
346
- def test_external_target(self):
347
- self.assertNotIn(
348
- "MD-LINK-TARGET", self._rules("# 标题\n\n[文档](https://e.invalid/a.md)\n")
349
- )
350
-
351
- def test_anchor_target_not_checked_here(self):
352
- self.assertNotIn("MD-LINK-TARGET", self._rules("# 标题\n\n[跳转](#标题)\n"))
353
-
354
- def test_missing_image_target(self):
355
- self.assertIn("MD-LINK-TARGET", self._rules("# 标题\n\n![图](img/nope.png)\n"))
356
-
357
- def test_existing_image_target(self):
358
- self.assertNotIn("MD-LINK-TARGET", self._rules("# 标题\n\n![图](img/a.png)\n"))
359
-
360
- def test_percent_encoded_target(self):
361
- self.assertNotIn(
362
- "MD-LINK-TARGET", self._rules("# 标题\n\n[文档](docs/我 的.md)\n")
363
- )
364
-
365
- def test_very_long_target_does_not_crash(self):
366
- self.assertIn("MD-LINK-TARGET", self._rules("# 标题\n\n[x](" + "a" * 5000 + ")\n"))
367
-
368
- def test_safe_exists_swallows_oserror(self):
369
- self.assertFalse(check.safe_exists(self.root / ("a" * 5000)))
370
-
371
- def test_resolve_reference_absolute_missing(self):
372
- self.assertFalse(check.resolve_reference("/definitely/not/here.md", self.root))
373
-
374
- def test_resolve_reference_empty(self):
375
- self.assertTrue(check.resolve_reference("", self.root))
376
-
377
-
378
- class AnchorTest(unittest.TestCase):
379
- def _anchors(self, text: str) -> list[str]:
380
- return [f.rule for f in check.check_anchors(build(text))]
381
-
382
- def test_valid_cjk_anchor(self):
383
- self.assertNotIn("MD-ANCHOR", self._anchors("# 输出格式\n\n见 [格式](#输出格式)。\n"))
384
-
385
- def test_wrong_cjk_anchor(self):
386
- self.assertIn("MD-ANCHOR", self._anchors("# 输出格式\n\n见 [格式](#输出规范)。\n"))
387
-
388
- def test_english_slug(self):
389
- self.assertNotIn(
390
- "MD-ANCHOR", self._anchors("## Role and Objective\n\n见 [角色](#role-and-objective)。\n")
391
- )
392
-
393
- def test_explicit_html_id(self):
394
- text = '<a id="custom"></a>\n\n# 标题\n\n见 [跳转](#custom)。\n'
395
- self.assertNotIn("MD-ANCHOR", self._anchors(text))
396
-
397
- def test_duplicate_heading_suffix(self):
398
- text = "# 甲\n\n# 甲\n\n见 [第二个](#甲-1)。\n"
399
- self.assertNotIn("MD-ANCHOR", self._anchors(text))
400
-
401
- def test_external_and_top_skipped(self):
402
- text = "# 标题\n\n见 [外](https://e.invalid) 与 [顶](#top)。\n"
403
- self.assertNotIn("MD-ANCHOR", self._anchors(text))
404
-
405
- def test_anchor_in_fence_ignored(self):
406
- text = "# 标题\n\n```\n[跳转](#不存在)\n```\n"
407
- self.assertNotIn("MD-ANCHOR", self._anchors(text))
408
-
409
-
410
- class InlineTest(unittest.TestCase):
411
- def test_unbalanced_backtick(self):
412
- self.assertIn("MD-INLINE-CODE", rules_of("# 标题\n\n见 `pytest 的用法。\n"))
413
-
414
- def test_balanced_backticks(self):
415
- self.assertNotIn("MD-INLINE-CODE", rules_of("# 标题\n\n见 `pytest` 的用法。\n"))
416
-
417
- def test_escaped_backtick(self):
418
- self.assertNotIn("MD-INLINE-CODE", rules_of("# 标题\n\n见 \\` 字面量的用法 `x`。\n"))
419
-
420
- def test_backtick_in_fence(self):
421
- self.assertNotIn("MD-INLINE-CODE", rules_of("# 标题\n\n```\n单个 ` 反引号\n```\n"))
422
-
423
- def test_multiline_code_span(self):
424
- doc = build("# 标题\n\n第一行 `abc\n第二行 def` 结束\n")
425
- self.assertEqual(doc.unclosed_inline, [])
426
-
427
- def test_unclosed_code_span_across_lines_once(self):
428
- doc = build("# 标题\n\n第一行 `abc\n第二行 def 结束\n")
429
- self.assertEqual(doc.unclosed_inline, [3])
430
-
431
- def test_unclosed_bold(self):
432
- self.assertIn("MD-EMPHASIS", rules_of("# 标题\n\n这是 **未闭合。\n"))
433
-
434
- def test_closed_bold(self):
435
- self.assertNotIn("MD-EMPHASIS", rules_of("# 标题\n\n这是 **加粗** 文字。\n"))
436
-
437
- def test_identifier_inside_code(self):
438
- self.assertNotIn(
439
- "MD-EMPHASIS", rules_of("# 标题\n\n调用 `__init__` 和 `_private`。\n")
440
- )
441
-
442
- def test_escaped_markers(self):
443
- self.assertNotIn("MD-EMPHASIS", rules_of("# 标题\n\n转义 \\*\\* 两个星号。\n"))
444
-
445
- def test_triple_star(self):
446
- self.assertNotIn("MD-EMPHASIS", rules_of("# 标题\n\n***粗斜体*** 结束。\n"))
447
-
448
- def test_multiline_emphasis(self):
449
- doc = build("# 标题\n\n这里有 **加粗\n结束** 文本\n")
450
- self.assertEqual(doc.unclosed_emphasis, [])
451
-
452
- def test_unclosed_emphasis_across_lines_once(self):
453
- doc = build("# 标题\n\n这里有 **加粗\n结束 文本\n")
454
- self.assertEqual(doc.unclosed_emphasis, [(3, "**")])
455
-
456
-
457
- class InlineHtmlTest(unittest.TestCase):
458
- def test_unclosed_tag(self):
459
- self.assertIn("MD-INLINE-HTML", rules_of("# 标题\n\n<b>粗\n"))
460
-
461
- def test_closed_tag(self):
462
- self.assertNotIn("MD-INLINE-HTML", rules_of("# 标题\n\n<b>粗</b>\n"))
463
-
464
- def test_void_and_self_closing(self):
465
- text = "# 标题\n\n换行<br>继续 <img src='x' />\n"
466
- self.assertNotIn("MD-INLINE-HTML", rules_of(text))
467
-
468
- def test_tag_in_fence(self):
469
- self.assertNotIn("MD-INLINE-HTML", rules_of("# 标题\n\n```\n<b>粗\n```\n"))
470
-
471
- def test_autolink_url(self):
472
- self.assertNotIn(
473
- "MD-INLINE-HTML", rules_of("# 标题\n\n见 <https://example.invalid> 说明。\n")
474
- )
475
-
476
- def test_autolink_email(self):
477
- self.assertNotIn(
478
- "MD-INLINE-HTML", rules_of("# 标题\n\n联系 <user@example.invalid>。\n")
479
- )
480
-
481
- def test_placeholders_ignored(self):
482
- for line in ("把 <target-file> 换成路径。", "返回 <T> 类型。", "见 <file>。"):
483
- with self.subTest(line=line):
484
- self.assertNotIn("MD-INLINE-HTML", rules_of(f"# 标题\n\n{line}\n"))
485
-
486
- def test_uppercase_tag_paired(self):
487
- self.assertNotIn("MD-INLINE-HTML", rules_of("# 标题\n\n<DIV>x</div>\n"))
488
-
489
-
490
- class HtmlBlockTest(unittest.TestCase):
491
- def test_unclosed_block_comment(self):
492
- text = "# 标题\n\n<!-- 开始\n\n## 被吞掉的标题\n"
493
- findings = all_findings(text)
494
- self.assertIn("MD-HTML-COMMENT", [f.rule for f in findings])
495
- self.assertEqual(
496
- next(f.level for f in findings if f.rule == "MD-HTML-COMMENT"), check.ERROR
497
- )
498
-
499
- def test_closed_block_comment(self):
500
- self.assertNotIn(
501
- "MD-HTML-COMMENT", rules_of("# 标题\n\n<!-- 注释 -->\n\n正文。\n")
502
- )
503
-
504
- def test_unclosed_inline_comment(self):
505
- self.assertIn(
506
- "MD-HTML-COMMENT", rules_of("# 标题\n\n- 正文 <!-- 注释开始\n- 下一条\n")
507
- )
508
-
509
- def test_closed_inline_comment(self):
510
- self.assertNotIn(
511
- "MD-HTML-COMMENT", rules_of("# 标题\n\n- 正文 <!-- 注释 --> 结束\n")
512
- )
513
-
514
- def test_unclosed_script(self):
515
- self.assertIn("MD-HTML-BLOCK", rules_of("<script>\nvar x=1;\n\n# 标题\n"))
516
-
517
- def test_unclosed_style(self):
518
- self.assertIn("MD-HTML-BLOCK", rules_of("<style>\na{}\n\n# 标题\n"))
519
-
520
- def test_closed_pre(self):
521
- self.assertNotIn("MD-HTML-BLOCK", rules_of("<pre>\n文字\n</pre>\n\n# 标题\n"))
522
-
523
- def test_self_closing_script(self):
524
- self.assertNotIn("MD-HTML-BLOCK", rules_of('<script src="x" />\n\n# 标题\n'))
525
-
526
-
527
- class FormatTest(unittest.TestCase):
528
- def test_long_prose_line(self):
529
- self.assertIn("MD-LINE-LEN", rules_of("# 标题\n\n" + "字" * 201 + "\n"))
530
-
531
- def test_exactly_limit(self):
532
- self.assertNotIn("MD-LINE-LEN", rules_of("# 标题\n\n" + "字" * 200 + "\n"))
533
-
534
- def test_long_table_row_ignored(self):
535
- self.assertNotIn("MD-LINE-LEN", rules_of("# 标题\n\n| " + "字" * 220 + " |\n"))
536
-
537
- def test_long_fence_line_ignored(self):
538
- self.assertNotIn("MD-LINE-LEN", rules_of("# 标题\n\n```\n" + "字" * 220 + "\n```\n"))
539
-
540
- def test_long_url_ignored(self):
541
- text = "# 标题\n\n参见 https://example.invalid/" + "a" * 220 + "\n"
542
- self.assertNotIn("MD-LINE-LEN", rules_of(text))
543
-
544
- def test_long_front_matter_ignored(self):
545
- text = "---\ndescription: " + "字" * 220 + "\n---\n\n# 标题\n"
546
- self.assertNotIn("MD-LINE-LEN", rules_of(text))
547
-
548
- def test_long_html_block_ignored(self):
549
- text = "# 标题\n\n<div>\n" + "字" * 220 + "\n</div>\n"
550
- self.assertNotIn("MD-LINE-LEN", rules_of(text))
551
-
552
- def test_two_blank_lines(self):
553
- self.assertIn("MD-BLANK-RUN", rules_of("# 标题\n\n\n正文\n"))
554
-
555
- def test_one_blank_line(self):
556
- self.assertNotIn("MD-BLANK-RUN", rules_of("# 标题\n\n正文\n"))
557
-
558
- def test_blank_run_reported_once(self):
559
- found = [f for f in all_findings("# 标题\n\n\n\n正文\n") if f.rule == "MD-BLANK-RUN"]
560
- self.assertEqual(len(found), 1)
561
-
562
- def test_blank_lines_in_fence_ignored(self):
563
- self.assertNotIn("MD-BLANK-RUN", rules_of("# 标题\n\n```\n\n\n```\n"))
564
-
565
- def test_blank_lines_in_html_ignored(self):
566
- self.assertNotIn("MD-BLANK-RUN", rules_of("# 标题\n\n<pre>\n\n\n</pre>\n"))
567
-
568
- def test_trailing_whitespace(self):
569
- self.assertIn("MD-TRAILING-WS", rules_of("# 标题\n\n正文。 \n"))
570
-
571
- def test_tab(self):
572
- self.assertIn("MD-TAB", rules_of("# 标题\n\n正文\t继续。\n"))
573
-
574
- def test_tab_in_fence_ignored(self):
575
- self.assertNotIn("MD-TAB", rules_of("# 标题\n\n```\n\tx\n```\n"))
576
-
577
- def test_missing_final_newline(self):
578
- self.assertIn("MD-FINAL-NEWLINE", rules_of("# 标题\n\n正文。"))
579
-
580
- def test_present_final_newline(self):
581
- self.assertNotIn("MD-FINAL-NEWLINE", rules_of("# 标题\n\n正文。\n"))
582
-
583
-
584
- class FenceTest(unittest.TestCase):
585
- def test_unclosed_fence_error(self):
586
- findings = all_findings("# 标题\n\n```python\nprint(1)\n")
587
- self.assertIn("MD-FENCE", [f.rule for f in findings])
588
- self.assertEqual(next(f.level for f in findings if f.rule == "MD-FENCE"), check.ERROR)
589
-
590
- def test_closed_fence(self):
591
- self.assertNotIn("MD-FENCE", rules_of("# 标题\n\n```python\nprint(1)\n```\n"))
592
-
593
-
594
- class BomEolTest(unittest.TestCase):
595
- def test_bom_reported(self):
596
- self.assertIn("MD-BOM", rules_of("\ufeff# 标题\n\n正文。\n"))
597
-
598
- def test_no_bom_not_reported(self):
599
- self.assertNotIn("MD-BOM", rules_of("# 标题\n\n正文。\n"))
600
-
601
- def test_mixed_eol_reported(self):
602
- found = [f for f in all_findings("# 标题\r\n\r\n正文。\n") if f.rule == "MD-EOL"]
603
- self.assertEqual(len(found), 1)
604
- self.assertEqual(found[0].line, 3)
605
-
606
- def test_uniform_lf_not_reported(self):
607
- self.assertNotIn("MD-EOL", rules_of("# 标题\n\n正文。\n"))
608
-
609
- def test_uniform_crlf_not_reported(self):
610
- self.assertNotIn("MD-EOL", rules_of("# 标题\r\n\r\n正文。\r\n"))
611
-
612
-
613
- class HeadBlankAfterTest(unittest.TestCase):
614
- def test_missing_blank_after_reported(self):
615
- self.assertIn("MD-HEAD-BLANK-AFTER", rules_of("# 标题\n正文。\n"))
616
-
617
- def test_blank_after_not_reported(self):
618
- self.assertNotIn("MD-HEAD-BLANK-AFTER", rules_of("# 标题\n\n正文。\n"))
619
-
620
- def test_heading_at_end_not_reported(self):
621
- self.assertNotIn("MD-HEAD-BLANK-AFTER", rules_of("# 标题\n"))
622
-
623
- def test_list_after_heading_not_reported(self):
624
- self.assertNotIn("MD-HEAD-BLANK-AFTER", rules_of("# 标题\n- a\n"))
625
-
626
- def test_quote_blank_not_reported(self):
627
- self.assertNotIn("MD-HEAD-BLANK-AFTER", rules_of("> # 标题\n>\n> 正文。\n"))
628
-
629
-
630
- class FenceLangTest(unittest.TestCase):
631
- def test_missing_language_reported(self):
632
- self.assertIn("MD-FENCE-LANG", rules_of("# 标题\n\n```\nx\n```\n"))
633
-
634
- def test_language_present_not_reported(self):
635
- self.assertNotIn("MD-FENCE-LANG", rules_of("# 标题\n\n```python\nx\n```\n"))
636
-
637
-
638
- class EmptyLinkDefinitionTest(unittest.TestCase):
639
- def test_empty_definition_reported(self):
640
- self.assertIn("MD-LINK-DEF-EMPTY", rules_of("# 标题\n\n[a]:\n"))
641
-
642
- def test_empty_destination_reported(self):
643
- self.assertIn("MD-LINK-DEF-EMPTY", rules_of("# 标题\n\n[a]: <>\n"))
644
-
645
- def test_multiline_definition_ok(self):
646
- text = "# 标题\n\n[a]:\n https://example.invalid\n"
647
- self.assertNotIn("MD-LINK-DEF-EMPTY", rules_of(text))
648
-
649
- def test_normal_definition_ok(self):
650
- text = "# 标题\n\n[a]: https://example.invalid\n"
651
- self.assertNotIn("MD-LINK-DEF-EMPTY", rules_of(text))
652
-
653
- def test_definition_in_fence_ignored(self):
654
- text = "# 标题\n\n```\n[a]:\n```\n"
655
- self.assertNotIn("MD-LINK-DEF-EMPTY", rules_of(text))
656
-
657
-
658
- class BlankBeforeBlockTest(unittest.TestCase):
659
- def test_list_after_heading_not_reported(self):
660
- self.assertNotIn("MD-BLANK-BEFORE-BLOCK", rules_of("# 标题\n- a\n"))
661
-
662
- def test_list_after_paragraph_without_blank(self):
663
- self.assertIn("MD-BLANK-BEFORE-BLOCK", rules_of("正文\n- a\n"))
664
-
665
- def test_list_after_heading_with_blank(self):
666
- self.assertNotIn("MD-BLANK-BEFORE-BLOCK", rules_of("# 标题\n\n- a\n"))
667
-
668
- def test_heading_after_paragraph_without_blank(self):
669
- self.assertIn("MD-BLANK-BEFORE-BLOCK", rules_of("正文\n# 标题\n"))
670
-
671
- def test_fence_after_paragraph_without_blank(self):
672
- self.assertIn("MD-BLANK-BEFORE-BLOCK", rules_of("正文\n```python\nx\n```\n"))
673
-
674
- def test_nested_list_not_reported(self):
675
- self.assertNotIn("MD-BLANK-BEFORE-BLOCK", rules_of("- a\n - b\n"))
676
-
677
- def test_heading_in_quote_not_reported(self):
678
- self.assertNotIn("MD-BLANK-BEFORE-BLOCK", rules_of("> # 标题\n"))
679
-
680
- def test_table_at_start_not_reported(self):
681
- self.assertNotIn("MD-BLANK-BEFORE-BLOCK", rules_of("|a|b|\n|-|-|\n|1|2|\n"))
682
-
683
-
684
- class HeadSpacingTest(unittest.TestCase):
685
- def test_extra_space_after_hash_reported(self):
686
- self.assertIn("MD-HEAD-MULTI-SPACE", rules_of("# 标题\n"))
687
-
688
- def test_single_space_after_hash_not_reported(self):
689
- self.assertNotIn("MD-HEAD-MULTI-SPACE", rules_of("# 标题\n"))
690
-
691
- def test_indented_heading_reported(self):
692
- self.assertIn("MD-HEAD-INDENT", rules_of(" # 标题\n"))
693
-
694
- def test_heading_at_column_zero_not_reported(self):
695
- self.assertNotIn("MD-HEAD-INDENT", rules_of("# 标题\n"))
696
-
697
- def test_heading_in_quote_not_indented(self):
698
- self.assertNotIn("MD-HEAD-INDENT", rules_of("> # 标题\n"))
699
-
700
-
701
- class QuoteSpaceTest(unittest.TestCase):
702
- def test_missing_space_reported(self):
703
- self.assertIn("MD-QUOTE-SPACE", rules_of(">引用\n"))
704
-
705
- def test_space_not_reported(self):
706
- self.assertNotIn("MD-QUOTE-SPACE", rules_of("> 引用\n"))
707
-
708
- def test_nested_quote_not_reported(self):
709
- self.assertNotIn("MD-QUOTE-SPACE", rules_of(">> 引用\n"))
710
-
711
- def test_empty_quote_not_reported(self):
712
- self.assertNotIn("MD-QUOTE-SPACE", rules_of(">\n"))
713
-
714
-
715
- class BlankAfterBlockTest(unittest.TestCase):
716
- def test_fence_followed_by_text_reported(self):
717
- self.assertIn("MD-BLANK-AFTER-BLOCK", rules_of("```python\nx\n```\n正文\n"))
718
-
719
- def test_fence_followed_by_blank_not_reported(self):
720
- self.assertNotIn(
721
- "MD-BLANK-AFTER-BLOCK", rules_of("```python\nx\n```\n\n正文\n")
722
- )
723
-
724
- def test_fence_followed_by_heading_not_reported(self):
725
- self.assertNotIn(
726
- "MD-BLANK-AFTER-BLOCK", rules_of("```python\nx\n```\n# 标题\n")
727
- )
728
-
729
- def test_list_with_blank_after_not_reported(self):
730
- self.assertNotIn("MD-BLANK-AFTER-BLOCK", rules_of("- a\n\n正文\n"))
731
-
732
-
733
- class FinalBlankTest(unittest.TestCase):
734
- def test_extra_blank_reported(self):
735
- self.assertIn("MD-FINAL-BLANK", rules_of("# 标题\n\n正文。\n\n"))
736
-
737
- def test_single_newline_not_reported(self):
738
- self.assertNotIn("MD-FINAL-BLANK", rules_of("# 标题\n\n正文。\n"))
739
-
740
-
741
- class LinkTextSpaceTest(unittest.TestCase):
742
- def test_outer_space_reported(self):
743
- self.assertIn(
744
- "MD-LINK-TEXT-SPACE",
745
- rules_of("[ 文字 ](https://example.invalid)\n"),
746
- )
747
-
748
- def test_normal_text_not_reported(self):
749
- self.assertNotIn(
750
- "MD-LINK-TEXT-SPACE",
751
- rules_of("[文字](https://example.invalid)\n"),
752
- )
753
-
754
-
755
- class FenceStyleTest(unittest.TestCase):
756
- def test_mixed_style_reported(self):
757
- text = "```python\nx\n```\n\n~~~python\ny\n~~~\n"
758
- self.assertIn("MD-FENCE-STYLE", rules_of(text))
759
-
760
- def test_single_style_not_reported(self):
761
- text = "```python\nx\n```\n\n```python\ny\n```\n"
762
- self.assertNotIn("MD-FENCE-STYLE", rules_of(text))
763
-
764
-
765
- class HrStyleTest(unittest.TestCase):
766
- def test_mixed_style_reported(self):
767
- self.assertIn("MD-HR-STYLE", rules_of("---\n\n正文\n\n***\n"))
768
-
769
- def test_single_style_not_reported(self):
770
- self.assertNotIn("MD-HR-STYLE", rules_of("---\n\n正文\n\n---\n"))
771
-
772
-
773
- class EmphasisStyleTest(unittest.TestCase):
774
- def test_mixed_em_reported(self):
775
- self.assertIn("MD-EMPHASIS-STYLE", rules_of("*a* 与 _b_\n"))
776
-
777
- def test_single_em_not_reported(self):
778
- self.assertNotIn("MD-EMPHASIS-STYLE", rules_of("*a* 与 *b*\n"))
779
-
780
- def test_mixed_strong_reported(self):
781
- self.assertIn("MD-EMPHASIS-STYLE", rules_of("**a** 与 __b__\n"))
782
-
783
- def test_single_strong_not_reported(self):
784
- self.assertNotIn("MD-EMPHASIS-STYLE", rules_of("**a** 与 **b**\n"))
785
-
786
-
787
- class FormatOnlyTest(unittest.TestCase):
788
- """内容问题不由脚本判断:这是纯格式校验脚本的边界。"""
789
-
790
- def test_content_problems_produce_no_findings(self):
791
- text = "# 随便\n\n- 只写一句。\n"
792
- self.assertEqual(all_findings(text), [])
793
-
794
- def test_no_content_check_api(self):
795
- for name in (
796
- "check_rules",
797
- "rule_lines",
798
- "check_duplicates",
799
- "check_references",
800
- "check_baseline",
801
- "check_structure",
802
- ):
803
- with self.subTest(name=name):
804
- self.assertFalse(hasattr(check, name))
805
-
806
-
807
- class HelperTest(unittest.TestCase):
808
- def test_strip_inline_code(self):
809
- line = "正文:`代码` 结束。"
810
- stripped = check.strip_inline_code(line)
811
- self.assertEqual(len(stripped), len(line))
812
- self.assertNotIn("代码", stripped)
813
-
814
- def test_collapse_label(self):
815
- self.assertEqual(check.collapse_label(" A B "), "a b")
816
-
817
- def test_github_slug(self):
818
- self.assertEqual(check.github_slug("Role and Objective"), "role-and-objective")
819
- self.assertEqual(check.github_slug("规则 S1–S13"), "规则-s1s13")
820
-
821
- def test_count_row_cells(self):
822
- self.assertEqual(check.count_row_cells("| a | b |"), 2)
823
- self.assertEqual(check.count_row_cells("| a \\| b | c |"), 2)
824
- # GFM 要求代码跨度内的管道也转义,未转义时按分隔符计数
825
- self.assertEqual(check.count_row_cells("| `x | y` | 2 |"), 3)
826
-
827
- def test_count_inline_backticks(self):
828
- self.assertEqual(check.count_inline_backticks("`a` 和 \\`"), 2)
829
-
830
- def test_count_marker(self):
831
- self.assertEqual(check.count_marker("**x**", "**"), 2)
832
- self.assertEqual(check.count_marker("***x***", "**"), 2)
833
-
834
-
835
- class CliTest(unittest.TestCase):
836
- def test_parse_args_strict(self):
837
- target, strict = check.parse_args(["--strict", "a.md"])
838
- self.assertEqual(target, "a.md")
839
- self.assertTrue(strict)
840
-
841
- def test_parse_args_rejects_unknown(self):
842
- with self.assertRaises(SystemExit) as ctx:
843
- check.parse_args(["--nope", "a.md"])
844
- self.assertEqual(ctx.exception.code, 2)
845
-
846
- def test_parse_args_requires_one_target(self):
847
- with self.assertRaises(SystemExit) as ctx:
848
- check.parse_args([])
849
- self.assertEqual(ctx.exception.code, 2)
850
-
851
- def test_help_exits_zero(self):
852
- with self.assertRaises(SystemExit) as ctx:
853
- check.parse_args(["--help"])
854
- self.assertEqual(ctx.exception.code, 0)
855
-
856
- def test_missing_file_returns_2(self):
857
- with tempfile.TemporaryDirectory() as tmp:
858
- code, _, err = run_main(str(Path(tmp) / "nope.md"))
859
- self.assertEqual(code, 2)
860
- self.assertIn("文件不存在", err)
861
-
862
- def test_clean_file_returns_0(self):
863
- with tempfile.TemporaryDirectory() as tmp:
864
- path = Path(tmp) / "doc.md"
865
- path.write_text("# 标题\n\n正文。\n", encoding="utf-8")
866
- code, out, _ = run_main(str(path))
867
- self.assertEqual(code, 0)
868
- self.assertIn("结果: 通过", out)
869
-
870
- def test_warning_only_returns_0(self):
871
- with tempfile.TemporaryDirectory() as tmp:
872
- path = Path(tmp) / "doc.md"
873
- path.write_text("## 标题\n\n正文。\n", encoding="utf-8")
874
- code, out, _ = run_main(str(path))
875
- self.assertEqual(code, 0)
876
- self.assertIn("MD-HEAD-START", out)
877
-
878
- def test_strict_warning_returns_1(self):
879
- with tempfile.TemporaryDirectory() as tmp:
880
- path = Path(tmp) / "doc.md"
881
- path.write_text("## 标题\n\n正文。\n", encoding="utf-8")
882
- code, _, _ = run_main("--strict", str(path))
883
- self.assertEqual(code, 1)
884
-
885
- def test_error_returns_1(self):
886
- with tempfile.TemporaryDirectory() as tmp:
887
- path = Path(tmp) / "doc.md"
888
- path.write_text("# 标题\n\n```\nx\n", encoding="utf-8")
889
- code, out, _ = run_main(str(path))
890
- self.assertEqual(code, 1)
891
- self.assertIn("MD-FENCE", out)
892
-
893
- def test_invalid_utf8_returns_2(self):
894
- with tempfile.TemporaryDirectory() as tmp:
895
- path = Path(tmp) / "doc.md"
896
- path.write_bytes(b"\xff\xfe")
897
- code, _, err = run_main(str(path))
898
- self.assertEqual(code, 2)
899
- self.assertIn("读取失败", err)
900
-
901
-
902
- if __name__ == "__main__":
903
- unittest.main()