weflow-cli 1.16.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +19 -4
  3. package/bin/weflow-cli.ts +515 -6
  4. package/dist/bin/weflow-cli.js +580 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/diagnosticReport.d.ts +6 -0
  26. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  27. package/dist/src/services/diagnosticReport.js +16 -0
  28. package/dist/src/services/diagnosticReport.js.map +1 -1
  29. package/dist/src/services/qchatImport.d.ts +89 -0
  30. package/dist/src/services/qchatImport.d.ts.map +1 -0
  31. package/dist/src/services/qchatImport.js +273 -0
  32. package/dist/src/services/qchatImport.js.map +1 -0
  33. package/dist/src/services/setupVenv.d.ts +43 -0
  34. package/dist/src/services/setupVenv.d.ts.map +1 -0
  35. package/dist/src/services/setupVenv.js +198 -0
  36. package/dist/src/services/setupVenv.js.map +1 -0
  37. package/dist/src/services/wechatCompat.d.ts +29 -0
  38. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  39. package/dist/src/services/wechatCompat.js +65 -0
  40. package/dist/src/services/wechatCompat.js.map +1 -0
  41. package/dist/src/utils/python.d.ts +10 -0
  42. package/dist/src/utils/python.d.ts.map +1 -1
  43. package/dist/src/utils/python.js +27 -4
  44. package/dist/src/utils/python.js.map +1 -1
  45. package/docs/DATA_CONTRACT.md +25 -0
  46. package/docs/DECISIONS.md +118 -30
  47. package/docs/NT_DATABASES.md +47 -0
  48. package/docs/PROJECT_STATE.md +7 -3
  49. package/docs/RELEASING.md +4 -0
  50. package/docs/REPLAY.md +169 -0
  51. package/docs/SYNC_CONTRACT.md +197 -180
  52. package/package.json +89 -88
  53. package/resources/js/graph3d/viewer2d.js +550 -448
  54. package/scripts/annual_report.py +3 -1
  55. package/scripts/chat_notes.py +86 -3
  56. package/scripts/check_readme_commands.py +427 -426
  57. package/scripts/extract_todos.py +3 -1
  58. package/scripts/graph_2d.py +156 -151
  59. package/scripts/graph_3d.py +672 -646
  60. package/scripts/imported_chats.py +68 -0
  61. package/scripts/mcp_bridge.py +3 -1
  62. package/scripts/media_index.py +150 -0
  63. package/scripts/nt_common.py +32 -0
  64. package/scripts/nt_decrypt.py +154 -16
  65. package/scripts/on_this_day.py +178 -178
  66. package/scripts/promises.py +185 -185
  67. package/scripts/replay.py +803 -0
  68. package/scripts/run_hidden.vbs +25 -0
  69. package/scripts/semantic_search.py +78 -6
  70. package/scripts/wechat_avatar.py +185 -0
  71. package/scripts/wechat_ledger.py +190 -0
  72. package/src/core/ntCore.ts +59 -0
  73. package/src/services/assistantService.ts +48 -8
  74. package/src/services/assistantTools.ts +73 -10
  75. package/src/services/configService.ts +3 -2
  76. package/src/services/cursorProbe.ts +222 -0
  77. package/src/services/diagnosticReport.ts +22 -0
  78. package/src/services/qchatImport.ts +327 -0
  79. package/src/services/setupVenv.ts +238 -0
  80. package/src/services/wechatCompat.ts +67 -0
  81. package/src/utils/python.ts +33 -4
@@ -1,426 +1,427 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """**手写的命令面**必须在 CLI 里真实存在:README 的代码块 + `capabilities --json` 里的 `cli:` 串。
4
-
5
- `capabilities --json` 也在这一支里,因为它是**学这个项目的入口**(比 600 KB 的文档好读),而它的
6
- `cli:` 字段是**手写**的 —— 同一个输出里的 MCP 那半从真实注册表派生(`safety.mcpSurface.derivedFrom`),
7
- 手写的那半同样会漂移。2026-10-11 量过:21 条 `cli:` 串、0 处对不上(准的),但准不等于有人盯着。
8
-
9
-
10
- 为什么要有它:`1.5.0` 与主线之间曾拉开 128 个提交、两个多月,用户照着 README 敲
11
- `init --path` 得到 `unknown option` —— **文档漂移是用户先发现的那一类故障**,而仓库里
12
- 原本没有任何东西盯着它(`test/pack-contents.test.ts` 管的是 npm 包里**有什么文件**,
13
- 不是**命令面**)。
14
-
15
- 判据取自真实 CLI,不复刻命令表:逐级问 `--help`,用输出里的 `Usage:` 行判断 commander
16
- 实际解析到了哪一级。**不能看退出码** —— `--help` 会短路,`wiki <不存在的子命令> --help`
17
- 照样打印 wiki 的帮助并 exit 0。第一版就是这么错的:`bad_path` 永远是 0,看着"全绿",
18
- 其实是那条判据从来没机会执行(同 `AGENTS.md` 的"负结论要配正对照")。
19
-
20
- 只扫**围栏代码块**里以 `weflow-cli` 开头的行。散文与表格里的提及不扫 —— 英文 README 里
21
- 「Manage with `weflow-cli scene`, or bind…」的 `or` 会被当成子命令,那是假阳性。
22
-
23
- 第三类**手写的命令面**:文档代码块里的 `python|py scripts/x.py --flag`。判据**不跑脚本**,
24
- 用 AST 取 `add_argument('--x')` 当声明表 —— 仓库对 Python 脚本的既定纪律就是不 import 也不运行
25
- (`test/script_stdout_encoding_test.py` 头部写着原因:CI 的 python job 只装两个依赖,import 会假失败)。
26
- 2026-10-11 量过:24 处文档调用、脚本全在、旗标全认。
27
-
28
- 跑法:
29
-
30
- npm run build # cli.cjs 依赖 dist/
31
- python scripts/check_readme_commands.py
32
- python scripts/check_readme_commands.py --self-test # 正对照:证明判据是活的
33
- """
34
- import argparse
35
- import ast
36
- import glob
37
- import json
38
- import os
39
- import re
40
- import shlex
41
- import subprocess
42
- import sys
43
-
44
- # 输出里会有中文与命令原文;手跑/重定向时 stdout 走本机编码(GBK)会 UnicodeEncodeError。
45
- # 这是 `test/script_stdout_encoding_test.py` 盯着的规矩,见 `docs/EXTENDING.md` Recipe E。
46
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
47
-
48
- ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
- FENCE = '`' * 3
50
- TOKEN_OK = re.compile(r'^[a-z][a-z0-9-]*$')
51
- FLAG = re.compile(r'^--?[A-Za-z][A-Za-z0-9-]*$')
52
- # 文档里的 Python 调用(`py -3 scripts/x.py --flag` 这种也认)。
53
- # 脚本名里**允许连字符**:第一版写成 `[\w.]+\.py`,于是 `scripts/no-such-script.py` 整条被静默跳过 ——
54
- # 变异检查里夹具写了 3 条、提取到 2 条才发现("负结论要先排除假阴性")。`--` 前缀的自由参数也认。
55
- PY_CALL = re.compile(r'\b(?:python|py)\s+(?:--?[\w-]+\s+)*scripts/([\w.-]+\.py)(.*)$')
56
- PY_FLAG = re.compile(r'(?<!\S)(--?[A-Za-z][A-Za-z0-9-]*)')
57
-
58
-
59
- def usage_path(help_text):
60
- """这条帮助属于哪个命令 —— 从 `Usage:` 行取,直到第一个选项/占位符。"""
61
- if not help_text:
62
- return None
63
- m = re.search(r'^Usage: weflow-cli\s*([^\n]*)', help_text, re.M)
64
- if not m:
65
- return None
66
- out = []
67
- for tok in m.group(1).split():
68
- if tok.startswith(('-', '<', '[')):
69
- break
70
- out.append(tok)
71
- return out
72
-
73
-
74
- def has_subcommands(help_text):
75
- return bool(help_text) and bool(re.search(r'^\s*Commands:', help_text, re.M))
76
-
77
-
78
- def fenced_lines(text):
79
- """围栏代码块里的每一行(围栏标记本身不算):(行号, 行内容)。"""
80
- out = []
81
- fence = False
82
- for lineno, line in enumerate(text.splitlines(), 1):
83
- stripped = line.strip()
84
- if stripped.startswith(FENCE):
85
- fence = not fence
86
- continue
87
- if fence:
88
- out.append((lineno, stripped))
89
- return out
90
-
91
-
92
- def _body(stripped):
93
- """去掉 `$ ` 提示符与行尾注释。"""
94
- body = stripped[2:].strip() if stripped.startswith('$ ') else stripped
95
- return re.split(r'\s+#', body)[0].strip()
96
-
97
-
98
- def command_lines(text):
99
- """围栏代码块里的 `weflow-cli` 命令:(行号, 正文)。"""
100
- return [(lineno, body) for lineno, stripped in fenced_lines(text)
101
- if (body := _body(stripped)).startswith('weflow-cli ')]
102
-
103
-
104
- CLI_WRAPPER = re.compile(r'\b(?:npx\s+tsx|node)\s+\S*[\\/]?(?:cli\.cjs|weflow-cli\.ts)\s+(.*)$')
105
-
106
-
107
- def cli_wrapped_lines(text):
108
- """**换了个写法的 CLI 调用**:`node cli.cjs capabilities --json` → [(行号, 'weflow-cli …')]。
109
-
110
- 文档里这种写法有几处(`docs/DEEPSEEK_V4_FLASH施工文档.md`、`docs/RELEASING.md`),它们的命令与
111
- 旗标和 `weflow-cli …` 是同一套东西,所以用同一个判据(CLI 自己的 `--help`)来验 —— 此前它们
112
- **一条都没被扫到**(只认行首的 `weflow-cli `)。要求文件名是 `cli.cjs`/`weflow-cli.ts`:
113
- `node scripts/graph3d_check.mjs --flag` 那种是**别的脚本自己的旗标**,不该拿 CLI 的表去比。
114
- """
115
- out = []
116
- for lineno, stripped in fenced_lines(text):
117
- m = CLI_WRAPPER.search(_body(stripped))
118
- if m and m.group(1).strip():
119
- out.append((lineno, 'weflow-cli ' + m.group(1).strip()))
120
- return out
121
-
122
-
123
- def python_invocations(text):
124
- """围栏代码块里的 Python 调用:(行号, 脚本名, 剩余参数)。"""
125
- out = []
126
- for lineno, stripped in fenced_lines(text):
127
- m = PY_CALL.search(_body(stripped))
128
- if m:
129
- out.append((lineno, m.group(1), m.group(2).strip()))
130
- return out
131
-
132
-
133
- def declared_flags(source):
134
- """一个脚本用 `add_argument` 声明过的旗标 —— **纯 AST:不 import、不运行**。
135
-
136
- 仓库对 Python 脚本的既定纪律就是不在检查里跑它们(`test/script_stdout_encoding_test.py`
137
- 头部写了原因:CI 的 python job 只装两个依赖,import 会假失败),所以这里也只读源码。
138
- """
139
- flags = set()
140
- for node in ast.walk(ast.parse(source)):
141
- if isinstance(node, ast.Call) and getattr(node.func, 'attr', None) == 'add_argument':
142
- for arg in node.args:
143
- if isinstance(arg, ast.Constant) and isinstance(arg.value, str) \
144
- and arg.value.startswith('-'):
145
- flags.add(arg.value)
146
- return flags
147
-
148
-
149
- def audit_line(body, help_of):
150
- """→ (命令路径, [问题])。问题里 kind 是 'unknown-command' 或 'unknown-flag'。"""
151
- tokens = shlex.split(body, posix=False)[1:] # posix=False:Windows 路径里的 \ 不被吃掉
152
- path, problems = [], []
153
- for tok in tokens:
154
- clean = tok.strip('`,')
155
- if not TOKEN_OK.match(clean):
156
- break # 参数/占位符,路径到此为止
157
- if usage_path(help_of(path + [clean])) == path + [clean]:
158
- path.append(clean)
159
- else:
160
- # 走不动了。父命令**有子命令**,说明这个裸词是想当子命令用的 ⇒ 它不存在。
161
- if has_subcommands(help_of(path)):
162
- problems.append(('unknown-command', ' '.join(path + [clean])))
163
- break
164
- help_text = help_of(path)
165
- for tok in tokens:
166
- clean = tok.strip('`,')
167
- if FLAG.match(clean) and clean not in (help_text or ''):
168
- problems.append(('unknown-flag', '%s %s' % (' '.join(path), clean)))
169
- return path, problems
170
-
171
-
172
- # ---------------------------------------------------------------- 真实 CLI / 正对照
173
-
174
- def cli_help(path, cli=None):
175
- """问真实 CLI。取不到就返回 None —— 由调用方报错,不许当成"没问题"。"""
176
- cli = cli or os.path.join(ROOT, 'cli.cjs')
177
- if not os.path.exists(cli):
178
- raise SystemExit('找不到 %s —— 先跑 `npm run build`(cli.cjs 依赖 dist/)' % cli)
179
- proc = subprocess.run(['node', cli, *path, '--help'], capture_output=True, cwd=ROOT)
180
- return (proc.stdout + proc.stderr).decode('utf-8', 'replace')
181
-
182
-
183
- def default_files():
184
- """默认扫的**用户文档**。
185
-
186
- 排除两份按性质记录过去的:`CHANGELOG.md`(发布日志)与 `docs/DECISIONS.md`(决策日志)——
187
- 它们会成段引用**当时**的命令,拿今天的 CLI 去校验必然假阳性。其余 `docs/*.md` 都扫:
188
- 2026-10-11 就是这份集合里的 `OPERATIONS.md` 抓到了"文档教用户敲一个 CLI 没有的旗标"
189
- (`search --no-rerank`),而只扫两份 README 时它一条也扫不到。
190
- """
191
- out = ['README.md', 'README.en.md', 'OPERATIONS.md', 'ARCHITECTURE.md',
192
- 'CONTRIBUTING.md', 'SECURITY.md']
193
- out += ['docs/' + os.path.basename(p)
194
- for p in sorted(glob.glob(os.path.join(ROOT, 'docs', '*.md')))
195
- if os.path.basename(p) != 'DECISIONS.md']
196
- return out
197
-
198
-
199
- def check_python_call(doc, lineno, script, rest):
200
- """文档里的一条 `py scripts/x.py …` → [问题]。"""
201
- path = os.path.join(ROOT, 'scripts', script)
202
- if not os.path.exists(path):
203
- return [{'file': doc, 'line': lineno, 'command': 'scripts/' + script,
204
- 'kind': 'missing-script', 'detail': 'scripts/%s 不存在' % script}]
205
- try:
206
- with open(path, encoding='utf-8') as fh:
207
- have = declared_flags(fh.read())
208
- except (OSError, SyntaxError) as exc:
209
- # 读不出来就说读不出来(这一面**没有被检查**),不当成通过
210
- return [{'file': doc, 'line': lineno, 'command': 'scripts/' + script,
211
- 'kind': 'unreadable-script', 'detail': '读不出这个脚本的声明:%s' % exc}]
212
- out = []
213
- for flag in PY_FLAG.findall(rest):
214
- # argparse 允许前缀缩写,所以 `--dry` 也算认;文档里写全名最清楚,这里按前缀判
215
- long_ok = flag.startswith('--') and any(h.startswith(flag) for h in have if h.startswith('--'))
216
- if flag not in have and not long_ok:
217
- out.append({'file': doc, 'line': lineno, 'command': 'scripts/%s %s' % (script, flag),
218
- 'kind': 'unknown-script-flag', 'detail': '%s 没声明 %s' % (script, flag)})
219
- return out
220
-
221
-
222
- ANGLE = re.compile(r'<[^<>]*>')
223
- ELLIPSIS = re.compile(r'\.\.\.|…')
224
-
225
-
226
- def command_forms(text):
227
- """把 `capabilities` 里一条命令串拆成**可校验的形式**(这个输出的记法不是 shell 语法)。
228
-
229
- · `<a|b|c>` —— 尖括号里的"或",取第一项(`export <talker> <json|txt|html|excel>` 是**一条**命令)
230
- · `a | b` —— 尖括号**之外**的 `|` 是"两种写法"(`decide --request … | decide --over …`),逐条验
231
- · `...` / `…` —— 省略号之后截掉(那部分本来就没写全,验不了)
232
-
233
- 2026-10-11 踩过:不知道这三种记法时直接按 `|` 切,8 条串报出 11 个"问题",**全是噪声**
234
- (`<add|remove|enable|…>` 被切成了命令)。
235
- """
236
- # 取第一项时**去掉尖括号**(按第一项替换)。这一条踩过:先写成"保留尖括号",于是
237
- # `<whitelist|blacklist> <add|rm> <target> --dry-run --json` 这类串在**第一个占位符**处就断了
238
- # ——而那个占位符**本身就是命令名**(`whitelist add`)⇒ 旗标被拿去跟**父命令**的 help 比,
239
- # 一次报出 6 处"没有这个旗标",全是假的(换回不带括号后 6 处全消,且与独立探针的结果一致)。
240
- # 反方向的代价很小:参数位上的占位符变成普通小写词,路径在那里停下即可(父命令没有子命令时不会误报)。
241
- text = ANGLE.sub(lambda m: m.group(0)[1:-1].split('|')[0].strip(), text)
242
- text = ELLIPSIS.split(text)[0]
243
- return [part.strip() for part in text.split('|') if part.strip()]
244
-
245
-
246
- def capability_commands(cli=None):
247
- """`capabilities --json` 里**手写**的命令串 → [(字段路径, 命令行)]。
248
-
249
- 收 `cli` **与 `preview`** 两个字段:`preview` 是"两阶段安全路径"里先跑的那一条(用户与 agent
250
- 都按它来),所以它和 `cli` 一样会被手写漂移。这个对象的其余部分要么是布尔/契约名,要么
251
- (`safety.mcpSurface`)是从真实注册表派生的 —— 派生那半不用这儿管。
252
- """
253
- cli = cli or os.path.join(ROOT, 'cli.cjs')
254
- if not os.path.exists(cli):
255
- raise SystemExit('找不到 %s —— 先跑 `npm run build`' % cli)
256
- proc = subprocess.run(['node', cli, 'capabilities', '--json'], capture_output=True, cwd=ROOT)
257
- text = (proc.stdout or b'').decode('utf-8', 'replace')
258
- try:
259
- data = json.loads(text)
260
- except ValueError:
261
- raise SystemExit('`capabilities --json` 没给出可解析的 JSON:%s' % text[:200])
262
- out = []
263
-
264
- def walk(node, where):
265
- if isinstance(node, dict):
266
- for key, value in node.items():
267
- if key in ('cli', 'preview') and isinstance(value, str):
268
- out.append((where + '.' + key, value))
269
- else:
270
- walk(value, where + '.' + key)
271
- elif isinstance(node, list):
272
- for i, item in enumerate(node):
273
- walk(item, '%s[%d]' % (where, i))
274
-
275
- walk(data, 'capabilities')
276
- return out
277
-
278
-
279
- CANNED = {
280
- (): ('Usage: weflow-cli [options] [command]\n\nOptions:\n -V, --version\n\n'
281
- 'Commands:\n sessions [options] 会话列表\n wiki [options] 概念图谱管理\n'),
282
- ('sessions',): 'Usage: weflow-cli sessions [options]\n\nOptions:\n -n, --limit <n>\n',
283
- ('wiki',): ('Usage: weflow-cli wiki [options] [command]\n\nOptions:\n -h, --help\n\n'
284
- 'Commands:\n graph [options]\n lint [options]\n'),
285
- ('wiki', 'graph'): 'Usage: weflow-cli wiki graph [options]\n\nOptions:\n --flat\n --open\n',
286
- }
287
-
288
- # (说明, 命令行, 期望报出的问题数)
289
- CONTROLS = [
290
- ('该过的过:存在的命令 + 存在的旗标', 'weflow-cli sessions -n 5', 0),
291
- ('该报的报:不存在的旗标', 'weflow-cli sessions --bogus', 1),
292
- ('该报的报:不存在的子命令', 'weflow-cli wiki bogus', 1),
293
- ('该报的报:不存在的顶层命令', 'weflow-cli bogus', 1),
294
- ('不该报的不报:参数里的裸词(父命令没有子命令)', 'weflow-cli sessions 张三', 0),
295
- ]
296
-
297
-
298
- def self_test():
299
- """判据本身的正对照。扫描器失灵时它会安静地全绿,所以先证明它活着。"""
300
- bad = []
301
- for label, line, expect in CONTROLS:
302
- _, problems = audit_line(line, lambda p: CANNED.get(tuple(p)))
303
- if len(problems) != expect:
304
- bad.append('%s\n %s\n 期望 %d 个问题,实得 %d:%s'
305
- % (label, line, expect, len(problems), problems))
306
- # 反"空扫":一条命令都没解析出来,说明扫描器坏了,那时的"没问题"不作数
307
- sample = FENCE + 'powershell\ndir\nweflow-cli sessions -n 5\n' + FENCE + '\n'
308
- if len(command_lines(sample)) != 1:
309
- bad.append('反空扫失败:围栏代码块里的命令没被识别出来')
310
- # Python 那一半:**围栏里**的调用要认得出,**散文里**的提及不许认(那正是假阳性来源)
311
- fenced = FENCE + 'powershell\npy scripts/health_check.py --json\n' + FENCE + '\n'
312
- if len(python_invocations(fenced)) != 1:
313
- bad.append('反空扫失败:围栏里的 `py scripts/x.py` 没被识别出来')
314
- if python_invocations('跑 `py scripts/health_check.py --json` 就行(散文里的一句)\n'):
315
- bad.append('散文里的调用被当成命令了 —— 那会造出假阳性')
316
- flags = declared_flags('import argparse\np = argparse.ArgumentParser()\n'
317
- "p.add_argument('--dry-run')\np.add_argument('-n', '--limit')\n")
318
- if flags != {'--dry-run', '-n', '--limit'}:
319
- bad.append('AST 取声明旗标取错了:%s' % sorted(flags))
320
- # 脚本名里的连字符:第一版的正则漏了它,整条调用被静默跳过(夹具 3 条只提取到 2 条才发现)。
321
- # 注意围栏**必须单独一行** —— 第一版把命令写在 ``` 同一行上,那行被当成围栏标记,内容根本不产出
322
- # (控制项自己错了,却报成"连字符没被认出来")。
323
- hyphen = FENCE + '\npy scripts/some-hyphenated-script.py --flag\n' + FENCE + '\n'
324
- if len(python_invocations(hyphen)) != 1:
325
- bad.append('脚本名里的连字符没被认出来 —— 那会让整条调用静默漏检')
326
- # `capabilities` 命令串的三种记法:尖括号里的"或"取第一项、括号外的 `|` 拆成两种写法、`…` 截断
327
- forms = command_forms('export <talker> <json|txt|html|excel>')
328
- if forms != ['export talker json']:
329
- bad.append('尖括号里的"或"没取第一项:%s' % forms)
330
- forms = command_forms('<whitelist|blacklist> <add|rm> <target> --dry-run --json')
331
- if forms != ['whitelist add target --dry-run --json']:
332
- bad.append('命令名上的占位符没被替换成第一项(那会让路径断在这里):%s' % forms)
333
- forms = command_forms('decide --a --yes | decide --b --yes')
334
- if forms != ['decide --a --yes', 'decide --b --yes']:
335
- bad.append('括号外的 `|` 没拆成两种写法:%s' % forms)
336
- forms = command_forms('scene <add|remove> … --dry-run')
337
- if forms != ['scene add']:
338
- bad.append('省略号没截断 / 或"或"没取第一项:%s' % forms)
339
- # 换了写法的 CLI 调用:那两种写法都认得出,**不**把别的脚本当成 CLI
340
- wrapped = FENCE + '\nnode cli.cjs capabilities --json\nnode node_modules\\weflow-cli\\cli.cjs --version\n' + FENCE + '\n'
341
- if len(cli_wrapped_lines(wrapped)) != 2:
342
- bad.append('`node …/cli.cjs <命令>` 这种写法没被认出来:%s' % cli_wrapped_lines(wrapped))
343
- if cli_wrapped_lines(FENCE + '\nnode scripts/graph3d_check.mjs --flag\n' + FENCE + '\n'):
344
- bad.append('把别的脚本的调用当成 CLI 了(那会拿 CLI 的表去比它的旗标)')
345
- controls = len(CONTROLS) + 10
346
- print('正对照:%d 条,通过 %d 条' % (controls, controls - len(bad)))
347
- for item in bad:
348
- print(' ✗ ' + item)
349
- return 1 if bad else 0
350
-
351
-
352
- def main():
353
- parser = argparse.ArgumentParser(description='校验文档与 capabilities 里的命令在 CLI 里存在')
354
- parser.add_argument('--files', nargs='*', default=None,
355
- help='要扫的 markdown(缺省:README + 仓库根那几份 + docs/*.md,'
356
- '排除按性质记录过去的 CHANGELOG 与 DECISIONS)')
357
- parser.add_argument('--json', action='store_true')
358
- parser.add_argument('--self-test', action='store_true', help='只跑判据的正对照')
359
- args = parser.parse_args()
360
-
361
- if args.self_test:
362
- raise SystemExit(self_test())
363
-
364
- checked, findings, doc_checked = 0, [], 0
365
- for name in (args.files if args.files else default_files()):
366
- path = os.path.join(ROOT, name)
367
- if not os.path.exists(path):
368
- raise SystemExit('找不到 %s' % path)
369
- with open(path, encoding='utf-8') as fh:
370
- text = fh.read()
371
- for lineno, body in command_lines(text) + cli_wrapped_lines(text):
372
- checked += 1
373
- doc_checked += 1
374
- _, problems = audit_line(body, cli_help)
375
- findings += [{'file': name, 'line': lineno, 'command': body,
376
- 'kind': kind, 'detail': detail} for kind, detail in problems]
377
- # 同一批文档里的第三类手写面:`py scripts/x.py --flag`
378
- for lineno, script, rest in python_invocations(text):
379
- checked += 1
380
- findings += check_python_call(name, lineno, script, rest)
381
-
382
- # 第二个手写面:`capabilities --json` 的 `cli:` 与 `preview:` 串(学这个项目的入口,也是手写的)。
383
- caps = capability_commands()
384
- cap_forms = 0
385
- for where, command in caps:
386
- for form in command_forms(command):
387
- cap_forms += 1
388
- checked += 1
389
- _, problems = audit_line('weflow-cli ' + form, cli_help)
390
- findings += [{'file': 'capabilities --json', 'line': 0, 'command': form, 'where': where,
391
- 'kind': kind, 'detail': detail} for kind, detail in problems]
392
- script_checked = checked - doc_checked - cap_forms
393
-
394
- if args.json:
395
- print(json.dumps({'checked': checked, 'docChecked': doc_checked,
396
- 'scriptChecked': script_checked, 'capabilityFormsChecked': cap_forms,
397
- 'capabilityStringsChecked': len(caps), 'findings': findings},
398
- ensure_ascii=False, indent=2))
399
- else:
400
- for f in findings:
401
- source = ('%s(%s)' % (f['file'], f['where'])) if f.get('where') else ('%s:%d' % (f['file'], f['line']))
402
- where_it_lives = ('文档里写了它' if f['kind'] == 'unknown-script-flag' else '写的地方有它')
403
- missing_side = ('那个脚本没声明' if f['kind'] == 'unknown-script-flag' else 'CLI 里没有')
404
- print('[FAIL] %s %s\n %s —— %s%s' % (source, f['command'], f['detail'], where_it_lives, missing_side))
405
- if findings:
406
- print('\n%d 处对不上(共检查 %d 条:文档命令 %d + 脚本调用 %d + capabilities %d 种写法)。'
407
- % (len(findings), checked, doc_checked, script_checked, cap_forms))
408
- else:
409
- print('命令面与实现一致:文档命令 %d 条 + `py scripts/…` 调用 %d 条 + capabilities 的 %d 种写法'
410
- '(来自 %d 条 `cli:`/`preview:` 串),0 处对不上。'
411
- % (doc_checked, script_checked, cap_forms, len(caps)))
412
- # 反空扫只对**缺省语料**生效:那批文档里一定有命令,扫到 0 条说明判据/提取器坏了,不该报通过。
413
- # 显式 `--files <夹具>` 时不算失败 —— 夹具本来就可能只含某一类。
414
- # ("提取器本身还活着"另由 `--self-test` 的三条对照负责;脚本那一面**不做** entry 级反空扫:
415
- # 文档里一条 `py scripts/…` 都没有是可能发生的。)
416
- if not args.files and doc_checked == 0:
417
- print('反空扫失败:缺省文档里一条命令都没扫到,这次"通过"不作数。')
418
- return 2
419
- if not caps:
420
- print('反空扫失败:`capabilities --json` 里一条 `cli:` 串都没读到,这一半没有被检查。')
421
- return 2
422
- return 1 if findings else 0
423
-
424
-
425
- if __name__ == '__main__':
426
- raise SystemExit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """**手写的命令面**必须在 CLI 里真实存在:README 的代码块 + `capabilities --json` 里的 `cli:` 串。
4
+
5
+ `capabilities --json` 也在这一支里,因为它是**学这个项目的入口**(比 600 KB 的文档好读),而它的
6
+ `cli:` 字段是**手写**的 —— 同一个输出里的 MCP 那半从真实注册表派生(`safety.mcpSurface.derivedFrom`),
7
+ 手写的那半同样会漂移。2026-10-11 量过:21 条 `cli:` 串、0 处对不上(准的),但准不等于有人盯着。
8
+
9
+
10
+ 为什么要有它:`1.5.0` 与主线之间曾拉开 128 个提交、两个多月,用户照着 README 敲
11
+ `init --path` 得到 `unknown option` —— **文档漂移是用户先发现的那一类故障**,而仓库里
12
+ 原本没有任何东西盯着它(`test/pack-contents.test.ts` 管的是 npm 包里**有什么文件**,
13
+ 不是**命令面**)。
14
+
15
+ 判据取自真实 CLI,不复刻命令表:逐级问 `--help`,用输出里的 `Usage:` 行判断 commander
16
+ 实际解析到了哪一级。**不能看退出码** —— `--help` 会短路,`wiki <不存在的子命令> --help`
17
+ 照样打印 wiki 的帮助并 exit 0。第一版就是这么错的:`bad_path` 永远是 0,看着"全绿",
18
+ 其实是那条判据从来没机会执行(同 `AGENTS.md` 的"负结论要配正对照")。
19
+
20
+ 只扫**围栏代码块**里以 `weflow-cli` 开头的行。散文与表格里的提及不扫 —— 英文 README 里
21
+ 「Manage with `weflow-cli scene`, or bind…」的 `or` 会被当成子命令,那是假阳性。
22
+
23
+ 第三类**手写的命令面**:文档代码块里的 `python|py scripts/x.py --flag`。判据**不跑脚本**,
24
+ 用 AST 取 `add_argument('--x')` 当声明表 —— 仓库对 Python 脚本的既定纪律就是不 import 也不运行
25
+ (`test/script_stdout_encoding_test.py` 头部写着原因:CI 的 python job 只装两个依赖,import 会假失败)。
26
+ 2026-10-11 量过:24 处文档调用、脚本全在、旗标全认。
27
+
28
+ 跑法:
29
+
30
+ npm run build # cli.cjs 依赖 dist/
31
+ python scripts/check_readme_commands.py
32
+ python scripts/check_readme_commands.py --self-test # 正对照:证明判据是活的
33
+ """
34
+ import argparse
35
+ import ast
36
+ import glob
37
+ import json
38
+ import os
39
+ import re
40
+ import shlex
41
+ import subprocess
42
+ import sys
43
+
44
+ # 输出里会有中文与命令原文;手跑/重定向时 stdout 走本机编码(GBK)会 UnicodeEncodeError。
45
+ # 这是 `test/script_stdout_encoding_test.py` 盯着的规矩,见 `docs/EXTENDING.md` Recipe E。
46
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
47
+
48
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
+ FENCE = '`' * 3
50
+ TOKEN_OK = re.compile(r'^[a-z][a-z0-9-]*$')
51
+ FLAG = re.compile(r'^--?[A-Za-z][A-Za-z0-9-]*$')
52
+ # 文档里的 Python 调用(`py -3 scripts/x.py --flag` 这种也认)。
53
+ # 脚本名里**允许连字符**:第一版写成 `[\w.]+\.py`,于是 `scripts/no-such-script.py` 整条被静默跳过 ——
54
+ # 变异检查里夹具写了 3 条、提取到 2 条才发现("负结论要先排除假阴性")。`--` 前缀的自由参数也认。
55
+ PY_CALL = re.compile(r'\b(?:python|py)\s+(?:--?[\w-]+\s+)*scripts/([\w.-]+\.py)(.*)$')
56
+ PY_FLAG = re.compile(r'(?<!\S)(--?[A-Za-z][A-Za-z0-9-]*)')
57
+
58
+
59
+ def usage_path(help_text):
60
+ """这条帮助属于哪个命令 —— 从 `Usage:` 行取,直到第一个选项/占位符。"""
61
+ if not help_text:
62
+ return None
63
+ m = re.search(r'^Usage: weflow-cli\s*([^\n]*)', help_text, re.M)
64
+ if not m:
65
+ return None
66
+ out = []
67
+ for tok in m.group(1).split():
68
+ if tok.startswith(('-', '<', '[')):
69
+ break
70
+ out.append(tok)
71
+ return out
72
+
73
+
74
+ def has_subcommands(help_text):
75
+ return bool(help_text) and bool(re.search(r'^\s*Commands:', help_text, re.M))
76
+
77
+
78
+ def fenced_lines(text):
79
+ """围栏代码块里的每一行(围栏标记本身不算):(行号, 行内容)。"""
80
+ out = []
81
+ fence = False
82
+ for lineno, line in enumerate(text.splitlines(), 1):
83
+ stripped = line.strip()
84
+ if stripped.startswith(FENCE):
85
+ fence = not fence
86
+ continue
87
+ if fence:
88
+ out.append((lineno, stripped))
89
+ return out
90
+
91
+
92
+ def _body(stripped):
93
+ """去掉 `$ ` 提示符与行尾注释。"""
94
+ body = stripped[2:].strip() if stripped.startswith('$ ') else stripped
95
+ return re.split(r'\s+#', body)[0].strip()
96
+
97
+
98
+ def command_lines(text):
99
+ """围栏代码块里的 `weflow-cli` 命令:(行号, 正文)。"""
100
+ return [(lineno, body) for lineno, stripped in fenced_lines(text)
101
+ if (body := _body(stripped)).startswith('weflow-cli ')]
102
+
103
+
104
+ CLI_WRAPPER = re.compile(r'\b(?:npx\s+tsx|node)\s+\S*[\\/]?(?:cli\.cjs|weflow-cli\.ts)\s+(.*)$')
105
+
106
+
107
+ def cli_wrapped_lines(text):
108
+ """**换了个写法的 CLI 调用**:`node cli.cjs capabilities --json` → [(行号, 'weflow-cli …')]。
109
+
110
+ 文档里这种写法有几处(`docs/DEEPSEEK_V4_FLASH施工文档.md`、`docs/RELEASING.md`),它们的命令与
111
+ 旗标和 `weflow-cli …` 是同一套东西,所以用同一个判据(CLI 自己的 `--help`)来验 —— 此前它们
112
+ **一条都没被扫到**(只认行首的 `weflow-cli `)。要求文件名是 `cli.cjs`/`weflow-cli.ts`:
113
+ `node scripts/graph3d_check.mjs --flag` 那种是**别的脚本自己的旗标**,不该拿 CLI 的表去比。
114
+ """
115
+ out = []
116
+ for lineno, stripped in fenced_lines(text):
117
+ m = CLI_WRAPPER.search(_body(stripped))
118
+ if m and m.group(1).strip():
119
+ out.append((lineno, 'weflow-cli ' + m.group(1).strip()))
120
+ return out
121
+
122
+
123
+ def python_invocations(text):
124
+ """围栏代码块里的 Python 调用:(行号, 脚本名, 剩余参数)。"""
125
+ out = []
126
+ for lineno, stripped in fenced_lines(text):
127
+ m = PY_CALL.search(_body(stripped))
128
+ if m:
129
+ out.append((lineno, m.group(1), m.group(2).strip()))
130
+ return out
131
+
132
+
133
+ def declared_flags(source):
134
+ """一个脚本用 `add_argument` 声明过的旗标 —— **纯 AST:不 import、不运行**。
135
+
136
+ 仓库对 Python 脚本的既定纪律就是不在检查里跑它们(`test/script_stdout_encoding_test.py`
137
+ 头部写了原因:CI 的 python job 只装两个依赖,import 会假失败),所以这里也只读源码。
138
+ """
139
+ flags = set()
140
+ for node in ast.walk(ast.parse(source)):
141
+ if isinstance(node, ast.Call) and getattr(node.func, 'attr', None) == 'add_argument':
142
+ for arg in node.args:
143
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str) \
144
+ and arg.value.startswith('-'):
145
+ flags.add(arg.value)
146
+ return flags
147
+
148
+
149
+ def audit_line(body, help_of):
150
+ """→ (命令路径, [问题])。问题里 kind 是 'unknown-command' 或 'unknown-flag'。"""
151
+ tokens = shlex.split(body, posix=False)[1:] # posix=False:Windows 路径里的 \ 不被吃掉
152
+ path, problems = [], []
153
+ for tok in tokens:
154
+ clean = tok.strip('`,')
155
+ if not TOKEN_OK.match(clean):
156
+ break # 参数/占位符,路径到此为止
157
+ if usage_path(help_of(path + [clean])) == path + [clean]:
158
+ path.append(clean)
159
+ else:
160
+ # 走不动了。父命令**有子命令**,说明这个裸词是想当子命令用的 ⇒ 它不存在。
161
+ if has_subcommands(help_of(path)):
162
+ problems.append(('unknown-command', ' '.join(path + [clean])))
163
+ break
164
+ help_text = help_of(path)
165
+ for tok in tokens:
166
+ clean = tok.strip('`,')
167
+ if FLAG.match(clean) and clean not in (help_text or ''):
168
+ problems.append(('unknown-flag', '%s %s' % (' '.join(path), clean)))
169
+ return path, problems
170
+
171
+
172
+ # ---------------------------------------------------------------- 真实 CLI / 正对照
173
+
174
+ def cli_help(path, cli=None):
175
+ """问真实 CLI。取不到就返回 None —— 由调用方报错,不许当成"没问题"。"""
176
+ cli = cli or os.path.join(ROOT, 'cli.cjs')
177
+ if not os.path.exists(cli):
178
+ raise SystemExit('找不到 %s —— 先跑 `npm run build`(cli.cjs 依赖 dist/)' % cli)
179
+ proc = subprocess.run(['node', cli, *path, '--help'], capture_output=True, cwd=ROOT)
180
+ return (proc.stdout + proc.stderr).decode('utf-8', 'replace')
181
+
182
+
183
+ def default_files():
184
+ """默认扫的**用户文档**。
185
+
186
+ 排除两份按性质记录过去的:`CHANGELOG.md`(发布日志)与 `docs/DECISIONS.md`(决策日志)——
187
+ 它们会成段引用**当时**的命令,拿今天的 CLI 去校验必然假阳性。其余 `docs/*.md` 都扫:
188
+ 2026-10-11 就是这份集合里的 `OPERATIONS.md` 抓到了"文档教用户敲一个 CLI 没有的旗标"
189
+ (`search --no-rerank`),而只扫两份 README 时它一条也扫不到。
190
+ """
191
+ out = ['README.md', 'README.en.md', 'OPERATIONS.md', 'ARCHITECTURE.md',
192
+ 'CONTRIBUTING.md', 'SECURITY.md']
193
+ out += ['docs/' + os.path.basename(p)
194
+ for p in sorted(glob.glob(os.path.join(ROOT, 'docs', '*.md')))
195
+ if os.path.basename(p) != 'DECISIONS.md']
196
+ return out
197
+
198
+
199
+ def check_python_call(doc, lineno, script, rest):
200
+ """文档里的一条 `py scripts/x.py …` → [问题]。"""
201
+ path = os.path.join(ROOT, 'scripts', script)
202
+ if not os.path.exists(path):
203
+ return [{'file': doc, 'line': lineno, 'command': 'scripts/' + script,
204
+ 'kind': 'missing-script', 'detail': 'scripts/%s 不存在' % script}]
205
+ try:
206
+ with open(path, encoding='utf-8') as fh:
207
+ have = declared_flags(fh.read())
208
+ except (OSError, SyntaxError) as exc:
209
+ # 读不出来就说读不出来(这一面**没有被检查**),不当成通过
210
+ return [{'file': doc, 'line': lineno, 'command': 'scripts/' + script,
211
+ 'kind': 'unreadable-script', 'detail': '读不出这个脚本的声明:%s' % exc}]
212
+ out = []
213
+ for flag in PY_FLAG.findall(rest):
214
+ # argparse 允许前缀缩写,所以 `--dry` 也算认;文档里写全名最清楚,这里按前缀判
215
+ long_ok = flag.startswith('--') and any(h.startswith(flag) for h in have if h.startswith('--'))
216
+ if flag not in have and not long_ok:
217
+ out.append({'file': doc, 'line': lineno, 'command': 'scripts/%s %s' % (script, flag),
218
+ 'kind': 'unknown-script-flag', 'detail': '%s 没声明 %s' % (script, flag)})
219
+ return out
220
+
221
+
222
+ ANGLE = re.compile(r'<[^<>]*>')
223
+ ELLIPSIS = re.compile(r'\.\.\.|…')
224
+
225
+
226
+ def command_forms(text):
227
+ """把 `capabilities` 里一条命令串拆成**可校验的形式**(这个输出的记法不是 shell 语法)。
228
+
229
+ · `<a|b|c>` —— 尖括号里的"或",取第一项(`export <talker> <json|txt|html|excel>` 是**一条**命令)
230
+ · `a | b` —— 尖括号**之外**的 `|` 是"两种写法"(`decide --request … | decide --over …`),逐条验
231
+ · `...` / `…` —— 省略号之后截掉(那部分本来就没写全,验不了)
232
+
233
+ 2026-10-11 踩过:不知道这三种记法时直接按 `|` 切,8 条串报出 11 个"问题",**全是噪声**
234
+ (`<add|remove|enable|…>` 被切成了命令)。
235
+ """
236
+ # 取第一项时**去掉尖括号**(按第一项替换)。这一条踩过:先写成"保留尖括号",于是
237
+ # `<whitelist|blacklist> <add|rm> <target> --dry-run --json` 这类串在**第一个占位符**处就断了
238
+ # ——而那个占位符**本身就是命令名**(`whitelist add`)⇒ 旗标被拿去跟**父命令**的 help 比,
239
+ # 一次报出 6 处"没有这个旗标",全是假的(换回不带括号后 6 处全消,且与独立探针的结果一致)。
240
+ # 反方向的代价很小:参数位上的占位符变成普通小写词,路径在那里停下即可(父命令没有子命令时不会误报)。
241
+ text = ANGLE.sub(lambda m: m.group(0)[1:-1].split('|')[0].strip(), text)
242
+ text = ELLIPSIS.split(text)[0]
243
+ return [part.strip() for part in text.split('|') if part.strip()]
244
+
245
+
246
+ def capability_commands(cli=None):
247
+ """`capabilities --json` 里**手写**的命令串 → [(字段路径, 命令行)]。
248
+
249
+ 收 `cli`、`preview` **与 `probe`** 三个字段:`preview` 是"两阶段安全路径"里先跑的那一条,
250
+ `probe` 是指向**诊断/探测命令**的那一条(`cursorReadiness.probe`)——用户与 agent 都会
251
+ 照着跑,所以它们和 `cli` 一样会被手写漂移。这个对象的其余部分要么是布尔/契约名,要么
252
+ (`safety.mcpSurface`)是从真实注册表派生的 —— 派生那半不用这儿管。
253
+ """
254
+ cli = cli or os.path.join(ROOT, 'cli.cjs')
255
+ if not os.path.exists(cli):
256
+ raise SystemExit('找不到 %s —— 先跑 `npm run build`' % cli)
257
+ proc = subprocess.run(['node', cli, 'capabilities', '--json'], capture_output=True, cwd=ROOT)
258
+ text = (proc.stdout or b'').decode('utf-8', 'replace')
259
+ try:
260
+ data = json.loads(text)
261
+ except ValueError:
262
+ raise SystemExit('`capabilities --json` 没给出可解析的 JSON:%s' % text[:200])
263
+ out = []
264
+
265
+ def walk(node, where):
266
+ if isinstance(node, dict):
267
+ for key, value in node.items():
268
+ if key in ('cli', 'preview', 'probe') and isinstance(value, str):
269
+ out.append((where + '.' + key, value))
270
+ else:
271
+ walk(value, where + '.' + key)
272
+ elif isinstance(node, list):
273
+ for i, item in enumerate(node):
274
+ walk(item, '%s[%d]' % (where, i))
275
+
276
+ walk(data, 'capabilities')
277
+ return out
278
+
279
+
280
+ CANNED = {
281
+ (): ('Usage: weflow-cli [options] [command]\n\nOptions:\n -V, --version\n\n'
282
+ 'Commands:\n sessions [options] 会话列表\n wiki [options] 概念图谱管理\n'),
283
+ ('sessions',): 'Usage: weflow-cli sessions [options]\n\nOptions:\n -n, --limit <n>\n',
284
+ ('wiki',): ('Usage: weflow-cli wiki [options] [command]\n\nOptions:\n -h, --help\n\n'
285
+ 'Commands:\n graph [options]\n lint [options]\n'),
286
+ ('wiki', 'graph'): 'Usage: weflow-cli wiki graph [options]\n\nOptions:\n --flat\n --open\n',
287
+ }
288
+
289
+ # (说明, 命令行, 期望报出的问题数)
290
+ CONTROLS = [
291
+ ('该过的过:存在的命令 + 存在的旗标', 'weflow-cli sessions -n 5', 0),
292
+ ('该报的报:不存在的旗标', 'weflow-cli sessions --bogus', 1),
293
+ ('该报的报:不存在的子命令', 'weflow-cli wiki bogus', 1),
294
+ ('该报的报:不存在的顶层命令', 'weflow-cli bogus', 1),
295
+ ('不该报的不报:参数里的裸词(父命令没有子命令)', 'weflow-cli sessions 张三', 0),
296
+ ]
297
+
298
+
299
+ def self_test():
300
+ """判据本身的正对照。扫描器失灵时它会安静地全绿,所以先证明它活着。"""
301
+ bad = []
302
+ for label, line, expect in CONTROLS:
303
+ _, problems = audit_line(line, lambda p: CANNED.get(tuple(p)))
304
+ if len(problems) != expect:
305
+ bad.append('%s\n %s\n 期望 %d 个问题,实得 %d:%s'
306
+ % (label, line, expect, len(problems), problems))
307
+ # 反"空扫":一条命令都没解析出来,说明扫描器坏了,那时的"没问题"不作数
308
+ sample = FENCE + 'powershell\ndir\nweflow-cli sessions -n 5\n' + FENCE + '\n'
309
+ if len(command_lines(sample)) != 1:
310
+ bad.append('反空扫失败:围栏代码块里的命令没被识别出来')
311
+ # Python 那一半:**围栏里**的调用要认得出,**散文里**的提及不许认(那正是假阳性来源)
312
+ fenced = FENCE + 'powershell\npy scripts/health_check.py --json\n' + FENCE + '\n'
313
+ if len(python_invocations(fenced)) != 1:
314
+ bad.append('反空扫失败:围栏里的 `py scripts/x.py` 没被识别出来')
315
+ if python_invocations('跑 `py scripts/health_check.py --json` 就行(散文里的一句)\n'):
316
+ bad.append('散文里的调用被当成命令了 —— 那会造出假阳性')
317
+ flags = declared_flags('import argparse\np = argparse.ArgumentParser()\n'
318
+ "p.add_argument('--dry-run')\np.add_argument('-n', '--limit')\n")
319
+ if flags != {'--dry-run', '-n', '--limit'}:
320
+ bad.append('AST 取声明旗标取错了:%s' % sorted(flags))
321
+ # 脚本名里的连字符:第一版的正则漏了它,整条调用被静默跳过(夹具 3 条只提取到 2 条才发现)。
322
+ # 注意围栏**必须单独一行** —— 第一版把命令写在 ``` 同一行上,那行被当成围栏标记,内容根本不产出
323
+ # (控制项自己错了,却报成"连字符没被认出来")。
324
+ hyphen = FENCE + '\npy scripts/some-hyphenated-script.py --flag\n' + FENCE + '\n'
325
+ if len(python_invocations(hyphen)) != 1:
326
+ bad.append('脚本名里的连字符没被认出来 —— 那会让整条调用静默漏检')
327
+ # `capabilities` 命令串的三种记法:尖括号里的"或"取第一项、括号外的 `|` 拆成两种写法、`…` 截断
328
+ forms = command_forms('export <talker> <json|txt|html|excel>')
329
+ if forms != ['export talker json']:
330
+ bad.append('尖括号里的"或"没取第一项:%s' % forms)
331
+ forms = command_forms('<whitelist|blacklist> <add|rm> <target> --dry-run --json')
332
+ if forms != ['whitelist add target --dry-run --json']:
333
+ bad.append('命令名上的占位符没被替换成第一项(那会让路径断在这里):%s' % forms)
334
+ forms = command_forms('decide --a --yes | decide --b --yes')
335
+ if forms != ['decide --a --yes', 'decide --b --yes']:
336
+ bad.append('括号外的 `|` 没拆成两种写法:%s' % forms)
337
+ forms = command_forms('scene <add|remove> … --dry-run')
338
+ if forms != ['scene add']:
339
+ bad.append('省略号没截断 / 或"或"没取第一项:%s' % forms)
340
+ # 换了写法的 CLI 调用:那两种写法都认得出,**不**把别的脚本当成 CLI
341
+ wrapped = FENCE + '\nnode cli.cjs capabilities --json\nnode node_modules\\weflow-cli\\cli.cjs --version\n' + FENCE + '\n'
342
+ if len(cli_wrapped_lines(wrapped)) != 2:
343
+ bad.append('`node …/cli.cjs <命令>` 这种写法没被认出来:%s' % cli_wrapped_lines(wrapped))
344
+ if cli_wrapped_lines(FENCE + '\nnode scripts/graph3d_check.mjs --flag\n' + FENCE + '\n'):
345
+ bad.append('把别的脚本的调用当成 CLI 了(那会拿 CLI 的表去比它的旗标)')
346
+ controls = len(CONTROLS) + 10
347
+ print('正对照:%d 条,通过 %d 条' % (controls, controls - len(bad)))
348
+ for item in bad:
349
+ print(' ✗ ' + item)
350
+ return 1 if bad else 0
351
+
352
+
353
+ def main():
354
+ parser = argparse.ArgumentParser(description='校验文档与 capabilities 里的命令在 CLI 里存在')
355
+ parser.add_argument('--files', nargs='*', default=None,
356
+ help='要扫的 markdown(缺省:README + 仓库根那几份 + docs/*.md,'
357
+ '排除按性质记录过去的 CHANGELOG 与 DECISIONS)')
358
+ parser.add_argument('--json', action='store_true')
359
+ parser.add_argument('--self-test', action='store_true', help='只跑判据的正对照')
360
+ args = parser.parse_args()
361
+
362
+ if args.self_test:
363
+ raise SystemExit(self_test())
364
+
365
+ checked, findings, doc_checked = 0, [], 0
366
+ for name in (args.files if args.files else default_files()):
367
+ path = os.path.join(ROOT, name)
368
+ if not os.path.exists(path):
369
+ raise SystemExit('找不到 %s' % path)
370
+ with open(path, encoding='utf-8') as fh:
371
+ text = fh.read()
372
+ for lineno, body in command_lines(text) + cli_wrapped_lines(text):
373
+ checked += 1
374
+ doc_checked += 1
375
+ _, problems = audit_line(body, cli_help)
376
+ findings += [{'file': name, 'line': lineno, 'command': body,
377
+ 'kind': kind, 'detail': detail} for kind, detail in problems]
378
+ # 同一批文档里的第三类手写面:`py scripts/x.py --flag`
379
+ for lineno, script, rest in python_invocations(text):
380
+ checked += 1
381
+ findings += check_python_call(name, lineno, script, rest)
382
+
383
+ # 第二个手写面:`capabilities --json` 的 `cli:` 与 `preview:` 串(学这个项目的入口,也是手写的)。
384
+ caps = capability_commands()
385
+ cap_forms = 0
386
+ for where, command in caps:
387
+ for form in command_forms(command):
388
+ cap_forms += 1
389
+ checked += 1
390
+ _, problems = audit_line('weflow-cli ' + form, cli_help)
391
+ findings += [{'file': 'capabilities --json', 'line': 0, 'command': form, 'where': where,
392
+ 'kind': kind, 'detail': detail} for kind, detail in problems]
393
+ script_checked = checked - doc_checked - cap_forms
394
+
395
+ if args.json:
396
+ print(json.dumps({'checked': checked, 'docChecked': doc_checked,
397
+ 'scriptChecked': script_checked, 'capabilityFormsChecked': cap_forms,
398
+ 'capabilityStringsChecked': len(caps), 'findings': findings},
399
+ ensure_ascii=False, indent=2))
400
+ else:
401
+ for f in findings:
402
+ source = ('%s(%s)' % (f['file'], f['where'])) if f.get('where') else ('%s:%d' % (f['file'], f['line']))
403
+ where_it_lives = ('文档里写了它' if f['kind'] == 'unknown-script-flag' else '写的地方有它')
404
+ missing_side = ('那个脚本没声明' if f['kind'] == 'unknown-script-flag' else 'CLI 里没有')
405
+ print('[FAIL] %s %s\n %s —— %s%s' % (source, f['command'], f['detail'], where_it_lives, missing_side))
406
+ if findings:
407
+ print('\n%d 处对不上(共检查 %d 条:文档命令 %d + 脚本调用 %d + capabilities %d 种写法)。'
408
+ % (len(findings), checked, doc_checked, script_checked, cap_forms))
409
+ else:
410
+ print('命令面与实现一致:文档命令 %d 条 + `py scripts/…` 调用 %d 条 + capabilities 的 %d 种写法'
411
+ '(来自 %d 条 `cli:`/`preview:`/`probe:` 串),0 处对不上。'
412
+ % (doc_checked, script_checked, cap_forms, len(caps)))
413
+ # 反空扫只对**缺省语料**生效:那批文档里一定有命令,扫到 0 条说明判据/提取器坏了,不该报通过。
414
+ # 显式 `--files <夹具>` 时不算失败 —— 夹具本来就可能只含某一类。
415
+ # ("提取器本身还活着"另由 `--self-test` 的三条对照负责;脚本那一面**不做** entry 级反空扫:
416
+ # 文档里一条 `py scripts/…` 都没有是可能发生的。)
417
+ if not args.files and doc_checked == 0:
418
+ print('反空扫失败:缺省文档里一条命令都没扫到,这次"通过"不作数。')
419
+ return 2
420
+ if not caps:
421
+ print('反空扫失败:`capabilities --json` 里一条 `cli:` 串都没读到,这一半没有被检查。')
422
+ return 2
423
+ return 1 if findings else 0
424
+
425
+
426
+ if __name__ == '__main__':
427
+ raise SystemExit(main())