dsh-markdown-lint 0.1.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.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # dsh-markdown-lint
2
+
3
+ Markdown 检查插件(v0.1.0):三类规则检查 + 可修复项自动修复。代码块内容全程跳过(围栏内 `#`、链接都不误报)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install dsh-markdown-lint
9
+ ```
10
+
11
+ ## 工具
12
+
13
+ ### `lint_markdown`
14
+
15
+ | 参数 | 类型 | 说明 |
16
+ |------|------|------|
17
+ | `text` / `file` | string | 源内容二选一(file 须在工作目录内) |
18
+
19
+ **三类规则**:
20
+
21
+ | 规则 | 内容 | 可自动修复 |
22
+ |------|------|-----------|
23
+ | `heading-hierarchy` | 标题跳级(H1→H3);多个 H1(文件级) | 跳级可,多 H1 不可 |
24
+ | `list-indent` | 列表缩进用 Tab;嵌套步长不一致(≥2 次出现的宽度中取最小为标准) | 可 |
25
+ | `link-exists` | 相对链接(`./x.md`、`../a.md`、`sub/x.md`)目标文件不存在;file 模式以**文件所在目录**解析,text 模式以 cwd 解析 | 不可 |
26
+
27
+ http(s)/mailto/锚点链接不做联网检查。输出每条问题带行号、规则名与可修复标注。
28
+
29
+ ### `fix_markdown`
30
+
31
+ | 参数 | 类型 | 说明 |
32
+ |------|------|------|
33
+ | `text` / `file` | string | 源内容二选一 |
34
+ | `write` | boolean | file 模式下 true 直接覆盖写回文件(默认 false 仅预览) |
35
+
36
+ 自动修复内容:跳级标题降级补齐、Tab 缩进转 4 空格、嵌套步长统一。修复后残余的不可修复问题(多 H1、死链)会提示用 `lint_markdown` 查看。
37
+
38
+ ## 安全与限制
39
+
40
+ - 文件路径必须在工作目录内;输入上限 1MB
41
+ - `write=true` 直接覆盖文件,建议先预览(默认行为)
42
+ - 相对链接检查只验证**存在性**,不验证指向内容正确性
43
+ - 零 npm 依赖
44
+
45
+ ## 测试
46
+
47
+ ```bash
48
+ npm test
49
+ ```
50
+
51
+ 覆盖三类规则的命中与不误报、代码块跳过、file/text 两种解析基准、修复写回、路径越界拒绝等 28 项断言。
52
+
53
+ ## Roadmap(v0.2 候选)
54
+
55
+ - http 链接可选联网检查(超时保护)
56
+ - 多 H1 自动降级为 H2(需用户确认首个标题)
57
+ - 表格格式检查
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: markdown-lint
3
+ name: "dsh-markdown-lint"
package/index.js ADDED
@@ -0,0 +1,345 @@
1
+ import { defineTool } from "@deepseek-ai/dsh-tools"
2
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs"
3
+ import { resolve, isAbsolute, sep, dirname } from "node:path"
4
+
5
+ export const name = "markdown-lint"
6
+ export const inject = ["tools"]
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // 配置与基础设施
10
+ // ---------------------------------------------------------------------------
11
+ const MAX_FILE_CHARS = 1_000_000 // 1MB 输入上限
12
+ const OUTPUT_LIMIT = 8000
13
+
14
+ function safePath(p) {
15
+ if (!p || typeof p !== "string") throw new Error("path 参数必须是字符串")
16
+ const abs = isAbsolute(p) ? resolve(p) : resolve(process.cwd(), p)
17
+ const root = resolve(process.cwd())
18
+ if (abs !== root && !abs.startsWith(root + sep)) {
19
+ throw new Error(`拒绝访问:路径 "${p}" 在工作目录之外。`)
20
+ }
21
+ return abs
22
+ }
23
+
24
+ function readTarget(p) {
25
+ const abs = safePath(p)
26
+ if (!existsSync(abs)) throw new Error(`文件不存在: ${abs}`)
27
+ const st = statSync(abs)
28
+ if (!st.isFile()) throw new Error(`目标不是普通文件: ${abs}`)
29
+ const content = readFileSync(abs, "utf8")
30
+ if (content.length > MAX_FILE_CHARS) {
31
+ throw new Error(`文件过大(${(content.length / 1024 / 1024).toFixed(1)}MB,上限 1MB)。`)
32
+ }
33
+ return { content, abs }
34
+ }
35
+
36
+ function getSource(args) {
37
+ if (args.text != null && args.text !== "") {
38
+ const t = String(args.text)
39
+ if (t.length > MAX_FILE_CHARS) throw new Error(`文本过长(${t.length} 字符,上限 ${MAX_FILE_CHARS})。`)
40
+ return { content: t, abs: null }
41
+ }
42
+ if (args.file) return readTarget(args.file)
43
+ throw new Error("请提供 text 或 file 之一。")
44
+ }
45
+
46
+ function truncateOut(text) {
47
+ if (text.length <= OUTPUT_LIMIT) return text
48
+ return text.slice(0, OUTPUT_LIMIT) + `\n\n[输出已截断,完整长度 ${text.length} 字符]`
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // lint 规则(纯函数,便于测试与 fix 复用)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** 跳过围栏代码块与行内代码影响,返回有效行标记数组 */
56
+ export function maskCodeBlocks(lines) {
57
+ const masked = lines.map(() => false)
58
+ let inFence = false
59
+ let fenceChar = ""
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const m = lines[i].match(/^\s*(`{3,}|~{3,})/)
62
+ if (m) {
63
+ if (!inFence) {
64
+ inFence = true
65
+ fenceChar = m[1][0]
66
+ masked[i] = true // 围栏行本身也跳过
67
+ } else if (m[1][0] === fenceChar) {
68
+ inFence = false
69
+ masked[i] = true
70
+ }
71
+ continue
72
+ }
73
+ masked[i] = inFence
74
+ }
75
+ return masked
76
+ }
77
+
78
+ /**
79
+ * 规则 1:heading-hierarchy。
80
+ * - 跳级(如 H1 后直接 H3)
81
+ * - 多个 H1
82
+ * 返回 issues: [{ line, rule, message }]
83
+ */
84
+ export function checkHeadings(lines, masked) {
85
+ const issues = []
86
+ let prevLevel = 0
87
+ let h1Count = 0
88
+ for (let i = 0; i < lines.length; i++) {
89
+ if (masked[i]) continue
90
+ const m = lines[i].match(/^(#{1,6})\s+\S/)
91
+ if (!m) continue
92
+ const level = m[1].length
93
+ if (level === 1) h1Count++
94
+ if (prevLevel > 0 && level > prevLevel + 1) {
95
+ issues.push({
96
+ line: i + 1,
97
+ rule: "heading-hierarchy",
98
+ message: `标题跳级: H${prevLevel} 之后直接出现 H${level}(应为 H${prevLevel + 1})`,
99
+ fixable: true,
100
+ })
101
+ }
102
+ prevLevel = level
103
+ }
104
+ if (h1Count > 1) {
105
+ issues.push({
106
+ line: 0,
107
+ rule: "heading-hierarchy",
108
+ message: `存在 ${h1Count} 个 H1 标题(约定全文唯一)`,
109
+ fixable: false,
110
+ })
111
+ }
112
+ return issues
113
+ }
114
+
115
+ /**
116
+ * 规则 2:list-indent。
117
+ * - 列表缩进使用 Tab
118
+ * - 嵌套缩进步长不一致(同一文件混用 2 空格与 4 空格等)
119
+ * 返回 issues 与规范化所需的步长信息。
120
+ */
121
+ export function checkListIndent(lines, masked) {
122
+ const issues = []
123
+ const steps = new Map() // 缩进宽度 -> 出现次数(仅统计空格缩进的嵌套行)
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (masked[i]) continue
126
+ const m = lines[i].match(/^(\t+|\s+)([-*+]|\d+[.)])\s/)
127
+ if (!m) continue
128
+ const indent = m[1]
129
+ if (indent.includes("\t")) {
130
+ issues.push({
131
+ line: i + 1,
132
+ rule: "list-indent",
133
+ message: "列表缩进使用了 Tab(应使用空格)",
134
+ fixable: true,
135
+ })
136
+ continue
137
+ }
138
+ const width = indent.length
139
+ if (width > 0) steps.set(width, (steps.get(width) || 0) + 1)
140
+ }
141
+ // 只出现一次的宽度可能是偶然,出现 ≥2 次的宽度中取最小者为“标准步长”
142
+ const widths = [...steps.entries()].filter(([, c]) => c >= 2).map(([w]) => w).sort((a, b) => a - b)
143
+ if (widths.length > 1) {
144
+ // 多种主流宽度:报不一致
145
+ for (let i = 0; i < lines.length; i++) {
146
+ if (masked[i]) continue
147
+ const m = lines[i].match(/^( +)([-*+]|\d+[.)])\s/)
148
+ if (!m) continue
149
+ if (m[1].length !== widths[0]) {
150
+ issues.push({
151
+ line: i + 1,
152
+ rule: "list-indent",
153
+ message: `嵌套缩进 ${m[1].length} 空格与主流 ${widths[0]} 空格不一致`,
154
+ fixable: true,
155
+ })
156
+ }
157
+ }
158
+ }
159
+ return issues
160
+ }
161
+
162
+ /**
163
+ * 规则 3:link-exists。
164
+ * 相对路径链接([text](./x.md)、(../a/b.md)、(sub/x.md))指向的文件必须存在。
165
+ * http(s)/mailto/anchor 链接跳过(本版本不联网检查)。
166
+ * baseDir: 相对路径的解析基准(file 模式为文件所在目录,text 模式为 cwd)
167
+ */
168
+ export function checkLinks(lines, masked, baseDir) {
169
+ const issues = []
170
+ for (let i = 0; i < lines.length; i++) {
171
+ if (masked[i]) continue
172
+ const line = lines[i]
173
+ // 行内链接与引用链接统一扫描 [text](target)
174
+ const re = /\[[^\]]*\]\(([^)]+)\)/g
175
+ let m
176
+ while ((m = re.exec(line)) !== null) {
177
+ const target = m[1].trim()
178
+ if (target === "" || /^https?:\/\//i.test(target) || /^mailto:/i.test(target) || /^#/.test(target)) {
179
+ continue
180
+ }
181
+ // 去掉锚点与标题后缀
182
+ const filePart = target.split("#")[0]
183
+ if (filePart === "") continue // 纯锚点
184
+ const resolved = resolve(baseDir, filePart)
185
+ if (!existsSync(resolved)) {
186
+ issues.push({
187
+ line: i + 1,
188
+ rule: "link-exists",
189
+ message: `相对链接目标不存在: ${target}(解析为 ${resolved})`,
190
+ fixable: false,
191
+ })
192
+ }
193
+ }
194
+ }
195
+ return issues
196
+ }
197
+
198
+ /** 渲染 issues 为文本 */
199
+ function renderIssues(issues, sourceName) {
200
+ if (issues.length === 0) {
201
+ return `✅ ${sourceName} 未发现问题。`
202
+ }
203
+ const lines = issues.map(
204
+ (it) => `${it.line > 0 ? `第 ${it.line} 行` : "文件级"} [${it.rule}]${it.fixable ? "(可自动修复)" : ""}: ${it.message}`
205
+ )
206
+ const autoFix = issues.filter((it) => it.fixable).length
207
+ return (
208
+ `❌ ${sourceName} 发现 ${issues.length} 个问题` +
209
+ (autoFix > 0 ? `(其中 ${autoFix} 个可用 fix_markdown 自动修复)` : "") +
210
+ `:\n` +
211
+ lines.join("\n")
212
+ )
213
+ }
214
+
215
+ /** 应用自动修复:标题跳级降级、Tab 缩进转空格、嵌套宽度统一 */
216
+ export function autoFix(lines, masked) {
217
+ const fixes = []
218
+ // 1. 标题跳级
219
+ let prevLevel = 0
220
+ for (let i = 0; i < lines.length; i++) {
221
+ if (masked[i]) continue
222
+ const m = lines[i].match(/^(#{1,6})(\s+\S.*)$/)
223
+ if (!m) continue
224
+ const level = m[1].length
225
+ if (prevLevel > 0 && level > prevLevel + 1) {
226
+ lines[i] = "#".repeat(prevLevel + 1) + m[2]
227
+ fixes.push({ line: i + 1, message: `标题从 H${level} 降为 H${prevLevel + 1}` })
228
+ prevLevel = prevLevel + 1
229
+ continue
230
+ }
231
+ prevLevel = level
232
+ }
233
+ // 2. Tab 缩进 → 4 空格
234
+ for (let i = 0; i < lines.length; i++) {
235
+ if (masked[i]) continue
236
+ const m = lines[i].match(/^(\t+)([-*+]|\d+[.)])\s/)
237
+ if (m) {
238
+ lines[i] = lines[i].replace(/^\t+/, " ".repeat(m[1].length))
239
+ fixes.push({ line: i + 1, message: "列表缩进 Tab 转为 4 空格" })
240
+ }
241
+ }
242
+ // 3. 缩进宽度统一到主流步长
243
+ const steps = new Map()
244
+ for (let i = 0; i < lines.length; i++) {
245
+ if (masked[i]) continue
246
+ const m = lines[i].match(/^( +)([-*+]|\d+[.)])\s/)
247
+ if (m && m[1].length > 0) steps.set(m[1].length, (steps.get(m[1].length) || 0) + 1)
248
+ }
249
+ const widths = [...steps.entries()].filter(([, c]) => c >= 2).map(([w]) => w).sort((a, b) => a - b)
250
+ if (widths.length > 1) {
251
+ const target = widths[0]
252
+ for (let i = 0; i < lines.length; i++) {
253
+ if (masked[i]) continue
254
+ const m = lines[i].match(/^( +)((?:[-*+]|\d+[.)])\s.*)$/)
255
+ if (m && m[1].length !== target) {
256
+ lines[i] = " ".repeat(target) + m[2]
257
+ fixes.push({ line: i + 1, message: `嵌套缩进统一为 ${target} 空格` })
258
+ }
259
+ }
260
+ }
261
+ return fixes
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // 工具注册
266
+ // ---------------------------------------------------------------------------
267
+ export function apply(ctx) {
268
+ ctx.tools.register(
269
+ defineTool({
270
+ name: "lint_markdown",
271
+ description:
272
+ "Markdown 检查(三类规则):标题跳级与多 H1、列表缩进(Tab/嵌套步长不一致)、相对链接目标存在性(http 链接不联网检查)。代码块内容跳过。返回带行号的问题列表及可修复标注。",
273
+ parameters: {
274
+ text: { type: "string", description: "Markdown 文本(与 file 二选一)" },
275
+ file: { type: "string", description: "工作目录内 Markdown 文件路径(相对链接以文件所在目录解析)" },
276
+ },
277
+ output: {
278
+ schema: { type: "string" },
279
+ render: (_args, value) => [{ type: "text", text: value }],
280
+ },
281
+ async execute(args) {
282
+ const { content, abs } = getSource(args)
283
+ const lines = content.split("\n")
284
+ const masked = maskCodeBlocks(lines)
285
+ const baseDir = abs ? dirname(abs) : resolve(process.cwd())
286
+ const issues = [
287
+ ...checkHeadings(lines, masked),
288
+ ...checkListIndent(lines, masked),
289
+ ...checkLinks(lines, masked, baseDir),
290
+ ]
291
+ return truncateOut(renderIssues(issues, abs ?? "(text)"))
292
+ },
293
+ })
294
+ )
295
+
296
+ ctx.tools.register(
297
+ defineTool({
298
+ name: "fix_markdown",
299
+ description:
300
+ "自动修复可修复问题:标题跳级(降级补齐)、列表 Tab 缩进转空格、嵌套步长统一。link-exists 与多 H1 不可自动修复。file 模式下加 write=true 直接覆盖写回文件;text 模式返回修复后的全文。",
301
+ parameters: {
302
+ text: { type: "string", description: "Markdown 文本(与 file 二选一)" },
303
+ file: { type: "string", description: "工作目录内 Markdown 文件路径" },
304
+ write: {
305
+ type: "boolean",
306
+ description: "可选,file 模式下是否写回文件(默认 false 仅预览)",
307
+ },
308
+ },
309
+ output: {
310
+ schema: { type: "string" },
311
+ render: (_args, value) => [{ type: "text", text: value }],
312
+ },
313
+ async execute(args) {
314
+ const { content, abs } = getSource(args)
315
+ const lines = content.split("\n")
316
+ const masked = maskCodeBlocks(lines)
317
+ const fixes = autoFix(lines, masked)
318
+ const fixed = lines.join("\n")
319
+
320
+ const head =
321
+ fixes.length === 0
322
+ ? "没有可自动修复的问题。"
323
+ : `应用了 ${fixes.length} 处修复:\n` + fixes.map((f) => ` 第 ${f.line} 行: ${f.message}`).join("\n")
324
+
325
+ // 残余问题提示
326
+ const newMasked = maskCodeBlocks(fixed.split("\n"))
327
+ const remaining = [
328
+ ...checkHeadings(fixed.split("\n"), newMasked),
329
+ ...checkListIndent(fixed.split("\n"), newMasked),
330
+ ].filter((it) => !it.fixable)
331
+
332
+ let tail = ""
333
+ if (abs && args.write === true) {
334
+ writeFileSync(abs, fixed, "utf8")
335
+ tail = `\n\n已写回文件: ${abs}`
336
+ }
337
+
338
+ if (!abs || args.write !== true) {
339
+ return truncateOut(head + (fixes.length > 0 ? "\n\n--- 修复后全文 ---\n" + fixed : "") + tail)
340
+ }
341
+ return truncateOut(head + tail + (remaining.length > 0 ? `\n\n仍需人工处理 ${remaining.length} 个问题(多 H1/链接不存在),可用 lint_markdown 查看。` : ""))
342
+ },
343
+ })
344
+ )
345
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "dsh-markdown-lint",
3
+ "version": "0.1.0",
4
+ "description": "Markdown linter for DeepSeek Harness: heading hierarchy, list indentation, and relative-link existence checks, with auto-fix for repairable issues.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "cordis.patch.yml",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "dsh",
14
+ "plugin",
15
+ "markdown",
16
+ "lint",
17
+ "documentation"
18
+ ],
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2"
22
+ },
23
+ "scripts": {
24
+ "test": "node test-plugin.js",
25
+ "prepublishOnly": "npm test"
26
+ },
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ }
31
+ },
32
+ "private": false,
33
+ "publishConfig": {
34
+ "registry": "https://registry.npmjs.org/",
35
+ "access": "public",
36
+ "tag": "latest"
37
+ }
38
+ }