CGL SCRM  /  quality

工程纪律

11 道闸门 + 169 条守护

这一页讲「怎么保证东西不坏」

每次改完代码,都要过 11 道自动检查;平时还有 169 条「防回归」的看守点。

这不是流程文档,是一条命令:跑 bash scripts/verify.sh, 下面这些全部自动跑一遍,任何一道不过就红。 所以「改好了」这件事不靠人记,靠机器判。

11
自动检查关卡
一条命令全跑完
169
防回归检查
26 个文件
199
被挨个打开过的页面
不是抽查,是全量
56
被挨个真做过的提交
做完回滚

顺序不是随便排的

下面按执行顺序列出。注意第一道和最后一道——它们都会真的动数据。

1
把开发库清空重来 会动数据
不是检查,是准备工作。它会清库——所以它排在第一道。
"$PY" scripts/dev_seed.py --reset
2
能不能正常导入
把所有模块挨个导入一遍。挡的是「语法没问题但一启动就崩」这类。
"$PY" scripts/import_audit.py
3
页面模板能不能编译
把所有模板编译一遍,也检查模板里用的过滤器存不存在。
"$PY" scripts/template_lint.py
4
每个网页挨个打开一遍
每一个「打开页面」类的地址都真发一次请求,看有没有 500。
"$PY" scripts/smoke_routes.py
5
有没有写了却永远用不到的地址
两条地址规则打架时,先注册的会把后注册的挡死——这种「死地址」很难自己发现。
"$PY" scripts/route_shadow_audit.py
6
代码风格与常见错误
静态检查,不运行程序。
"$PY" -m ruff check app/ scripts/ tests/ skill/
7
功能是否还正常
最主要的一道:接口、AI 工具、数据模型、命令行,全跑一遍。
"$PY" -m pytest tests/ -q
8
代码里的表结构 与 数据库里的表结构 是否一致
两边不一致会导致「本机能跑、线上报错」——本项目踩过八次。
"$PY" scripts/check_schema_drift.py --quiet
9
自动生成的文档是否过期
代码改了、文档没跟着改,是最容易被忽略的一类过期。
"$PY" scripts/check_generated_docs.py
10
乱填参数会不会把系统弄崩
对着每个地址灌各种畸形参数(超长、负数、乱码),看有没有 500。
"$PY" scripts/fuzz_get_params.py
11
每个提交动作挨个真做一遍 会动数据
会真的写库,所以排在最后;做完再按快照把数据回滚、逐行复核。
"$PY" scripts/smoke_posts.py
第一道是「把库清空重来」,最后一道是「真的写一遍再回滚」。 所以这两道只在本机跑,绝不对着线上跑。 线上只跑那些只读的(打开页面、乱填参数),这一点是明确规定的。
为什么把「清空重来」排在最前: 如果后面的检查依赖库里已有的数据,那结果就会随「你上次跑到哪一步」而变—— 同一份代码跑出两种结果,检查就失去了意义。 先清空、再按固定脚本灌数据,每次起点都一样。 代价是:跑完之后需要重新把 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表结构一致性无漂移
为什么总盯着「跟记录的数字比」: 有一次一个测试突然变红,查了半天代码没动过——最后发现是数据变了。 所以「读数跟记录对不上」本身就是一个很有价值的信号, 前提是记录里的数字是干净的——这也是为什么不允许测试往共享库里乱写。