weflow-cli 1.9.0 → 1.11.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/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1051 -0
- package/OPERATIONS.md +99 -13
- package/README.en.md +14 -5
- package/README.md +23 -8
- package/bin/weflow-cli.ts +364 -40
- package/dist/bin/weflow-cli.js +381 -69
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +19 -1
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +35 -0
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +19 -0
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +20 -0
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +27 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +169 -35
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +2 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/exportService.d.ts +43 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +116 -11
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1274 -0
- package/docs/EXTENDING.md +69 -7
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +2 -2
- package/docs/PROJECT_STATE.md +64 -5
- package/docs/RELEASING.md +128 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/index.ts +18 -3
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/index.html +12 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +111 -0
- package/resources/panel/renderer.js +480 -8
- package/scripts/_utils.py +1203 -977
- package/scripts/biz_daily.py +25 -10
- package/scripts/chat_notes.py +15 -5
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +163 -57
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +346 -6
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +212 -58
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wiki_lint.py +94 -17
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +33 -0
- package/src/services/assistantService.ts +26 -0
- package/src/services/assistantTools.ts +2452 -2319
- package/src/services/configService.ts +5 -2
- package/src/services/exportService.ts +129 -12
- package/src/services/messageQuery.ts +29 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/scripts/biz_daily.py
CHANGED
|
@@ -212,19 +212,29 @@ def _group_by_topic(articles):
|
|
|
212
212
|
return groups, fallbacks
|
|
213
213
|
|
|
214
214
|
|
|
215
|
-
def _classify_with_jev(client, title, body, topics):
|
|
215
|
+
def _classify_with_jev(client, title, body, topics, noise=None):
|
|
216
216
|
"""让 Jev 判断这一篇;**问不出来就返回 None**,由调用方逐篇退回老路。
|
|
217
217
|
|
|
218
218
|
这里刻意吞掉异常(包括 `JevError` 之外的意外),因为"分类这一篇失败"
|
|
219
219
|
不该让整天的日报挂掉。但异常类型会打出来——一个真的写错了的行号不该
|
|
220
220
|
在日志里长得跟"网络抖了一下"一样。
|
|
221
|
+
|
|
222
|
+
`noise` 可选(见 `_classify_articles_parallel`):把**同一条**失败折叠成一次输出。
|
|
223
|
+
一天上百篇全因同一个原因失败时(实测:判断层按地区不可用,HTTP 451),打上百行
|
|
224
|
+
等于没说;而**没见过的异常类型或没见过的消息仍然每次照打**——折叠的是重复,不是未知。
|
|
221
225
|
"""
|
|
222
226
|
if client is None:
|
|
223
227
|
return None
|
|
224
228
|
try:
|
|
225
229
|
return client.decide_article(title, body, topics)
|
|
226
230
|
except Exception as error:
|
|
227
|
-
|
|
231
|
+
signature = (type(error).__name__, str(error)[:200])
|
|
232
|
+
if noise is not None and signature in noise['seen']:
|
|
233
|
+
noise['repeat'] += 1
|
|
234
|
+
else:
|
|
235
|
+
if noise is not None:
|
|
236
|
+
noise['seen'].add(signature)
|
|
237
|
+
print(f' [WARN] Jev 分类失败({type(error).__name__}),本篇退回 LLM 解析:{error}')
|
|
228
238
|
return None
|
|
229
239
|
|
|
230
240
|
|
|
@@ -284,9 +294,12 @@ def _classify_articles_parallel(articles, client, topics, workers=JEV_WORKERS):
|
|
|
284
294
|
|
|
285
295
|
started = time.time()
|
|
286
296
|
decisions = {}
|
|
297
|
+
# 同一条失败折叠成一次输出(见 `_classify_with_jev`):一天上百篇同一个原因失败时,
|
|
298
|
+
# 逐篇刷同一行等于没说;但没见过的异常/消息仍然照打。
|
|
299
|
+
noise = {'seen': set(), 'repeat': 0}
|
|
287
300
|
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
288
301
|
futures = {
|
|
289
|
-
pool.submit(_classify_with_jev, client, title, body, topics): index
|
|
302
|
+
pool.submit(_classify_with_jev, client, title, body, topics, noise): index
|
|
290
303
|
for index, title, body in todo
|
|
291
304
|
}
|
|
292
305
|
for future in as_completed(futures):
|
|
@@ -296,6 +309,8 @@ def _classify_articles_parallel(articles, client, topics, workers=JEV_WORKERS):
|
|
|
296
309
|
# 先报个数:并发之后逐篇日志没有意义了,但这三件事必须看得见。
|
|
297
310
|
print(f' 分类完成 {ok}/{len(todo)} 篇,耗时 {time.time() - started:.1f}s'
|
|
298
311
|
f'({workers} 并发;串行约需 {len(todo) * 0.97:.0f}s)')
|
|
312
|
+
if noise['repeat']:
|
|
313
|
+
print(f' [WARN] 另有 {noise["repeat"]} 篇因**同一条**失败被折叠(原因已在上面打过一次)')
|
|
299
314
|
return decisions
|
|
300
315
|
|
|
301
316
|
|
|
@@ -473,13 +488,13 @@ def get_db_keys(config):
|
|
|
473
488
|
contact_salt = config.get('contactSalt', '')
|
|
474
489
|
contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
|
|
475
490
|
|
|
476
|
-
# biz_message_0.db(订阅号库):钥匙派生统一在 `_utils.biz_message_db()`。
|
|
477
|
-
# 这块原来自己算了两遍(先按 bizKey/bizSalt,又被下面按 passphrase 派生的一遍覆盖),
|
|
478
|
-
# 两遍并存就是分叉留下的痕迹 —— 见那个函数的注释。
|
|
479
|
-
from _utils import biz_message_db
|
|
480
|
-
try:
|
|
481
|
-
biz_db, biz_key, biz_salt = biz_message_db(config)
|
|
482
|
-
except RuntimeError as exc:
|
|
491
|
+
# biz_message_0.db(订阅号库):钥匙派生统一在 `_utils.biz_message_db()`。
|
|
492
|
+
# 这块原来自己算了两遍(先按 bizKey/bizSalt,又被下面按 passphrase 派生的一遍覆盖),
|
|
493
|
+
# 两遍并存就是分叉留下的痕迹 —— 见那个函数的注释。
|
|
494
|
+
from _utils import biz_message_db
|
|
495
|
+
try:
|
|
496
|
+
biz_db, biz_key, biz_salt = biz_message_db(config)
|
|
497
|
+
except RuntimeError as exc:
|
|
483
498
|
raise SystemExit(str(exc))
|
|
484
499
|
|
|
485
500
|
return {
|
package/scripts/chat_notes.py
CHANGED
|
@@ -42,8 +42,8 @@ from datetime import datetime, timedelta, timezone
|
|
|
42
42
|
from pathlib import Path
|
|
43
43
|
|
|
44
44
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
45
|
-
from _utils import (CHAT_CARD_PREFIX,
|
|
46
|
-
get_api_key, load_config,
|
|
45
|
+
from _utils import (CHAT_CARD_PREFIX, LinkNamer, SEPARATOR, # noqa: E402
|
|
46
|
+
call_deepseek, decrypt_lock, get_api_key, line_concepts_path, load_config,
|
|
47
47
|
note_path as _shared_note_path, plain, write_with_frontmatter)
|
|
48
48
|
from reply_debt import collect_conversations, format_line # noqa: E402
|
|
49
49
|
|
|
@@ -179,12 +179,15 @@ def parse_note(raw):
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
|
|
182
|
-
def render_note(note, name, days, updated, messages):
|
|
182
|
+
def render_note(note, name, days, updated, messages, namer=None):
|
|
183
183
|
"""卡片 → (frontmatter, body)。
|
|
184
184
|
|
|
185
185
|
frontmatter 的键是**消费者定的**:`compile_wiki.scan_articles` 读 `title`/`source`/`topic`/`tags`。
|
|
186
186
|
body 的 `## AI 摘要` 标题也是它定的(`_extract_summary` 就找这一段)。
|
|
187
187
|
"""
|
|
188
|
+
# `namer` 由调用方按**库根**建好(`LinkNamer.for_out_dir(<库>/Chat/Concepts)`):
|
|
189
|
+
# 这个脚本只管卡片,而"另一条线有哪些名字"要问库 —— 不传就按裸名字渲染(老行为)。
|
|
190
|
+
namer = namer or (lambda x: x)
|
|
188
191
|
frontmatter = {
|
|
189
192
|
'title': f'{name}',
|
|
190
193
|
'type': 'chat-card',
|
|
@@ -212,7 +215,9 @@ def render_note(note, name, days, updated, messages):
|
|
|
212
215
|
continue
|
|
213
216
|
parts.append('### %s\n' % label)
|
|
214
217
|
for item in note[key]:
|
|
215
|
-
|
|
218
|
+
# 跨线同名要带路径(`namer`,见 `_utils.LinkNamer`):卡片里这个链接指的是
|
|
219
|
+
# **聊天线**那张概念页,而两条线同名时裸 `[[名字]]` 在 Obsidian 里是二义的。
|
|
220
|
+
parts.append('- [[%s]] %s %s' % (namer(item['name']), SEPARATOR, item['desc']))
|
|
216
221
|
parts.append('')
|
|
217
222
|
if note['owed']:
|
|
218
223
|
parts.append('## 欠着什么\n')
|
|
@@ -501,6 +506,10 @@ def main():
|
|
|
501
506
|
return 1
|
|
502
507
|
|
|
503
508
|
updated = datetime.now(TZ).strftime('%Y-%m-%d %H:%M')
|
|
509
|
+
# 卡片里指向概念的链接要跨线消歧(两条线同名时带路径)。按**库根**建一次:
|
|
510
|
+
# 认不出线(自定义 `--vault-root`)时它回一个恒等函数 ⇒ 渲染成裸名字(老行为)。
|
|
511
|
+
chat_concepts = line_concepts_path('chat') or 'Chat/Concepts'
|
|
512
|
+
namer = LinkNamer.for_out_dir(Path(args.vault_root).joinpath(*chat_concepts.split('/')))
|
|
504
513
|
written, failed = [], []
|
|
505
514
|
for card in cards:
|
|
506
515
|
prompt = build_note_prompt(card['name'], card['lines'], args.days)
|
|
@@ -521,7 +530,8 @@ def main():
|
|
|
521
530
|
continue
|
|
522
531
|
path = note_path(args.out, card['name'])
|
|
523
532
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
524
|
-
frontmatter, body = render_note(note, card['name'], args.days, updated, card['messages']
|
|
533
|
+
frontmatter, body = render_note(note, card['name'], args.days, updated, card['messages'],
|
|
534
|
+
namer=namer)
|
|
525
535
|
write_with_frontmatter(str(path), frontmatter, body)
|
|
526
536
|
written.append({'name': card['name'], 'file': str(path),
|
|
527
537
|
'topics': len(note['topics']), 'people': len(note['people'])})
|
|
@@ -0,0 +1,426 @@
|
|
|
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())
|