CGL SCRM / quality
工程纪律
11 道闸门 + 169 条守护
这一页讲「怎么保证东西不坏」
每次改完代码,都要过 11 道自动检查;平时还有 169 条「防回归」的看守点。
这不是流程文档,是一条命令:跑 bash scripts/verify.sh,
下面这些全部自动跑一遍,任何一道不过就红。
所以「改好了」这件事不靠人记,靠机器判。
11
自动检查关卡
一条命令全跑完
169
防回归检查
26 个文件
199
被挨个打开过的页面
不是抽查,是全量
56
被挨个真做过的提交
做完回滚
顺序不是随便排的
下面按执行顺序列出。注意第一道和最后一道——它们都会真的动数据。
1
把开发库清空重来
会动数据
不是检查,是准备工作。它会清库——所以它排在第一道。
2
能不能正常导入
把所有模块挨个导入一遍。挡的是「语法没问题但一启动就崩」这类。
3
页面模板能不能编译
把所有模板编译一遍,也检查模板里用的过滤器存不存在。
4
每个网页挨个打开一遍
每一个「打开页面」类的地址都真发一次请求,看有没有 500。
5
有没有写了却永远用不到的地址
两条地址规则打架时,先注册的会把后注册的挡死——这种「死地址」很难自己发现。
6
代码风格与常见错误
静态检查,不运行程序。
7
功能是否还正常
最主要的一道:接口、AI 工具、数据模型、命令行,全跑一遍。
8
代码里的表结构 与 数据库里的表结构 是否一致
两边不一致会导致「本机能跑、线上报错」——本项目踩过八次。
9
自动生成的文档是否过期
代码改了、文档没跟着改,是最容易被忽略的一类过期。
10
乱填参数会不会把系统弄崩
对着每个地址灌各种畸形参数(超长、负数、乱码),看有没有 500。
11
每个提交动作挨个真做一遍
会动数据
会真的写库,所以排在最后;做完再按快照把数据回滚、逐行复核。
第一道是「把库清空重来」,最后一道是「真的写一遍再回滚」。
所以这两道只在本机跑,绝不对着线上跑。
线上只跑那些只读的(打开页面、乱填参数),这一点是明确规定的。
为什么把「清空重来」排在最前:
如果后面的检查依赖库里已有的数据,那结果就会随「你上次跑到哪一步」而变——
同一份代码跑出两种结果,检查就失去了意义。
先清空、再按固定脚本灌数据,每次起点都一样。
代价是:跑完之后需要重新把 8 个导入器跑一遍,才能回到「复原态」。
一条判断检查是否可靠的规矩
「守 X」这条检查,必须先能证明它「会变红」——做不到,就等于没有这条检查。
这话听着像废话,但本项目栽过两次:
这话听着像废话,但本项目栽过两次:
- 有一次故意改坏代码,测试却照样显示「通过」——原因是用来改坏的脚本自己语法错了,根本没改成功。 「改坏没生效」和「检查不灵」在屏幕上长得一模一样。
- 还有一次,一条检查一直是红的(因为库里没有它要找的那条数据), 久而久之所有人都当它是正常的——一个一直红的检查,等于不存在。
全部 169 条看守点
按文件分组。每条后面的说明是它在防什么。
tests/test_api_body_types.py 4 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_bad_body_type_is_4xx_not_500 | 87 | 请求体里类型错的整数字段 → 4xx(422/400),绝不能 500。 |
| test_token_create_bad_rate_limit_is_4xx_not_500 | 106 | /api/v1/tokens 的 rate_limit_per_min —— 同一个函数里已有正确写法。 |
| test_h5_signup_bad_aid_is_4xx_not_500 | 147 | 公开报名页提交口:aid=abc → 4xx,不是 500。 |
| test_h5_survey_bad_qid_is_4xx_not_500 | 162 | 公开问卷提交口:qid=abc → 4xx,不是 500。同 a_event_signup。 |
tests/test_api_contract.py 23 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_export_csv_not_shadowed_by_uid_route | 46 | /leads/export.csv 必须命中它自己,不能被 /leads/{uid} 抢走。 |
| test_duplicates_not_shadowed_by_uid_route | 60 | /leads/duplicates 同上 —— 它与 export.csv 是同一个遮蔽的两个受害者。 |
| test_non_numeric_uid_is_404_not_422 | 67 | /leads/abc 必须是 404(没有这条路由),不能是 422。 |
| test_content_negotiation_returns_csv_file | 90 | 带 Accept: text/csv → 真 CSV 文件(带下载文件名头)。 |
| test_content_negotiation_defaults_to_json_envelope | 107 | 不带 Accept(或 */*)→ JSON 信封。 |
| test_lead_detail_survives_and_remarks_use_create_time | 139 | 线索详情不能 500;有备注时每条备注必须带 at 字段。 |
| test_event_detail_field_type_is_string_not_int | 163 | 带报名题的活动详情不能 500,且题型的 type 是字符串。 |
| test_no_key_is_401 | 199 | 无 Key → 401(而不是 200 + 空数据,那会让人以为"系统没数据")。 |
| test_bad_key_is_401 | 206 | 错 Key → 401。不要退化成 200。 |
| test_bearer_header_is_equivalent | 212 | Authorization: Bearer <key> 与 X-API-Key 等价(文档承诺过)。 |
| test_write_scope_is_required | 219 | 只读 Key 写线索 → 403,且错误信息里点名缺哪个 scope。 |
| test_pii_masked_by_default_and_plain_with_scope | 238 | 手机号默认脱敏;带 lead:read_pii 才是明文。 |
| test_health_needs_no_auth | 270 | /api/v1/health 是公开的 —— 探针/监控要能拿到它。 |
| test_documented_paths_are_registered | 294 | api.md 里写过的每一条路径,实现里都必须真的注册了。 |
| test_absurd_pageindex_is_422_not_500 | 320 | pageindex 超出 int64 必须是 422,不能是 500。 |
| test_pageindex_bound_does_not_reject_real_pages | 344 | 上界不能误伤真实页码 —— 边界值本身必须能过。 |
| test_absurd_uid_is_422_not_500 | 362 | /leads/{uid} 的 uid 超出 int64 也必须是 422,不能是 500。 |
| test_normal_missing_uid_is_still_404 | 380 | 对照:合法范围内但不存在的 uid 仍然是 404。 |
| test_unhandled_error_response_carries_a_real_request_id | 430 | 未捕获异常的 500 响应,request_id 必须非空,且与响应头一致。 |
| test_request_id_is_echoed_when_caller_supplies_one | 457 | 调用方带了 X-Request-Id 时必须原样回显 —— 成功与失败路径都要。 |
| test_unhandled_error_response_also_carries_security_headers | 476 | 500 响应也必须有基础安全头 —— 与上面同一个根因的另一半。 |
| test_hsts_branch_is_actually_reachable | 500 | HSTS 只在 prod 开 —— 但必须有一条测试真的把那条分支跑一次。 |
| test_successful_response_also_carries_request_id_header | 532 | 正常响应也要带 X-Request-Id —— 否则「这次到底成功没成功」 在用户侧无法与服务端日志对照。 |
tests/test_attribution_resolve.py 4 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_resolve_by_legacy_consultant_no | 59 | 守:老二维码(已印物料)必须继续解析成原来的那个顾问。 |
| test_resolve_by_primary_key_when_account_id_missing | 69 | 守事故本体:后台生成的码带的是 manager_user.id,不能解析不出顾问。 |
| test_zero_and_none_must_not_resolve | 80 | 守:account_id 的默认值 0 不是合法顾问号,不能误配到某个账号上。 |
| test_unknown_id_resolves_to_none | 87 | 守:不存在的号必须给 None,而不是抛异常或撞到别的账号。 |
tests/test_audit_status_contract.py 7 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_normalize_audit_covers_every_historical_representation | 78 | — |
| test_audit_int_is_total_for_every_contract_value_and_junk | 82 | API 契约是 int —— 任何输入都必须能无损转过去,不许 ValueError。 |
| test_audit_labels_keys_match_the_contract | 92 | 标签字典的键必须就是合同值 —— 否则 .get() 全部落到默认值,看着正常其实错。 |
| test_stored_audit_status_values_are_contract_strings | 102 | 直接读库:值必须是 str 且在 CONTRACT 内。 |
| test_signup_filter_accepts_int_and_str_alike | 118 | signups_of(audit_status=…) 收 int 也收 str,且两者结果一致。 |
| test_event_detail_renders_approved_badge_not_rejected | 189 | 报名名单页:已通过的报名必须渲染成「已通过」,不许掉进「已拒绝」。 |
| test_interactive_list_audit_column_is_not_blank | 223 | 互动问答页:状态列必须真的渲染出徽标(曾经因为比整数而整列空白)。 |
tests/test_code_rev.py 10 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_rev_is_a_stable_short_hex_string | 82 | 指纹是定长十六进制,且同一进程里重复调用值不变(缓存有效)。 |
| test_rev_changes_when_content_changes | 90 | 改一个字节 ⇒ 指纹必须变。这是它作为验收工具的前提。 |
| test_rev_changes_when_a_file_is_renamed | 100 | 把同一个文件改名 ⇒ 指纹也要变。 |
| test_rev_ignores_pycache | 115 | __pycache__ 必须被排除。 |
| test_rev_changes_when_a_template_changes | 134 | 改模板 ⇒ 指纹必须变。 |
| test_rev_changes_when_a_static_asset_changes | 157 | 改 static/ 下的 js / css ⇒ 指纹必须变。 |
| test_rev_covers_new_files_not_just_known_suffixes | 171 | 新出现一个此前不存在的文件(任意后缀)也要参与指纹。 |
| test_rev_ignores_editor_and_os_metadata | 192 | 点开头的编辑器 / 系统垃圾文件不参与指纹。 |
| test_rev_does_not_depend_on_directory_iteration_order | 212 | 打乱 rglob 的产出顺序 ⇒ 指纹必须不变。 |
| test_both_probes_expose_the_rev | 256 | /healthz 与 /readyz 都要带 rev,且等于 code_rev()。 |
tests/test_constants_single_source.py 7 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_scene_layout_tuple_describes_what_the_parser_really_reads | 70 | scene_str 的 12 段布局:名字与下标只许有一份。 |
| test_pagination_limits_live_in_settings_only | 95 | 分页上界的真源 = settings(可被环境变量覆写),不是 constants。 |
| test_wechat_callback_router_owns_exactly_two_paths | 109 | 微信回调的两条路径是切换期契约(公众号后台里配的就是它们)。 |
| test_mcp_tools_list_is_permission_filtered_not_a_static_list | 136 | tools/list 必须按 key 权限裁剪,而不是列一张静态表。 |
| test_qr_action_names_are_written_exactly_once | 155 | 二维码 action 名的字面量只许出现在 constants.py。 |
| test_every_db_writing_script_carries_a_library_guard | 208 | scripts/ 下会写库的脚本必须接上库闸门(scripts._dev_env)。 |
| test_the_guard_exemption_list_does_not_rot | 239 | 豁免清单里的文件必须真的存在。 |
tests/test_engine_json_serializer.py 3 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_httpgw_branch_engine_gets_json_serializer | 46 | 守:postgresql+httpgw:// 分支造出来的引擎必须带 json_serializer。 |
| test_sqlite_branch_engine_gets_json_serializer | 70 | 守:SQLite(dev / 闸门)分支同样要带 —— 两条路都不许漂。 |
| test_gateway_literal_handles_nested_decimal | 83 | 守:网关通道独有的参数内联路径也要认 Decimal / datetime。 |
tests/test_event_capacity.py 4 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_dev_seed_does_not_fabricate_capacity | 39 | dev_seed.py 里不许出现 capacity=。 |
| test_no_event_carries_a_capacity | 57 | 库里不许有 capacity > 0 的活动(真实导入的活动该列恒为 0 = 不限名额)。 |
| test_signup_and_reject_reason_agree | 84 | signup() 抛的原因必须逐字来自 reject_reason()(单一真源)。 |
| test_reject_reason_accepts_a_signupable_event | 103 | 正例:状态开放 + 未过截止 + 未满员 ⇒ reject_reason() 必须返回 None。 |
tests/test_event_signup_field_render.py 3 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_no_required_select_without_options | 144 | 🔴 核心:页面上不许存在「必填 + 零个可选值」的下拉。 |
| test_empty_option_field_degrades_to_hint | 157 | 同一条件下,三个题型必须给同一种兜底(否则就是「只漏一处」)。 |
| test_fields_with_options_still_render | 173 | 正向对照:有选项的字段必须照常渲染 —— 否则「全删掉」也能让上面全绿。 |
tests/test_export_endpoints.py 4 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_manager_export_csv_is_served_and_has_a_customer_id | 85 | 后台「导出 CSV」必须 200,且第一列有值。 |
| test_manager_export_xlsx_is_a_real_workbook | 104 | 后台「导出 Excel」必须 200,并且真能被 Excel 打开。 |
| test_api_export_csv_is_served | 126 | API / MCP / skill CLI 走的是这条(cgl_leads_export_csv 也指它)。 |
| test_the_route_this_file_covers_still_exists | 137 | 路由改名时这条要红,而不是让覆盖静默消失。 |
tests/test_export_roundtrip.py 5 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_snapshot_is_the_real_thing | 104 | 先证明「基准本身长得对」,否则后面所有比对都是在跟一个坏基准比。 |
| test_export_roundtrip_is_byte_identical_to_source_snapshot | 122 | CSV → Lead → CSV 往返恒等。本文件的主断言。 |
| test_every_exportable_field_matches_source_column_shape | 152 | 逐列核对「输出形态」—— 32 个字段一个不落。 |
| test_export_rows_never_loses_the_customer_id | 196 | 「客户ID」列永不为空 —— 不依赖快照文件的一条兜底。 |
| test_export_rows_does_not_reimplement_the_formatting_rules | 210 | 结构断言:格式规则只有一处(constants.format_export_value)。 |
tests/test_facility_hermetic.py 5 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_storage_backend_is_local_not_shared_bucket | 49 | 守 2026-10-12 隐患:装置没钉 STORAGE_BACKEND → 用例写生产 COS 桶。 |
| test_wechat_forward_is_off | 62 | 守:测试环境误把原始事件透传给 iSupport 生产端(灰度双跑开关)。 |
| test_ai_scoring_is_off | 70 | 守:测试环境真调 DeepSeek(花钱,且结果不可复现 ⇒ 红绿都不能当判据)。 |
| test_database_is_local_dev_sqlite | 78 | 守:测试打到生产库(本项目已有一次「拿生产库当验证手段」的取舍记录)。 |
| test_all_proxy_env_vars_are_cleared | 87 | 守:本机被注入的死代理端口把本地回环请求也拖出去(曾致「服务起不来」)。 |
tests/test_h5_public_submit.py 2 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_h5_signup_happy_path_actually_persists | 129 | 公开报名口:合法表单 → 3xx 跳 signup_done,且库里真的多了一行报名。 |
| test_h5_survey_submit_happy_path | 168 | 公开答卷口:合法表单 → 3xx 跳 done(并带上 score)。 |
tests/test_import_graph.py 2 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_every_referenced_app_module_exists_on_disk | 123 | 守的事故:app.xxx 被 import 了,但那个模块不存在。 |
| test_the_detector_itself_can_actually_see_nested_imports | 141 | 自测:证明上面那位真的会遍历函数体,而不是只扫顶层节点。 |
tests/test_import_tag_rel.py 6 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_frozen_basis_is_closed | 46 | 每个标签抓到的行数逐条等于声明;总数等于 items 长度。 |
| test_mobile_is_a_usable_join_key | 58 | 「手机」是唯一的配对键 ⇒ 它不能有空、也不能在同一个标签下重复。 |
| test_content_tags_are_excluded_not_fabricated | 68 | 那 3 个 content 标签只能进 excluded,不许在 items 里 —— 不编造。 |
| test_frozen_basis_carries_a_stable_header_shape | 87 | 表头形态被记下来了 —— 按名取列的前提是知道见过哪些表头。 |
| test_load_baseline_refuses_a_basis_with_problems | 96 | 能变红:自带 problems 的基准必须被拒(fail-closed,不许静默接着写库)。 |
| test_load_baseline_refuses_empty_and_missing | 108 | 另外两条 fail-closed 路径也要真的能红。 |
tests/test_lead_filter_controls.py 8 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_source_fixture_is_a_usable_basis | 177 | 留证必须真的解析出 15 个下拉 —— 否则后面所有断言都在测空气。 |
| test_select_field_set_matches_source_exactly | 188 | SEARCH_SELECT_FIELDS 必须恰好等于源站的下拉集合。 |
| test_static_options_match_source_verbatim | 203 | 逐条比对:选项的值与文案都要一样(照抄不许改写、不许补全)。 |
| test_source_selects_are_rendered_as_selects | 226 | 🔴 这条就是事故本身:14 个下拉被压成了文本框。 |
| test_rendered_selects_actually_have_options | 242 | 防「假修」:换成 <select> 但一个 <option> 都不给,也能骗过上下文那条。 |
| test_source_text_fields_are_not_upgraded_to_selects | 262 | 反向对照:防止「把所有字段都改成下拉」这种把功能删光的修复。 |
| test_chosen_value_is_echoed_back_as_selected | 275 | 下拉的功能:带上 ?s_meeting=1 之后,那个 option 必须被标成 selected。 |
| test_filter_actually_narrows_the_result_set | 289 | 下拉不只要长对,还要真的过滤:选「是」必须比不选少。 |
tests/test_manager_auth_gate.py 2 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_every_manager_route_is_behind_the_auth_gate | 125 | 守的事故:/manager/N_Event/pack_download 与 beautify_qr_code 曾经匿名可访问。 |
| test_anonymous_get_to_manager_routes_is_never_served | 188 | 运行时兜底:匿名 GET /manager/** 绝不能拿到 2xx。 |
tests/test_mcp_contract.py 23 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_no_key_is_401 | 56 | MCP 与 REST 共用同一套鉴权 —— 没有 Key 必须 401,不能"匿名只读"。 |
| test_bad_key_is_401 | 61 | — |
| test_initialize_handshake | 69 | 握手要回协议版本、工具能力声明、服务标识,以及一段 instructions。 |
| test_unknown_protocol_version_falls_back | 81 | 客户端报一个我们不认识的协议版本 → 回落到我们支持的版本,不是 400。 |
| test_notification_returns_202_with_empty_body | 104 | 通知(没有 id)必须回 202 + 空体。 |
| test_tools_list_trimmed_by_scope | 121 | tools/list 必须按 Key 权限裁剪,没权限的工具根本不出现。 |
| test_tools_call_happy_path | 149 | — |
| test_business_404_is_translated_for_the_model | 167 | 业务 404 要变成 isError:true + http_status + 可执行的 hint。 |
| test_missing_required_argument_is_rejected | 191 | 缺必填参数 → 明确的错误,而不是把 None 透下去打成 500。 |
| test_pii_is_masked_through_mcp | 204 | 脱敏在 MCP 通道同样生效 —— 两条通道共用一份实现,不能有一边漏。 |
| test_unknown_method_is_jsonrpc_error | 227 | — |
| test_malformed_json_is_parse_error | 234 | 非法 JSON → -32700,不能 500(500 会让客户端以为服务端崩了)。 |
| test_batch_request | 245 | JSON-RPC 批量:一次 POST 多条消息,逐条回结果。 |
| test_get_is_405_with_allow_post | 260 | GET /mcp → 405 且带 Allow: POST。 |
| test_status_legend_is_rendered_from_the_single_source | 318 | ① 三条带图例的工具,其图例逐字等于由 LEADS_STATUS_LABELS 渲染的结果。 |
| test_no_tool_description_invents_a_status_label | 331 | ② 工具标题/描述里出现的 N=文案 必须与真源一致。 |
| test_the_zero_evidence_label_words_stay_deleted | 353 | ③ 两个零证据的词不许回来。 |
| test_handbook_lists_exactly_the_public_tools | 386 | ④ 手册里的工具清单必须与 _TOOLS_SPEC 逐名相同(双向)。 |
| test_no_zero_evidence_funnel_wording_in_outward_files | 404 | ⑤ 那个零证据的统计口径(把快照讲成逐级递进)不许回到对外文件里。 |
| test_handbook_version_is_the_same_on_cover_and_footer | 442 | ⑥ 手册封面与页脚的版本号必须一致(一份对外文档不许自相矛盾)。 |
| test_outward_files_do_not_point_at_the_unregistered_default_domain | 472 | ⑦ 交付物里不许再出现 CloudBase 默认域名的完整 URL。 |
| test_the_legacy_url_exemption_still_deserves_to_exist | 510 | ⑦b 豁免清单不许烂:文件要在,且真的还含旧地址。 |
| test_every_tool_count_claim_matches_the_source_of_truth | 528 | ⑧ 对外文件里凡是写「N 个工具」,N 必须等于 _TOOLS_SPEC 的条数。 |
tests/test_migrate_seed_guards.py 4 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_pure_seed_db_trips_every_guard | 76 | ① 每一条守卫都必须真的会红 —— 不能有哑的。 |
| test_clean_db_passes_quietly | 88 | ② 干净数据必须 0 命中 —— 假红会让人把守卫关掉。 |
| test_renamed_column_is_loud_not_silent | 96 | ③ 列改名必须吵。静默 continue 会把『守卫没了』伪装成『守卫过了』。 |
| test_guard_predicates_reference_existing_columns | 116 | ④ 谓词里的列名必须在模型里存在(不允许只有数据库里存在)。 |
tests/test_param_bounds.py 3 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_self_check_bounds_reader_can_see_a_real_constraint | 105 | 自测:证明 _bounds 真的能读出约束。 |
| test_id_like_params_declare_an_upper_bound | 126 | 守的事故:裸 int 的 id 参数让超界值直通 DB → OverflowError → 500。 |
| test_arithmetic_params_declare_both_bounds | 152 | 守的事故:会进算术的参数只设了下界。 |
tests/test_post_write_paths.py 7 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_merge_writes_reason_and_jsonable_snapshot | 124 | 守:POST /manager/register/merge 与 POST /api/v1/leads/merge 必须真的能合并。 |
| test_merge_is_callable_from_both_entry_points | 165 | 守:网页 merge 路由的调用形状(不传 reason,吃默认值)也要能跑。 |
| test_rescore_reads_rule_score_column | 184 | 守:POST /manager/register/rescore(线索详情页「重算评分」)必须能用。 |
| test_api_survey_answer_sets_submitted_at | 239 | 守:POST /api/v1/surveys/{qid}/answers 必须能提交答卷。 |
| test_api_checkin_already_checked_in_is_422_not_500 | 293 | 守:「重复签到」是客户端状态错误,必须 4xx,不能是 500。 |
| test_api_lead_merge_returns_ok | 344 | 守:POST /api/v1/leads/merge —— 与网页版同一根因的第二条入口。 |
| test_replace_options_twice_does_not_explode | 369 | 守:POST /manager/Surveyqst/set_options(对话式「选项调节」)必须能连调两次。 |
tests/test_role_scopes.py 7 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_shared_roles_have_identical_scopes_in_both_tables | 89 | 两份表共有的角色,scope 集合必须逐字一致。 |
| test_the_two_tables_differ_by_exactly_the_cli_only_admin_role | 113 | 两份表的差集必须恰好是 {"admin"},且方向固定。 |
| test_only_the_cli_only_role_may_carry_token_admin | 140 | token:admin 只能出现在那个 CLI 专属角色上。 |
| test_token_admin_scope_has_no_api_key_consumer | 166 | token:admin 在 API Key 路径上确实没有消费者 —— 把这件事钉成事实。 |
| test_every_scope_a_tool_requires_is_grantable_by_some_role | 228 | MCP 工具要求的每个 scope,都必须有某个角色能提供。 |
| test_role_scopes_only_use_declared_scopes | 250 | 两份表里出现的 scope 名,都必须在 ALL_SCOPES 里声明过。 |
| test_create_token_docstring_lists_exactly_the_api_roles | 267 | POST /api/v1/tokens 的 docstring 里列出的 role,必须恰好是 _ROLE_SCOPES。 |
tests/test_route_contract.py 2 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_path_annotated_params_really_are_in_the_route_path | 48 | 凡被声明为路径参数(含 RowId)的名字,必须出现在路由模板里。 |
| test_every_route_placeholder_is_a_declared_param | 76 | 反向:路由模板里的每个 {xxx} 都必须有对应的参数声明。 |
tests/test_schema_contract.py 8 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_timestamp_mixin_models_do_not_also_have_created_at | 35 | 用了 TimestampMixin 的模型,不允许再额外挂 created_at。 |
| test_timestamp_mixin_contract | 79 | 用了 TimestampMixin 的模型,必须同时有 create_time 与 update_time。 |
| test_class_level_time_references_resolve_to_real_columns | 99 | 源码里所有 Model.某一列 形式的类级引用,都必须真的是那个模型的列。 |
| test_event_field_ltype_is_string | 144 | EventField.ltype 必须是字符串列(text/select/radio…)。 |
| test_no_code_converts_ltype_to_int | 163 | 源码里不允许对 ltype 做 int() 转换。 |
| test_api_token_keeps_owner_and_issuer_apart | 181 | api_token 必须分开存 owner_id(这张 key 代表谁)与 issued_by(谁发的)。 |
| test_every_model_has_a_primary_key | 202 | 每张表都要有主键。没有主键的表在 SQLAlchemy 里根本不能 ORM 操作, 但错误信息会出现在很远的地方(第一次查询时)。这里提前拦掉。 |
| test_table_names_are_snake_case | 211 | 表名统一 snake_case(小写 + 下划线)。 |
tests/test_skill_cli.py 13 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_unwrap_csv_strips_bom_from_bare_csv | 49 | 守 2026-10-11 的「双 BOM」事故。 |
| test_unwrap_csv_strips_bom_from_json_envelope_too | 62 | JSON 信封的两处 BOM(信封外层 + csv 字段)都要剥掉。 |
| test_unwrap_csv_never_returns_two_leading_boms | 71 | 汇总断言:无论输入哪种形态,返回值都不以 BOM 开头。 |
| test_unwrap_csv_keeps_json_envelope_when_it_has_no_csv_field | 89 | 没有 csv 字段的信封原样返回,不要把它当成 CSV。 |
| test_unwrap_csv_tolerates_none | 100 | None 不能炸 —— 上游 request(raw=True) 理论上永远返回 str, 但这里多一层防御的成本是零,而 CLI 崩一次的代价是同事对系统失去信任。 |
| test_every_group_documented_in_skill_md_exists | 117 | SKILL.md 里出现的每个命令组都必须真的注册了。 |
| test_documented_sub_actions_exist | 133 | SKILL.md 里 leads <动作> / events <动作> 的子动作也必须存在。 |
| test_default_base_url_is_absolute_http | 160 | 默认 base 必须是绝对 URL。 |
| test_export_json_mode_returns_full_data_not_a_truncated_preview | 187 | --json leads export 必须吐完整 JSON —— 不是一张截断的表格。 |
| test_export_preview_is_still_a_preview | 212 | 不带 --json 时,仍然是「表格预览 + 共 N 行」—— 别把预览也改没了。 |
| test_export_out_still_writes_file_and_reports_rows | 231 | --out 的副作用不能被 --json 改掉:该写文件还是写文件。 |
| test_whoami_json_mode_emits_json | 254 | --json whoami 也必须是 JSON(文件头承诺的是「任何命令」)。 |
| test_whoami_human_output_unchanged | 282 | 对照:whoami 不带 --json 时,人读输出必须逐字节不变。 |
tests/test_survey_form_field_render.py 3 条
| 检查 | 行 | 在防什么 |
|---|---|---|
| test_no_required_select_without_options | 176 | 🔴 核心:问卷页上不许存在「必填 + 零个可选值」的下拉。 |
| test_empty_option_questions_degrade_to_hint | 193 | 同一条件下,所有渲染选项的题型必须给同一种兜底。 |
| test_questions_with_options_still_render | 213 | 正向对照:有选项的题必须照常渲染 —— 否则「删光控件」也能让上面全绿。 |
几条被反复用到的经验
下面每一条都不是「最佳实践」这种空话,而是这套系统里真出过一次事故之后留下的。
| 教训 | 它是怎么被发现的 |
|---|---|
| 守「自己这一侧」不够,要守「交付物那一侧」 | 前八次「同一事实写两遍」的守护都在仓库内部,而这一份错的已经发出去了(手册 PDF)。 |
| 一条能变红的扳机 > N 条各管一段的清单 | 做不到「能变红」的守护,等于不存在。 |
| 突变没变红,先怀疑突变本身 | 一次 heredoc 语法错让突变根本没注入,而测试照样「1 passed」—— 标准的假绿。 |
| 自查用的正则也算「守卫」,它一样会覆盖面不足 | 本轮用粗正则找库闸门,误报了一个其实有闸门的脚本。 |
| 基准钉在「外部权威」上 | 判断「该用哪个域名」不看哪份文档看起来权威,去看系统自己 self-report 的值。 |
| 会给未来留下漂移源的事实,最好的处置是别说 | 邮件里「10 页」实际一直 11 页 ⇒ 改成不写页数;而必须在多处写的事实就要立守护(「N 个工具」)。 |
| 「原站没有」与「我们没导」在计数上长得一模一样 | 都是 0。写「缺什么」之前,先回源站找那个字段。 |
| 不编造 | 原站没有的字段就不填。猜错的台账比空着的台账更坏。 |
现在各项检查的读数
这些数字会随着改动变化。要紧的不是具体多少,而是它和前一次比有没有动、动的地方能不能解释。
| 检查项 | 看什么 | 怎么判「正常」 |
|---|---|---|
| ruff | 代码风格 | 必须是 0 条 |
| pytest | 功能测试 | 全通过。有跳过的那条要看它跳过的理由——本项目有一条是「库里没有符合条件的数据」,属于读数依赖库状态 |
| smoke_routes | 页面能不能打开 | 0 个失败。加新页面会让总数系统性变大,所以看的是「差值能不能解释」 |
| fuzz_get_params | 乱填参数 | 0 个服务器错误(500) |
| smoke_posts | 提交动作 | 全绿,且收尾时 69 张表逐行一致——真的回滚干净了 |
| check_schema_drift | 表结构一致性 | 无漂移 |
为什么总盯着「跟记录的数字比」:
有一次一个测试突然变红,查了半天代码没动过——最后发现是数据变了。
所以「读数跟记录对不上」本身就是一个很有价值的信号,
前提是记录里的数字是干净的——这也是为什么不允许测试往共享库里乱写。