@chengzhao-dev/dsh-cpp-workflow 0.6.7 → 0.6.9

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.
@@ -2,11 +2,11 @@
2
2
  // 只读注释审计:按 references/comments.md 的硬规则检查 C/C++ 源文件。
3
3
  //
4
4
  // 检查项:Doxygen 文件头、注释行宽度、标签、注释与声明的贴合、
5
- // 类型注释存在性、成员功能清单、设计论证(方案对比)、否定式边界、领域尺寸、谓词首句、@param 完整性、枚举成员注释一致性、数据成员主注释位置。
5
+ // 类型注释存在性、成员功能清单、设计论证(方案对比)、否定式边界、领域尺寸、谓词首句、@param 完整性、枚举成员注释一致性、枚举行尾句式、数据成员主注释位置。
6
6
  // 不重写任何文件。
7
7
  //
8
8
  // 用法:
9
- // node checking-comments.mjs [--root DIR] [--width N] [--no-header] [--json] [--quiet]
9
+ // node checking-comments.mjs [--root DIR] [--width N] [--comment-style slash|block] [--no-header] [--json] [--quiet]
10
10
  //
11
11
  // 退出码:0 无问题,1 发现问题,2 用法或读取错误。
12
12
  import { readdirSync, readFileSync, statSync } from 'node:fs'
@@ -30,6 +30,8 @@ const PRIVACY_TAGS = ['@author', '@date', '@copyright', '@version']
30
30
  const FORBIDDEN_TAGS = [...STATIC_FORBIDDEN_TAGS, ...PRIVACY_TAGS]
31
31
  const DOXY_FILE = /^\/\/\/\s*@file\s+\S/
32
32
  const DOXY_BRIEF = /^\/\/\/\s*@brief\s+\S/
33
+ const BLOCK_FILE = /^@file\s+\S/
34
+ const BLOCK_BRIEF = /^@brief\s+\S/
33
35
  const MEMBER_LIST = /提供[^。]{0,40}、[^。]{0,40}(的功能|的能力|的接口)|功能[::]/
34
36
  const RATIONALE = /而不是|而非|代价.{0,4}忽略|只留一份|与其|比.{0,12}更|更(?:安全|快|清楚|直接|好写|难写)/
35
37
  const FORWARD_REF = /(?:留在|归|交给|由)\s*`?[A-Z][A-Za-z0-9_]*`?/
@@ -41,12 +43,12 @@ const DECL_KEYWORDS = /^(class|struct|union|enum|namespace|using|typedef|templat
41
43
 
42
44
  function usage(message) {
43
45
  if (message) process.stderr.write(`错误:${message}\n`)
44
- process.stderr.write('用法:node checking-comments.mjs [--root DIR] [--width N] [--no-header] [--no-brief] [--allow-tag TAG] [--json] [--quiet]\n')
46
+ process.stderr.write('用法:node checking-comments.mjs [--root DIR] [--width N] [--comment-style slash|block] [--no-header] [--no-brief] [--allow-tag TAG] [--json] [--quiet]\n')
45
47
  return EXIT.usage
46
48
  }
47
49
 
48
50
  function parseArgs(argv) {
49
- const options = { root: undefined, width: 80, widthExplicit: false, requireHeader: true, requireBrief: true, allowTags: new Set(), json: false, quiet: false }
51
+ const options = { root: undefined, width: 80, widthExplicit: false, commentStyle: 'slash', requireHeader: true, requireBrief: true, allowTags: new Set(), json: false, quiet: false }
50
52
  for (let index = 0; index < argv.length; index += 1) {
51
53
  const arg = argv[index]
52
54
  if (arg === '--root') {
@@ -56,6 +58,11 @@ function parseArgs(argv) {
56
58
  options.width = Number(argv[index + 1])
57
59
  options.widthExplicit = true
58
60
  index += 1
61
+ } else if (arg === '--comment-style') {
62
+ const value = argv[index + 1]
63
+ if (value !== 'slash' && value !== 'block') throw new Error('--comment-style 只接受 slash 或 block')
64
+ options.commentStyle = value
65
+ index += 1
59
66
  } else if (arg === '--no-header') {
60
67
  options.requireHeader = false
61
68
  } else if (arg === '--no-brief') {
@@ -198,6 +205,34 @@ function classMemberLines(lines) {
198
205
  return flags
199
206
  }
200
207
 
208
+ /**
209
+ * 取从 `index` 开始的 Doxygen 块注释;不是块注释或没有收尾时返回 undefined。
210
+ * `contents` 去掉开头标记、结尾标记与每行的 `*` 前缀,空行保留为空串。
211
+ */
212
+ export function blockDocAt(lines, index) {
213
+ const first = lines[index]?.trim() ?? ''
214
+ if (!first.startsWith('/**') || first.startsWith('/**<')) return undefined
215
+ const contents = []
216
+ if (first.includes('*/')) {
217
+ const inner = first.slice(first.indexOf('/**') + 3, first.lastIndexOf('*/')).trim()
218
+ if (inner) contents.push(inner.replace(/^\*\s?/, '').trim())
219
+ return { start: index, end: index, contents }
220
+ }
221
+ const opening = first.slice(first.indexOf('/**') + 3).trim().replace(/^\*\s?/, '')
222
+ if (opening) contents.push(opening)
223
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
224
+ const line = lines[cursor]
225
+ const trimmed = line.trim()
226
+ if (trimmed.includes('*/')) {
227
+ const before = trimmed.slice(0, trimmed.indexOf('*/')).replace(/^\*\s?/, '').trim()
228
+ if (before) contents.push(before)
229
+ return { start: index, end: cursor, contents }
230
+ }
231
+ contents.push(trimmed.replace(/^\*\s?/, '').trim())
232
+ }
233
+ return undefined
234
+ }
235
+
201
236
  /** 检查一段文本,返回问题列表;每个问题带 1 起始的行号。 */
202
237
  export function checkText(text, options = {}) {
203
238
  const width = options.width ?? 80
@@ -207,38 +242,52 @@ export function checkText(text, options = {}) {
207
242
  const add = (line, rule, message) => issues.push({ line, rule, message })
208
243
  const inClass = classMemberLines(lines)
209
244
 
245
+ const style = options.commentStyle === 'block' ? 'block' : 'slash'
210
246
  const firstContent = lines.findIndex((line) => line.trim() !== '')
211
247
  const firstTrimmed = firstContent === -1 ? '' : lines[firstContent].trim()
212
- const hasDoxyHeader = DOXY_FILE.test(firstTrimmed)
248
+ const headerBlock = style === 'block' && firstContent !== -1 ? blockDocAt(lines, firstContent) : undefined
249
+ const hasDoxyHeader = style === 'block'
250
+ ? Boolean(headerBlock && BLOCK_FILE.test(headerBlock.contents[0] ?? ''))
251
+ : DOXY_FILE.test(firstTrimmed)
213
252
  let doxyHeaderEnd = -1
214
- if (hasDoxyHeader) {
253
+ if (hasDoxyHeader && style === 'block') {
254
+ doxyHeaderEnd = headerBlock.end
255
+ } else if (hasDoxyHeader) {
215
256
  doxyHeaderEnd = firstContent
216
257
  while (doxyHeaderEnd + 1 < lines.length && lines[doxyHeaderEnd + 1].trim().startsWith('///')) doxyHeaderEnd += 1
217
258
  }
218
259
  if (requireHeader && firstContent !== -1 && !hasDoxyHeader) {
219
- add(firstContent + 1, 'header-missing', '文件开头缺少文件头:写 /// @file <文件名>,下一行写 /// @brief')
260
+ if (style === 'block') add(firstContent + 1, 'header-missing', '文件开头缺少文件头:写 /** ... */,第一行 @file <文件名>,下一行 @brief')
261
+ else add(firstContent + 1, 'header-missing', '文件开头缺少文件头:写 /// @file <文件名>,下一行写 /// @brief')
220
262
  }
221
263
  if (firstContent !== -1 && hasDoxyHeader) {
222
264
  const own = options.fileName ? options.fileName.split(/[\\/]/).pop() : ''
265
+ const declaredText = style === 'block' ? (headerBlock.contents[0] ?? '') : lines[firstContent].trim()
223
266
  if (own) {
224
- const declared = /@file\s+(\S+)/.exec(lines[firstContent].trim())
267
+ const declared = /@file\s+(\S+)/.exec(declaredText)
225
268
  if (declared && declared[1] !== own) {
226
269
  add(firstContent + 1, 'header-file-name', `文件头写的是 ${declared[1]},本文件是 ${own}:@file 必须等于文件名`)
227
270
  }
228
271
  }
229
- if (hasDoxyHeader && options.requireBrief !== false && !DOXY_BRIEF.test((lines[firstContent + 1] ?? '').trim())) {
230
- add(firstContent + 2, 'header-brief', '文件头第二行缺少 /// @brief 一句话说明')
272
+ if (options.requireBrief !== false) {
273
+ const hasBrief = style === 'block'
274
+ ? BLOCK_BRIEF.test(headerBlock.contents[1] ?? '')
275
+ : DOXY_BRIEF.test((lines[firstContent + 1] ?? '').trim())
276
+ if (!hasBrief) {
277
+ add(style === 'block' ? headerBlock.start + 3 : firstContent + 2, 'header-brief', '文件头第二行缺少 @brief 一句话说明')
278
+ }
231
279
  }
232
- for (let index = firstContent + 1; index < lines.length; index += 1) {
233
- const trimmed = lines[index].trim()
234
- if (!trimmed.startsWith('///')) break
235
- const names = trimmed.match(/\b[A-Za-z0-9_-]+\.(?:h|hpp|cpp|cc)\b/g) ?? []
236
- const fileMatches = trimmed.match(/@file\s+(\S+)/)
280
+ const headerLines = style === 'block'
281
+ ? headerBlock.contents.slice(1).map((content, offset) => ({ line: headerBlock.start + offset + 2, text: content }))
282
+ : lines.slice(firstContent + 1, doxyHeaderEnd + 1).map((content, offset) => ({ line: firstContent + offset + 2, text: content.trim() }))
283
+ for (const { line: headerLine, text } of headerLines) {
284
+ const names = text.match(/\b[A-Za-z0-9_-]+\.(?:h|hpp|cpp|cc)\b/g) ?? []
285
+ const fileMatches = text.match(/@file\s+(\S+)/)
237
286
  const namesToCheck = fileMatches ? names.filter((name) => name !== fileMatches[1]) : names
238
287
  for (const name of namesToCheck) {
239
- if (name !== own) add(index + 1, 'header-cross-file', `文件头提到了别的源文件 ${name}:只介绍本文件,删掉跨文件句子`)
288
+ if (name !== own) add(headerLine, 'header-cross-file', `文件头提到了别的源文件 ${name}:只介绍本文件,删掉跨文件句子`)
240
289
  }
241
- if (/\bapps\//.test(trimmed)) add(index + 1, 'header-cross-file', '文件头指向了另一个目录:只介绍本文件')
290
+ if (/\bapps\//.test(text)) add(headerLine, 'header-cross-file', '文件头指向了另一个目录:只介绍本文件')
242
291
  }
243
292
  }
244
293
 
@@ -254,6 +303,22 @@ export function checkText(text, options = {}) {
254
303
  }
255
304
  }
256
305
 
306
+ if (style === 'block') {
307
+ for (let index = 0; index < lines.length; index += 1) {
308
+ const doc = blockDocAt(lines, index)
309
+ if (!doc) continue
310
+ for (const content of doc.contents) {
311
+ const rendered = ` * ${content}`.trimEnd()
312
+ if (displayWidth(rendered) > width) add(doc.start + 1, 'comment-width', `注释行 ${displayWidth(rendered)} 列,超过 ${width} 列`)
313
+ for (const tag of FORBIDDEN_TAGS) {
314
+ if (options.allowTags?.has(tag.slice(1))) continue
315
+ if (content.includes(tag)) add(doc.start + 1, 'forbidden-tag', `禁止使用 ${tag},按 references/comments.md 的标签表改写`)
316
+ }
317
+ }
318
+ index = doc.end
319
+ }
320
+ }
321
+
257
322
  for (let index = 0; index < lines.length; index += 1) {
258
323
  if (doxyHeaderEnd !== -1 && index <= doxyHeaderEnd) {
259
324
  index = doxyHeaderEnd
@@ -310,6 +375,47 @@ export function checkText(text, options = {}) {
310
375
  }
311
376
  }
312
377
 
378
+ if (style === 'block') {
379
+ for (let index = 0; index < lines.length; index += 1) {
380
+ if (doxyHeaderEnd !== -1 && index <= doxyHeaderEnd) {
381
+ index = doxyHeaderEnd
382
+ continue
383
+ }
384
+ const doc = blockDocAt(lines, index)
385
+ if (!doc) continue
386
+ index = doc.end
387
+ let next = doc.end + 1
388
+ if (next < lines.length && lines[next].trim() === '') {
389
+ add(doc.end + 1, 'doc-gap', '注释与声明之间有空行:注释会挂到下一个声明,请删掉空行')
390
+ while (next < lines.length && lines[next].trim() === '') next += 1
391
+ }
392
+ if (next >= lines.length) continue
393
+ const declaration = lines[next].trim()
394
+ const block = doc.contents.join('\n')
395
+ const isType = /^(template\s*<[^>]*>\s*)?(class|struct|union|enum)\b/.test(declaration)
396
+ if (RATIONALE.test(block)) add(doc.start + 1, 'rationale', '注释在写设计取舍:方案对比与理由移到正文或实现注释,接口注释只留契约')
397
+ if (isType && MEMBER_LIST.test(block)) add(doc.start + 1, 'member-list', '类型注释在罗列成员功能,应改写为概念与使用约定')
398
+ if (isType && FORWARD_REF.test(block)) add(doc.start + 1, 'forward-ref', '类型注释在交代别的类型负责什么:只写本类型的契约,删掉这句,不要挪到文件头')
399
+ if (isType && TYPE_SYSTEM.test(block)) add(doc.start + 1, 'type-system', '类型注释在复述类型系统已表达的性质:可拷贝与 const 查询由声明给出,删掉这句')
400
+ if (isType && NEGATED_BOUNDARY.test(block)) add(doc.start + 1, 'negated-boundary', '类型注释用「只保存/不拥有/不含」写否定式边界:改成名词短语,边界交给成员类型表达')
401
+ if (isType && DOMAIN_SIZE.test(block)) add(doc.start + 1, 'domain-size', '类型注释在写领域尺寸:格子数由 cells_ 之类成员声明表达,删掉这句')
402
+ const firstDocLine = doc.contents[0] ?? ''
403
+ if (!isType && PREDICATE_DECL.test(declaration) && !firstDocLine.startsWith('检查')) {
404
+ add(doc.start + 1, 'predicate-first', '布尔查询或 operator== 的第一句应写「检查…是否…」')
405
+ }
406
+ const params = functionParams(declaration)
407
+ if (!params) continue
408
+ const documented = [...block.matchAll(/@param(?:\[[^\]]*\])?\s+(\w+)/g)].map((match) => match[1])
409
+ if (documented.length === 0) continue
410
+ for (const name of params) {
411
+ if (!documented.includes(name)) add(doc.start + 1, 'param-missing', `参数 ${name} 没有 @param 说明`)
412
+ }
413
+ for (const name of documented) {
414
+ if (!params.includes(name)) add(doc.start + 1, 'param-extra', `@param ${name} 在声明里不存在`)
415
+ }
416
+ }
417
+ }
418
+
313
419
  for (let index = 0; index + 1 < lines.length; index += 1) {
314
420
  const trimmed = lines[index].trim()
315
421
  if (!trimmed.startsWith('//') || trimmed.startsWith('///') || trimmed.startsWith('//!')) continue
@@ -376,6 +482,22 @@ export function checkText(text, options = {}) {
376
482
  index = body
377
483
  }
378
484
 
485
+ for (let index = 0; index < lines.length; index += 1) {
486
+ const marker = lines[index].indexOf('///<')
487
+ if (marker === -1) continue
488
+ const content = lines[index].slice(marker + 4).trim().replace(/。$/, '').trim()
489
+ const colon = content.indexOf(':')
490
+ if (colon !== -1) {
491
+ if (content.slice(colon + 1).trim() === '') {
492
+ add(index + 1, 'enum-trailing-empty', '行尾注释冒号后为空:写「类别:说明」,冒号两侧都不能为空')
493
+ }
494
+ continue
495
+ }
496
+ if (/[,,]/.test(content)) {
497
+ add(index + 1, 'enum-trailing-comma', '行尾注释用逗号连接类别与说明:改成「类别:说明」,类别在前')
498
+ }
499
+ }
500
+
379
501
  for (let index = 0; index < lines.length; index += 1) {
380
502
  const trimmed = lines[index].trim()
381
503
  if (trimmed.startsWith('//') || trimmed.startsWith('///') || !/\bnoexcept\b/.test(trimmed)) continue
@@ -25,7 +25,7 @@
25
25
 
26
26
  **异常声明**:构造函数、`Get` 类查询、解析函数最容易抛;在声明注释里写「当……时抛出 `std::out_of_range`」这样的具体类型,不要只写「可能抛异常」,也不要给每个参数错误都新建类型。类型选择见 `references/exceptions.md`。
27
27
 
28
- **注释**:声明注释用 `///` 写中文散文,只写调用方需要知道的;实现细节留给 `.cpp`。`@param` 每个参数都写,`@return` 每个非 `void` 函数都写,`@throws` 会抛才写;构造、析构与 `void` 无参数函数不写空标签。标识符和标签保持英文。文件头默认写 `@file` 与 `@brief` 两行,`@file` 必须与文件名相同;作者、日期、版权、版本默认不写。标签开关在插件设置页,技能正文末尾附带当前配置的快照。写完运行 `node "<skill-dir>/scripts/checking-comments.mjs" --root <项目根>`;关闭 `@brief` 时加 `--no-brief`,配置里打开了隐私标签并填了值时加 `--allow-tag <标签>`。文件头、标签、参数完整性、成员清单与设计论证由脚本兜底。文件头与 TODO 的格式见 `references/comments.md`。
28
+ **注释**:声明注释用 `///` 写中文散文,只写调用方需要知道的;实现细节留给 `.cpp`。`@param` 每个参数都写,`@return` 每个非 `void` 函数都写,`@throws` 会抛才写;构造、析构与 `void` 无参数函数不写空标签。标识符和标签保持英文。文件头默认写 `@file` 与 `@brief` 两行,`@file` 必须与文件名相同;作者、日期、版权、版本开关出厂打开,填了非空值才写出。标签开关在插件设置页,技能正文末尾附带当前配置的快照。写完运行 `node "<skill-dir>/scripts/checking-comments.mjs" --root <项目根>`;关闭 `@brief` 时加 `--no-brief`,配置里打开了隐私标签并填了值时加 `--allow-tag <标签>`。文件头、标签、参数完整性、成员清单与设计论证由脚本兜底。文件头与 TODO 的格式见 `references/comments.md`。
29
29
 
30
30
  ## 正反例指针
31
31