Skip to content

Latest commit

 

History

History
543 lines (413 loc) · 54.2 KB

AGENTS.md

File metadata and controls

543 lines (413 loc) · 54.2 KB

ResumeForge 项目维护规则

本文件是 ResumeForge 仓库的长期项目规则,适用于项目根目录及其所有子目录。维护者和协作代理在提出建议、修改代码、补充测试或更新文档时,都应遵守本文件。系统级安全、工具和用户明确提出的要求优先于本文件。

项目背景

  • ResumeForge 是一个处于持续开发阶段的 FastAPI + React 简历工具。
  • 仓库已有稳定功能、真实用户数据、数据库迁移和测试体系;不得把它当作空白项目重新设计。
  • 所有改动都应建立在现有架构和代码风格之上,优先采用小步、可回滚的改进。

长期目标

持续把项目建设为高质量、可维护、可验证、社区友好的开源项目,同时保持现有功能和数据安全。

核心原则

  1. 渐进式改造:先阅读相关代码、测试和文档,再决定改动范围。避免一次性全量重写、无关重构或大范围格式化。
  2. 保持可运行:每次修改后都应保证项目仍可安装、编译、启动和使用。破坏性变更必须事先说明影响范围、兼容策略和迁移方案。
  3. 文档同步:代码、公共 API、配置项、数据格式或用户行为发生变化时,同步更新 README、架构/API 文档、示例、注释和 CHANGELOG。
  4. 测试优先:新增或修改功能必须补充或更新单元测试、集成测试或端到端测试。修复 bug 时先建立可复现测试,再实现修复;测试应覆盖成功、失败、边界和兼容路径。
  5. 代码规范:遵循 Python、FastAPI、SQLAlchemy、React、TypeScript 社区规范,保持命名一致、模块化、低耦合和高内聚,避免硬编码和魔法值。
  6. 错误处理与日志:外部网络调用、文件/数据库 IO、异步任务和模型调用都必须有明确的超时、异常处理和用户友好反馈。关键路径使用结构化、可检索的日志,禁止记录密钥和完整个人资料。
  7. 安全第一:不得硬编码密钥、密码、Token 或个人数据。敏感信息通过环境变量或配置管理;所有外部输入都要验证、限制大小并安全编码,注意注入、XSS、CSRF、路径穿越、SSRF、SQL 注入和权限边界。
  8. 依赖管理:新增依赖必须确有必要、活跃维护并与现有版本兼容。锁定可部署依赖版本,更新锁文件,检查许可证和已知漏洞,避免重复或臃肿依赖。
  9. 可配置性:环境差异通过 .env、.env.example、配置文件或启动参数管理,不把端口、路径、域名、密钥和环境相关行为直接写死在业务代码中。
  10. 可观测性:为关键业务流程提供必要的请求标识、错误日志、耗时或计数信息;健康检查和诊断信息不得泄露密钥、隐私或内部堆栈。
  11. 性能与资源:关注算法复杂度、数据库索引、查询次数、内存占用、网络超时和连接生命周期。避免阻塞事件循环、无界响应、重复请求和不必要的全量加载。
  12. 兼容性:尽量保持向后兼容。公共 API、配置项、数据库结构、导出格式或前端行为变更必须记录在 CHANGELOG,并提供迁移或回退说明。

代码风格

  • 命名清晰,避免晦涩缩写、单字母变量和无意义名称。
  • 函数和组件保持单一职责,长度适中;复杂逻辑拆分为可测试的纯函数或小模块。
  • 注释解释“为什么”以及约束和取舍,不重复描述显而易见的代码行为。
  • 目录按功能或领域组织,避免继续扩大上帝类、循环依赖和跨层直接耦合。
  • 优先使用依赖注入、接口隔离和现有本地抽象,以便替换实现和编写离线测试。
  • 复用已有工具、组件和类型;只有在确实减少复杂度或重复时才新增抽象。
  • 默认使用 ASCII 编辑文件;项目已有 UTF-8 中文文档时,保持其编码并避免因终端编码造成无意义的文件重写。
  • 使用 apply_patch 进行手工编辑,不用脚本覆盖无关文件,不提交构建产物、虚拟环境、数据库和个人资料。

工作流程

收到修改需求后按以下顺序执行:

  1. 阅读相关源码、测试、配置、文档和 Git 工作区状态,确认现有行为及用户未提交的改动。
  2. 用简短方案说明目标、涉及文件、影响面、风险、兼容策略和需要补充的测试。改动较大时先分阶段,不擅自进行大规模架构迁移。
  3. 实施范围可控的修改,沿用现有模式;不要回退或覆盖用户已有的无关改动。
  4. 补充测试和错误处理,更新相关文档与 CHANGELOG。
  5. 运行与风险匹配的验证命令,至少报告通过项、失败项和未覆盖风险。未能运行的检查必须明确说明原因。
  6. 汇总修改文件、行为变化、数据库/配置迁移、验证结果和后续建议。除非用户明确要求,不创建 Git commit、不推送远程、不删除用户数据。

不确定的架构决策如果会改变公共接口、数据模型、部署方式或用户工作流,应先向用户确认;低风险、可回滚且与现有模式一致的实现可以直接推进,但要在交付摘要中说明假设。

本项目验证命令

后端(Windows PowerShell)

在 backend 目录执行,中文环境先设置 PYTHONUTF8=1:

$env:PYTHONUTF8 = "1"
.venv\Scripts\python.exe -m ruff check .
.venv\Scripts\python.exe -m pytest -n auto --cov=app --cov-config=..\pyproject.toml
.venv\Scripts\python.exe -m pip check

-n auto 走 pytest-xdist(在 requirements-dev.txt 里)并行执行。这不是可选的美化:全量 1000+ 个用例串行要 ~8 分钟,并行后降到 ~3 分钟,而"改一次要等八分钟"正是协作最贵的一环。 conftest.py 的测试库与数据集目录按 PID 隔离,因此 worker 之间本来就不会互相踩;新增测试时 不要引入跨进程共享的固定路径(写进仓库目录、固定临时文件名、os.chdir),那会让并行从 "更快"变成"偶发不可复现的失败"。

并行有两个已经踩过的坑,改这块配置时别退回去:

  • [tool.coverage.run] parallel = true 是必需的。否则每个 worker 抢写同一个 .coverage 文件,会在所有用例都通过之后的收尾阶段抛 attempt to write a readonly database ——最坏的一类失败位置(看起来像测试挂了,其实是覆盖率写盘冲突)。

  • 退出码不能单独作为判据,而且并行跑可能根本没检查覆盖率门槛。本机沙箱的 sitecustomize.py 包装了 shutil.rmtree,pytest 清理自己的临时目录时会触发它的 "批量删除守卫"并 SystemExit(1)——worker 猝死,xdist 控制器随之抛 INTERNALERROR ... KeyError: <WorkerController>。此时汇总行仍然是 N passed, 0 failed, 但覆盖率报告那一步还没跑到,所以 80% 的门槛在并行跑里可能压根没执行。 因此分清楚用途:日常内循环用并行,看汇总行的失败数(必须为 0);交付判定必须再跑 一次全量,确认覆盖率报告与门槛真的过了——但注意串行的退出码同样可能被污染,见下一条。 反过来,汇总行出现 failed 就一定是真的坏了,不要用"可能是并行的问题"搪塞过去。

  • 但"串行就没事"这个结论并不成立(2026-09-18 实测):本机沙箱下串行 pytest --cov 同样会在 pytest-cov 收尾的 cov.combine() 删 .coverage.* 时命中批量删除守卫 → SystemExit(1)、退出码 3,而且覆盖率报告那一步整段跑不到。拿到干净覆盖率的办法 是绕开 pytest-cov——注意 coverage 只从 cwd 找配置(.coveragerc/pyproject.toml), 不会向上层目录搜索;在 backend/ 下裸跑会拿到"连 tests/ 一起统计"的假百分比 (2026-10-10 实测:假 41% vs 真实 86%),必须显式传 --rcfile 指向仓库根配置:

    .venv\Scripts\python.exe -m coverage run --rcfile=..\pyproject.toml -m pytest -q
    .venv\Scripts\python.exe -m coverage combine --keep
    .venv\Scripts\python.exe -m coverage report --rcfile=..\pyproject.toml --fail-under=80

    判定时以汇总行 + 覆盖率报告为准;只有两者都正常打印,退出码才可信。

  • "并行跑"和"覆盖率"不能写进同一条命令(2026-10-27 实测):coverage run ... -m pytest -n 4 会因为 xdist 的 worker 是子进程而统计不到它们——[tool.coverage.run] parallel = true 只让被测进程各写一份 .coverage.*,plain coverage run 不追子进程,控制器进程本身又几乎 不执行被测代码。结果 combine + report 出来是 28%(真值 88%),--fail-under=80 直接 红。要覆盖率就串行跑上面那三条;要速度就并行跑 pytest 但别开 coverage,两者分开。

  • vite build 在本机沙箱下会以 exit 1 失败,但不是构建错误:模块已经全部 transform 成功, 死在 prepareOutDir/emptyDir 清理 frontend/dist/assets 时被同一个批量删除守卫拦下 (日志形如 SAFE_DELETE_BULK_CONFIRM_REQUIRED count=170 threshold=100)。 指定一个仓库外的输出目录即可正常跑通:

    npx vite build --outDir D:\rf_build_tmp --emptyOutDir
  • 测试里的等待预算要分两档,别用同一个很小的值。 期望超时的用例需要预算真的到期, 所以越小越快;期望成功的用例就绪即刻返回,预算大小不影响耗时,它只是安全网。 2026-09-23 修过一处:tests/test_boss_apply.py 里两类共用 timeout=0.02,于是 "成功"用例的成败取决于真实墙钟——单跑必过、-n auto 下偶发失败(一次调度抖动就超过 20ms)。修法是给成功路径单独一档 2s 的预算(READY_WAIT),既不多花时间,也不会误伤。 判断标准很简单:用例的成败是否取决于"等了多久",如果是,它的预算就不该是个位数毫秒。

另外别忘了一条:--cov 不能和"只跑几个文件"混用。覆盖率门槛是 80%,子集跑出来必然只有 二三十,于是 pytest 以失败退出——那是个假失败,不是代码坏了。子集只跑不加 --cov 的命令。

分层验证(改动时按此顺序,最后一步不能省)

  1. 先跑改动直接相关的用例(pytest -q <相关文件>,通常几秒到几十秒)——快速确认方向对不对;
  2. 再跑 ruff check .(秒级);
  3. 交付前跑一次全量 + 覆盖率(上面那条命令)。覆盖率门槛是 80%,只在全量时开 --cov ——--cov 自身有明显开销,在只跑几个文件的第 1 步加它没有意义。
  4. 前端同理:改动相关的 .test.tsx 先跑,prettier --check / eslint / tsc --noEmit / vite build 在全量前跑。

提速只能来自流程,不能来自降低标准:不许为了跑得快而跳过全量、跳过 pip check、 降低覆盖率门槛、删掉"跑得慢但守着关键性质"的用例、或把断言改松。慢的用例要优化(拆出 纯函数、把网络与时钟注入进去、缩小夹具),不是删掉。若某个用例确实必须慢(真的要等超时), 把它的等待参数注入成毫秒级而不是改断言。

缩小改动半径(省时间也省 token)

  • 先精确检索、再读最小必要片段:用 Grep 定位到文件与行号后再读那一段,不要整目录通读、 也不要在已知位置的情况下重复读同一文件。
  • 一次改一处、就近验证:不要攒一大堆改动最后一起跑,那样失败时无法定位是哪一处引入的。
  • 改之前先确认"这份逻辑是否已经有唯一实现":仓库多处刻意保持"一处逻辑一份实现" (如投递准入、状态白名单),遇到重复实现优先复用而不是再写一份。
  • 不要拿"函数签名行"当编辑锚点,除非把那一行原样写回去。本项目已经因为这件事三次 误删既有代码:def note_with_source(...)(把下一个函数的签名并进了注释)、 def meta_line(...)(并成一行导致语法错)、def test_import_path_is_registered(): (签名被吞掉、函数体变成悬空代码)。用签名行做锚点时,new_string 的第一行必须是同一个签名。
  • 每次 Edit 之后立刻 grep 复查被改处(grep -n "被改的关键字" -A 3 文件)。上面三次都是 几分钟内就能看出来的错误,代价只是看一眼;而它们若溜进测试运行,会表现成"语法错/某个用例 莫名消失",排查成本高得多。

涉及数据库结构时,还要检查 Alembic 升级/降级、旧库兼容、备份完整性和关键记录数;不得直接用 create_all 掩盖迁移问题。数据库迁移前应保留可恢复备份。

新增一个迁移后,先 grep 上一个 revision 号再跑测试:

# 在 backend 目录执行:把 00XX_xxx 换成**上一个** head 的 revision 字面量
Select-String -Path tests\*.py -Pattern "00XX_xxx" | Select-Object Path, LineNumber

具体地:把新迁移的 revision 写进 migrations/versions/ 之后,搜一下旧 head 的字面量在测试里出现过几次——至少 tests/test_database.py、tests/test_backup_apply_tables.py、tests/test_apply_migration_qa.py、tests/test_assistant_search.py 各钉着一处 "head revision 应当是 X"。不更新它们,一次纯加列的迁移就会带出十几个失败,而失败信息(assert '0015_…' == '0014_…')看起来像迁移本身坏了。新增迁移的情形还要确认:只加表 / 只加列(这是"旧备份仍可导入"成立的前提,test_migration_00XX.py 里要有"表集合前后不变"的断言)、表不存在时跳过而不是报错(迁移链也会跑在"只有部分业务表"的历史库上)、以及完整可用的 downgrade。

前端

在 frontend 目录执行:

npm ci
npm test
npm run format:check
npm run lint
npm run typecheck
npm run build
npm audit --registry=https://registry.npmjs.org

新增或修改交互时优先补充 Vitest 测试,并检查键盘可访问性、响应式布局、加载态、空态、错误态和异步竞态。

macOS 启动链(改 scripts/macos/** 或根目录 *.command 时)

bash scripts/tests/test-macos-launcher.sh          # 任何平台都能跑;Windows 上会跳过进程相关用例
bash -n start.command scripts/macos/start.sh       # 只做语法检查,几毫秒

这份测试不依赖任何测试框架(macOS 自带的 bash 是 3.2,也就没有 declare -A、${var,,}、mapfile;脚本本身也要按 3.2 写)。它覆盖换行/编码、可执行位、自举常量、版本窗口与进程记录匹配等纯逻辑;TMPDIR 不可写的环境可以传一个目录:bash scripts/tests/test-macos-launcher.sh /tmp。

覆盖不到的(真机首次安装、镜像可用性、下载摘要是否与上游一致、双击行为)必须交给 CI 的三个 macOS 作业:macos-launcher-guards(守卫测试)、macos-runtimes(真下载一遍便携版运行时并校验摘要)、macos-end-to-end(从干净检出走完整启动链路)。维护者没有 Mac,这三个作业目前是唯一的真机验证途径;改了 macOS 侧的东西要在交付说明里指明期望它们给出什么结果。

Windows 侧对应的是 windows-end-to-end(从干净检出走完整启动链路,并真的取一次前端入口模块)。它和 macOS 那个作业一样,捕的是"新下载的包在这台机器上起不来"——端口活着不等于页面能用:Vite 的入口模块取不到时浏览器就是一片空白(2026-09-29 用户在虚拟机上遇到的正是这个形态)。改了启动链、发布包或前端入口要跑它。

macOS 桌面安装包与发布资产(v1.0.1 起)

  • mac 安装包(dmg):在 macOS 上构建 bash scripts/build-desktop-mac.sh --arch both(或 --arch x64|arm64|universal),产物为 desktop/out/ResumeForge-<ver>-{x64,arm64}.dmg + 同名 .sha256。未签名(无 Apple 开发者证书),发布说明里必须写清右键→打开 / xattr -dr com.apple.quarantine 的打开方式。
  • 日常发版优先走 CI:gh workflow run release-assets.yml -f tag=<tag> -f dry_run=true|false——Windows Setup.exe 与 mac 双架构 dmg 一并构建并挂到对应 Release(dry_run=false 才上传)。构建源默认=该 tag(产物与发行严格对应);流程与已知坑见 docs/mac-build-handoff.md。
  • 桌面运行时缓存:desktop/python-runtime.zip 由依赖清单守卫自动管理——requirements 指纹或 Python 版本不匹配时 Build-Desktop.ps1 会自动重建(scripts/PythonRuntimeManifest.ps1,测试 scripts/tests/Test-PythonRuntimeGuard.ps1 随 CI 运行)。不要手工复用陈旧 zip。

本地运行

  • 后端默认仅监听本机地址;开发启动方式以 README 和当前配置为准。
  • 前端通过 Vite 代理访问后端 API。
  • PowerShell 5.1 不支持 &&;涉及中文 JSON 的命令避免直接内联传参,使用 UTF-8 文件配合 --data-binary。
  • 不要停止或覆盖用户未授权的其他进程、端口、数据库和备份。

Ant Design 使用规范(L1 原生 / L2 薄封装 / L3 禁止重实现)

  1. 默认直接用 antd 组件与 token。新增 <Xxx> 前先 grep node_modules/antd/es/<x>/index.d.ts,确认 antd 6.6.5 没有等价物(例:Listy 是 antd 自带,不要再造列表行)。
  2. message / notification / modal 一律取自 App.useApp();禁止 import { message } 与 Modal.confirm 等静态方法 —— 静态调用不读 React ConfigProvider,深色主题下会退回默认浅色 token。为此渲染的测试必须包 <AntdApp>。
  3. LoadingBlock 是块级加载态的唯一出口(spinning 或 children 模式)。无 children、无 spinning 的裸 <Spin /> 仍允许出现在 Modal 内、表格单元格、卡片内嵌的局部刷新位等空间受限处(LoadingBlock 的 min-height 会把容器撑高);size="small" 行内转圈与 <Spin fullscreen>(启动期全屏遮罩)照旧。
  4. 空态:页级 / Tab 面板主体 / 承担整块信息的 Card 用 EmptyState;紧凑容器(表格空数据、Drawer / Modal 内、侧栏列表、Listy)用 EmptyState inline(不渲染 .rf-state 外壳,避免 min-height:192px 凭空撑高)。保留默认大插图、或带富 description(含自定义子节点)的站点可以继续用裸 <Empty> —— 换成 EmptyState 会改掉插图尺寸与排版。两句冲突时以插图形态优先:页级位置但如果该站点刻意保留默认大插图,就继续用裸 <Empty>,不要为了"位置对"去换成一个更小的插图。
  5. 禁用语义只传 true 或 undefined:disabled={flag || undefined}。禁止 disabled={flag} —— 显式 false 会短路 Form disabled / ConfigProvider componentDisabled(正范式样板:components/profile/PartialDateSelect.tsx:62-63)。
  6. 不用裸 button/select/textarea/dialog 替代 Button/Select/Input.TextArea/Modal;禁止手贴 .ant-* 类名。
  7. .ant-* 覆写只允许四种理由:布局无 token、无障碍补偿、私有形态、antd 类名版本迁移。外观/颜色一律走 theme.components 或 --rf-* 变量,并在注释里写明理由。
  8. 改 .ant-* 前先 grep src/styles/*.test.ts —— input-focus-ring.test.ts / profile-disabled-select.test.ts / color-regression.test.ts 直接读 CSS 原文断言选择器,删改即断。
  9. 类型从 antd 根导出取(TableColumnsType、TablePaginationConfig、GetProps、FormInstance、FormListFieldData、FormListOperation);仅当根未导出时才用 antd/es/* 深路径(当前仅 SortOrder、TableRowSelection 属此例)。
  10. 原生 title= 只用于截断文本的完整值提示;功能提示(含「双击恢复默认」这类唯一可发现性入口)必须用 Tooltip,并给键盘等价(role="button" + tabIndex + Enter/空格)。
  11. 特色 UI(投投浮球、划词菜单、月历、拖拽手柄、全站搜索、网页表单浮球)保留自造,但必须:键盘可达(方向键 + Esc)、role 正确、颜色只用 --rf-*。
  12. 三态(loading / empty / error)各自只有一个出口:LoadingBlock / EmptyState / ErrorState + PageSkeleton。新增页面不许再手写 useState loading;存量站点在改到它时顺手迁移,不做一次性大扫除。
  13. 单文件 ≤150 行、入参是 antd 语汇、内部输出 antd 组件的封装才是 L2;其余一律算 L3。

存量未迁移清单不在此维护(会腐烂);判断依据只有一条——新写的代码必须按上述规范。

弹窗与抽屉的关闭语义(与 antd 原生一致)

  • 只读弹窗(详情、预览、大图)一律 footer={null},关闭只走右上角 ×;不额外放「关闭」按钮。
  • 有主操作的弹窗,footer 只放动作(开始/保存/用这个模板),不要再补一只「关闭」。
  • 「取消」只用于「有未保存内容、关闭会丢失」的场景,且必须与 × 行为不同(× 可留草稿/提示)。
  • 确实需要并列一个「关闭」(如失败态显式出口)时,在源码该行上方写理由注释。
  • 不要在 body 里自造 footer(Space + 按钮),统一用 Modal 的 footer 插槽。

弹层形态与关闭语义(2026-10-22 起,新增与改到的弹层按此执行)

口径来自《弹出层全链路优化方案》deliverables/software-company/ResumeForge-overlay-UX-plan-20261022.md。 存量不动(与三态迁移同一条原则:改到它时顺手修,不做一次性大扫除),新增代码必须遵守。

形态选择——按内容定,不按容器定:

  • 有「点主按钮提交的一组输入」→ 表单弹层;只读查看 → Drawer 或 common/RecordDetail; 能撤销 → 不弹确认;不可撤销 → 确认弹层;高频低风险 → 就地 Popconfirm。
  • 页内静态提示继续用 <Alert>;跨页常驻 + 需用户看见并处理 + 允许先不理这三条同时满足, 才用公告条(components/common/AnnouncementBar.tsx)。新增公告来源必须同时登记 features/announcements/announcementCodes.ts。
  • 公告条的两个关闭动作语义必须分开:× = 关掉这一条(版本变了会再来);「不再提醒」= 静音该来源,必须可撤销(设置页给恢复入口)。静音不可撤销等于让用户永远收不到更新提醒 且找不到开关。

关闭语义(Esc / × / 蒙层):

  • 有提交动作的 Modal(含 <Form> 或有写入请求)必须禁掉蒙层关闭:写 mask={{ closable: false }}。只读预览类(详情 / 预览 / 大图,footer={null})保留 antd 默认 true —— 关闭无损,禁了只会降低可用性(2026-10-22 用户拍板)。 ⚠️ 旧写法 maskClosable={false} 在 antd 6.6.5 已废弃(modal/interface.d.ts:71 标了 @deprecated,运行时打警告),新代码一律用 mask.closable;守卫两种都认,存量改到时顺手换。 这件事 ConfigProvider 兜不住(ModalConfig 只有 mask,没有逐弹窗的分级),只能逐个写。
  • Drawer 按内容定:详情/列表/导航保留 antd 默认 true;里面装表单则必须 false。
  • Esc 与 × 永远同义;不要只改 keyboard 或只改 onCancel 中的一个。
  • 有草稿时不问、直接关(关闭无损,配一条 2.5s Toast 告知);无草稿且有脏值才出内联确认条 ——footer 换态,不要再开一个 modal.confirm。
  • 提交中(submitting)忽略 Esc,并 closable={false}。

叠层与文案:

  • 同一时刻 Modal / Drawer / modal.confirm 三类容器数量 ≤ 1。 在 *Modal.tsx / *Drawer.tsx 内部再开 modal.confirm 属违规(popupStackGuard.test.ts 扫);替代品是内联确认条、就地 Popconfirm、页内 Alert。抽屉里要放详情/表单时用视图切换(CandidateJobsDrawer 是样板: view: "list" | "detail" | "form" | "import",标题随视图变,×/Esc 先退一层)。 唯一例外是 utils/taskNotify.ts 的批次完成打断(行内注 // popup-stack-ok: 理由)。
  • 按钮文案禁止「是/否/确定」,必须描述结果(「删除这条」「保存修改」);危险操作给 danger。
  • 标题 ≤7 字、正文 ≤30 字、Toast ≤20 字,不用感叹号与句号。只约束新增与本批改到的文案。
  • 新增文案一律 t() + i18n/messages/zh-CN.ts(否则 cjkRatchet 变红)。

草稿:

  • 草稿正文只允许 sessionStorage(resumeforge.form-draft.v1:<scope>@<datasetId>), 禁止 localStorage;凭据类字段(apiKey/token/password/secret)写前剔除。
  • 参考实现 features/webform/webFormSessionStorage.ts(450ms 防抖 + pagehide flush + 按数据集隔离 key)。
  • 偏好(布尔开关,如公告条静音)可以落 localStorage,与正文分开对待。

删除:

  • 能恢复的(软删进回收站,16 类)直接执行 + Toast 给 5 秒「撤销」(restoreTrashItem), 不再加前置 Popconfirm;批量 >100 条仍保留确认。
  • 不能恢复的(清空回收站 / 彻底删除)用 Modal + danger,文案写清后果。

守卫测试

四条守卫测试(新增合法例外用「行内豁免注释」,不要新建白名单文件):

  • src/theme/antdStaticGuard.test.ts —— 禁静态 message / notification / Modal.*(豁免:// antd-static-ok: 理由)
  • src/theme/antdOverrideGuard.test.ts —— .ant-* 覆写必须有自定义类前缀或就地 /* antd-override: 理由 */(文件级白名单仅 accessibility.css / dark-tokens.css / mobile.css)
  • src/theme/spinOutletGuard.test.ts —— <Spin spinning=> 只允许出现在 LoadingBlock 内部(豁免:// spin-ok: 理由)
  • src/i18n/cjkRatchet.test.ts —— 存量中文不许上涨(豁免:// i18n-ok: 理由)

弹层相关守卫(《弹出层优化方案》B1–B3 已落地,两条都是启用状态):

  • src/components/common/popupGuard.test.ts —— 含 <Form 的 <Modal> 必须禁蒙层关闭 (mask={{ closable: false }} 或旧写法 maskClosable={false});<Modal> 必须自带 footer= 或 okText=(禁 antd 默认「确定」);<Drawer> 禁 width=,统一 size=
  • src/components/common/popupStackGuard.test.ts —— *Modal.tsx / *Drawer.tsx 内禁 modal.confirm (叠层;豁免:// popup-stack-ok: 理由)。替代品:footer 内联确认条 / 就地 Popconfirm / 页内 Alert
  • src/i18n/popupCopy.test.ts —— 新弹层文案的长度与语气(标题 7 / 正文 30 / Toast 20,禁 ! ! 。)

界面文案口径(2026-10-22 起,新增与改到的文案按此执行)

判定基准的唯一真相是 docs/ui-copy-guide.md(37 条术语 + 24 条动词 + 12 类文案位规则 + 四级严重度 + 冲突裁决原则)。本节只列最容易违反的硬约束与指向,不复制正文——两处各写一份必然漂移。

改任何用户可见文案(按钮、标题、提示、空态、占位符、表头、Tooltip、aria-label)之前,先在 docs/ui-copy-guide.md 里找到它属于哪个实体、哪个动作、哪个位置。三问都落不到某一行,说明规范有缺口,补规范,不要各自发挥。

界面已双语化(2026-10-27 起,动手改文案前先看这一节)

前端已按领域把中文迁进语言包(frontend/src/i18n/messages/),迁过的领域界面文案不在组件里,在语言包里。改之前先确认目标文件属于哪一档:

  1. 已迁移文件(基线里是 0):改文案 = 改 zh-CN.<命名空间>.ts 与 en-US.<命名空间>.ts,两边成对改,{name} 占位符必须一致。在组件里改中文字面量不会生效,还会红。
  2. // i18n-ok: 只用于「这段中文永远不该翻」:协议值(提交给后端的枚举、value 字段)、includes("中文") 匹配后端文案、喂给模型的 prompt。新功能的中文不属于这三类,别拿豁免绕过棘轮——那是把上限一点点放松。
  3. 用了 t() 的组件必须有 useLocale()(i18n/localeSubscription.test.ts 会扫)。漏了不会报错,只会让用户切语言后停在旧文案。
  4. 禁止在模块作用域求值 t():模块级常量表存 labelKey: MessageKey,渲染处再 t();useMemo 里调 t() 必须把 locale 放进依赖,否则切语言不更新(这条没有守卫,靠评审)。
  5. types/*.ts 里的中文枚举是协议值,永远不翻。要显示名就在页面里建 Record<协议值, MessageKey> 映射,别改类型层。
  6. 新命名空间要在 zh-CN.namespaces.ts 与 en-US.ts 各登记一次(import + spread)。

判断口诀:这段话是给人看的,还是给后端/模型/对比逻辑看的? 给人看的 → 语言包;其余 → 原样保留并写清理由。

十条硬约束(违反即返工)

  1. 一词一义:只用术语表的正名。最容易混的是四层——模板(外观骨架)/ 版式(参数预设)/ 排版参数(字号行距页边距)/ 版面(排出来的效果)/ 统称 排版设置。禁用「样式」「样式模板」「格式模板」「版式模板」「新版式」。
  2. 按钮写具体动作,禁用空动词:确定 / 确认 / 提交 / 处理 / 操作 / 执行。用「删除」「移出」「彻底删除」「保存」「导出」这类能预测后果的词。
  3. 二次确认必须三段:动作 + 对象(带数量/名称)+ 后果(可逆性与去哪恢复)。只写「确定删除?」不合格。
  4. 成功提示必须含对象:已删除 不合格,已删除这条面经 合格。
  5. 错误提示必须给出路:说清"发生了什么 + 怎么办";禁止 操作失败 这类无信息兜底。
  6. 空态禁用方位词指路:「点右上角『放入资料』」不合格(视口一变就失效),主按钮要内嵌在空态里。
  7. 引用别处文案必须逐字一致:用户指南里写「点『某按钮』」,那个按钮就必须真的叫这个名字、且在本页存在。这是 glossary §4.13 三条硬约束,有守卫(见下)。
  8. 不用已废弃的组件属性:antd 6.6.5 里 Alert / notification 的标题属性是 title,message 已 @deprecated。判定依据是 node_modules/antd 当前版本的 .d.ts,不是记忆里的 API。tsc 与 eslint 都不报这类问题。
  9. 禁止"正名(旧别名)"写法:格式模板(版式)、模板(样式) 这类并列会让旧名长期残留并被当成第二个正名。
  10. 界面改名不动内部命名:路由 path、DB 列、API 字段、类型名、文件名(data/datasets、format_name、template)全部保留。界面名与内部名不一致不是缺陷。

改文案的固定顺序(不按这个顺序会返工)

  1. 改实现 → 2. 跑相关 .test.tsx 拿失败清单 → 3. 逐条改断言 → 4. 每次 Edit 后 grep 复查被改处(本项目出过三次"改一处吞掉邻行")→ 5. 全量 npm test。

界面文案改了,必须同一提交改五处,否则"文档与界面互相矛盾"本身就是 P1:

  • frontend/src/i18n/messages/zh-CN.<命名空间>.ts(+ 对应的 en-US.*)——文案本体在这里,已迁移领域尤其注意
  • frontend/src/components/userGuideSteps.ts(就地改那一步的措辞,不要为新功能随手加一步)
  • docs/user-guide.md(对应章节 + 常见问题排查)
  • README.md(功能表受影响时;注意 ## ✨ 功能特性 的行数守卫)
  • CHANGELOG.md

守卫(不许豁免、不许调阈值)

  • frontend/src/i18n/cjkRatchet.test.ts:存量中文只降不升(基线在 i18nBaseline.json)。已迁移领域的新文案走语言包(见本节「界面已双语化」);只有「永远不该翻」的三类才用 // i18n-ok: 理由。
  • frontend/src/components/userGuideCopyRef.test.ts(本轮新增,与 userGuideSteps.ts 同目录):把 userGuideSteps.ts 里每个「…」引号内容抽出来,断言它作为字面量存在于 src/**/*.tsx。只能证明"字符串出现过",检不出动态拼接——动态文案要么改成静态全名,要么显式豁免并写明理由。
  • frontend/src/theme/antdDeprecatedProp.test.ts(本轮新增,与既有 antdStaticGuard / antdOverrideGuard 同目录):按 docs/ui-copy-guide.md §4.14 的判定表扫 Alert / notification 的 message=。

一条成本常识

前端有 665 处 getByText 中文断言、804 处 { name: "…" } 查询——改一处按钮文字,平均连带改 1–3 个测试。所以改文案要按领域分批:一次改动连带测试超 5 个文件就拆 PR,不要一个 PR 横跨多个领域。

排版参数即呈现(禁止自动缩字号)

用户在排版栏里设的字号 / 行距 / 页边距,就是纸面上的值——任何"装不下就按比例压小"的实现都不许再加回来 (2026-10-21 用户明确要求,草稿预览与精确预览同一口径):

  • 模板 --fs 只能是字面量(--fs: {{ base_px }}px;),不许出现 calc(... * var(--fit-scale)) 这类系数挂钩; _resume_fit_script.j2 只能读(写 body.dataset.pages/measured/overflow),不许 setProperty;
  • 装不下的处理是如实提示 + 截断到页数上限,不是缩小:PDF 侧 build_resume_pdf 先按真实参数排一遍, 超出就以 max_pages 截断并标 overflow,真实页数走 X-Resume-Pages-Needed(Word 只标记不截断);
  • 前端提示统一为"按当前设置约需 N 页(上限 M 页)"+"字号、行距、页边距都按你设定的值原样渲染,不会自动缩小", 草稿(ResumePreviewCanvas)与精确(ExactPreviewPanel)必须用同一套措辞;
  • 想压进一页请改参数:加页数、收紧行距/页边距、或「版面诊断 → 自动一页」(它把参数写回滑杆,用户看得见改了 什么)。不要替用户做隐式降级。

守卫:frontend/src/components/resume-preview/noAutoShrinkGuard.test.ts(扫整个 src/,去注释后不得出现 --fit-scale)+ backend/tests/test_resume_templates.py(逐模板断言 --fs 是字面量、脚本里没有 setProperty("--fit-scale")。注意 geometry.ts 的 fitScale 是视口缩放(整张 A4 等比缩到看得见), 与"缩字号塞进一页"不是一回事,别把它当违例删掉。

安全与隐私边界

  • .env、API Key、SQLite 数据库、照片、简历、导出文件和本地虚拟环境不得提交到 Git。
  • 模型上下文只发送完成任务所必需的资料;身份信息、照片和联系方式默认在本地恢复。
  • 岗位描述、参考文件和用户粘贴内容均视为不可信输入,不能当作系统指令执行。
  • 导出 HTML、Markdown、JSON 和预览内容时分别处理转义、资源引用和隐私字段,避免把内部数据或密钥暴露给浏览器或日志。
  • 发现疑似安全问题时遵循 SECURITY.md,不要在公开 Issue 中披露可利用细节。

备份包与磁盘资产(改这里之前先看)

用户数据不止数据库一处:user_files/、referral_images/ 跟数据集走,fonts/ 跨数据集共用, datasets/ 下还有别的库。导出覆盖面靠结构保证,不靠记忆——判定与清单在 backend/app/services/data_backup/:

你要改的东西 必须同步的地方 漏了会怎样
新功能把文件写到新目录 跟数据集走 → asset_categories.ASSET_CATEGORIES;跨数据集共用 → 按 global_assets.py 的"全局成员"模式做;可重建/敏感 → coverage.RUNTIME_ONLY 该目录会进 uncovered(可见但不进包);若它是运行产物,则是每次导出都报一条噪音
落盘命名规则(disk_name) 迁移 + 保留旧文件 改名等于断链:旧行指向不存在的文件
数据库里引用附件的那一列 asset_manifest.collect_referenced_names 文件在磁盘上但永远不进包
新增 base64 原件来源(业务列) user_files._backfill_* 与 _lookup_original 加一支 副本丢了补不回来,预览 404、导出永久报缺失
包内新增成员/字段语义 升 BACKUP_FORMAT_* + 导入侧 fail-closed 校验 老版本安静地只恢复数据库,用户以为都回来了
后端新增模型表 无需手工登记:清单从 Base.metadata 推导 test_backup_table_coverage 会红

三条永久红灯(写错就红,不需要谁记得去查文档):

  • test_backup_table_coverage:manifest["tables"] 的表集合 == application_tables();
  • test_new_data_directory_is_never_silent:磁盘上不允许存在"既不在清单、也不在 uncovered"的顶层项;
  • test_asset_categories_and_coverage_agree:资产类别与覆盖名单同一份,且与 RUNTIME_ONLY 不相交。

改包结构时四处同改,缺一处视为未完成:CHANGELOG.md(用户可见变化)→ README.md (备份章节的"包含/不包含"清单)→ 本文件 → deliverables/software-company/resumeforge-backup-coverage-plan-20261020.md 的附录 A。新增格式号还要:用 scripts/make_backup_format_fixtures.py 重新生成 backend/tests/fixtures/backup-formats/ 的样本(test_backup_format_matrix 逐个导入)。

反模式(明确禁止):在 data_backup 里手写第二份表/资产清单;用 try/except: pass 吞掉附件失败; 新包成员不升格式号;把"只有新版本认识"的数据塞进既有段;删除或改名 disk_name / format / api_key_included / asset_manifest.manifest_version。

变更记录要求

以下变更必须更新 CHANGELOG.md,必要时同时更新 README 或架构文档:

  • API 路由、请求/响应字段或错误语义;
  • 数据库表、索引、外键或迁移行为;
  • 环境变量、默认配置、依赖版本或启动命令;
  • 导出格式、简历生成逻辑和用户可见交互;
  • 安全边界、隐私处理和部署要求。

使用指南同步

应用内的使用指南是 frontend/src/components/userGuideSteps.ts(侧栏「使用指南」,也是首次启动时自动弹出的那份)。docs/user-guide.md 更完整,但只有这一份是用户会主动打开的,所以它必须跟着功能一起变,不能只更新 CHANGELOG:

  • 新增用户可见的能力时,先问"用户会不会因为不知道而用错或用不上":会,就要更新 userGuideSteps.ts;不会(纯重构、内部性能、开发流程、只影响开发者的配置)则不必,但在交付说明里写清楚为什么不必。
  • 已存在对应步骤的功能,就地改那一步的措辞,不要为新功能随手加一步——这是一个流程向导,不是发行说明。加一步的前提是它确实是一个新的阶段(例如"数据备份")。
  • 指南里每一步的 path 必须指向 App.tsx 中真实存在的路由;改路由名时 UserGuideModal.test.tsx 会失败并指出是哪一步,照着改。
  • docs/user-guide.md 的对应章节与「常见问题排查」要一起改:两者讲的是同一批功能,只改一处会互相矛盾。

README(项目主页)与截图

README 是这个仓库的门面,也是对用户可见功能的权威清单。改它时有三条硬约束:

  • ## ✨ 功能特性 这一段里只能有那一张表。 backend/tests/test_assistant_knowledge_audit.py 会把该段落下 每一行的第一列提取出来,与 README_FEATURE_KEYS 双向比对(多一行、少一行、改名都会变红),因为这就是 "加了功能却没登记给求职助手"的防呆闸门。所以新增的截图、说明、目录都放到别的段落里——在该段里加一张 | 📸 界面一览 | … | 这样的表格会被当成一个新的功能行,直接让守卫失败。

  • **顶部必须有 当前版本:\X.Y.Z`**,test_version_consistency.py会比对它和frontend/package.json; scripts/bump_version.py` 也依赖这一行的格式来做替换,改格式会让发版脚本报"期望匹配 1 处,实际 0 处"。

  • 截图必须来自真实运行的应用,且数据必须是虚构的。 仓库里不允许出现真实用户的姓名、手机号、邮箱、公司名或 简历原文(含截图里的浏览器书签栏、任务栏、通知气泡)。做法是先用隔离的演示库启动前后端,再截图:

    # 1. 造一份虚构演示数据,并顺手把 58 个只读接口跑一遍自检
    #    (演示数据写坏 schema 的后果是"某个页面打不开",所以先撞一次墙)
    backend\.venv\Scripts\python.exe scripts\seed_demo_data.py --check
    
    # 2. 用这份库启动后端:**不要**动 backend\data\resume_forge.db
    cd backend
    $env:PYTHONUTF8 = "1"; $env:DATABASE_URL = "sqlite:///D:/ResumeForge/runtime/demo/demo.db"
    .venv\Scripts\python.exe -m uvicorn app.main:app --port 8123
    
    # 3. 前端指向它。注意 Vite 默认只监听 [::1]:要用 localhost 访问,127.0.0.1 会连不上
    cd ..\frontend
    $env:VITE_BACKEND_URL = "http://127.0.0.1:8123"
    .\node_modules\.bin\vite --port 5199 --strictPort

    截图脚本见 runtime/demo/tools/shoot.js(本机临时工具,不进仓库:playwright-core + 系统已装的 Chromium), 产物写进 docs/images/。截完把图压到 1440 宽(2x 原图 17 张接近 6 MB,压完约 4 MB)再提交。 scripts/seed_demo_data.py 的虚构性由 backend/tests/test_seed_demo_data.py 守着——改了演示数据记得跑它。

启动链路(start.cmd / scripts/)

首次启动必须能在什么都没装的电脑上跑通,这是它的唯一职责;Windows 与 macOS 都是这个承诺。改这几个文件时注意:

  • 两个平台是镜像实现,不是一份代码强行跨平台。 Windows 侧在 scripts/ResumeForge.*.ps1 + scripts/Start-ResumeForge.ps1;macOS 侧在 scripts/macos/lib/{common,python,node}.sh + scripts/macos/start.sh,逐文件对应。改了一侧的行为就要问另一侧是不是也要改——两边承诺同样的事,一边修好另一边没修,就是"同一个 bug 只在一个系统上消失"。
  • scripts/*.ps1 里的非 ASCII 字符必须配 UTF-8 BOM。 真正的规则不是"纯 ASCII",而是**"有非 ASCII 就必须带 BOM":start.cmd 走的是 Windows PowerShell 5.1,它把无 BOM** 的文件按 ANSI 码页解码,于是中文的 UTF-8 字节在中Windows 上被当成 GBK——注释会变成乱码,而字符串会直接把脚本读崩(2026-09-21 实测:把 ResumeForge.Process.ps1 的 BOM 去掉,PS 5.1 报 33 处语法错误)。加了 BOM 就一切正常。所以启动器的中文提示是允许的,前提是带 BOM。
    • 这条必须在字节层面校验:PowerShell 7 解析无 BOM 的文件完全正常,所以任何 pwsh 侧的语法检查(包括本仓库的启动器测试在 pwsh 下跑)和 CI 都看不见这个缺陷。scripts/tests/Test-Start-ResumeForge.ps1 现在逐个文件检查"有非 ASCII 就必须有 BOM"。
    • 注意不要把这条与下面那条混淆:backend/requirements*.txt 才是必须纯 ASCII(那条由同一个测试逐字节校验),因为 pip 24.x 会用 locale 编码去解码无 BOM 的 requirements 文件。
  • scripts/macos/** 与根目录的 *.command 是 shell 脚本,规则与 .ps1 完全不同:必须纯粹 LF、不能带 BOM。 一行 \r 就够毁掉它——内核会把 shebang 读成 #!/bin/bash\r,macOS 报 bad interpreter。这条对在 Windows 上编辑极其容易踩(编辑器默认 CRLF、Write 工具也可能写出 CRLF),而且在 Windows 上跑任何测试都看不出来,因为 Git Bash 与 PowerShell 都能容忍 CRLF。合规写法是纯 ASCII 或 UTF-8 无 BOM + LF;.gitattributes 的 * text=auto eol=lf 只保证入库的字节,工作区的那一份要靠自己守住。
  • 三个 *.command 必须在 git 里带可执行位(git update-index --chmod=+x)。Windows 上 core.fileMode 常为 false,不加的话入库是 100644,Finder 双击会被拒绝——而 Mac 用户手上只有发布 zip,没有别的补救路径。scripts/tests/test-macos-launcher.sh 与 Test-Build-Release.ps1 各有一处断言钉着它。
  • macOS 的自动准备不用 winget、不用 sudo。 macOS 上没有 winget,而 python.org 的 .pkg 安装器必须提权,所以那边下载的是便携版(Python 用 python-build-standalone 的 install_only 包,Node 用官方 darwin 压缩包)解压进 runtime/tools/。下载源先镜像后官方,但每个候选都必须过仓库里钉死的 SHA-256——摘要常量在仓库里、不来自镜像,所以镜像只能"慢或旧",不能替换内容。改版本号时四处常量要一起改:RF_PYTHON_BOOTSTRAP_VERSION / _TAG / 两个 SHA256,以及 RF_NODE_BOOTSTRAP_VERSION / 两个 SHA256;scripts/tests/test-macos-launcher.sh 会把它们逐个钉住。
  • macOS 的进程记录是扁平的 key=value,不是 Windows 那套 JSON。 文件名也刻意分开(runtime/backend.macos.pid vs runtime/backend.json):macOS 不预装 jq,自己解析自己写的 JSON 只能靠正则硬凑,而它出错的时刻恰好是最需要可靠的时刻。停止走整棵进程树(pgrep -P 递归),否则 npm → node → esbuild 只杀最上面一层、端口一直被占。PID 复用防护用 ps -o lstart= 的原始字符串做签名,不做时间换算(不受时区影响)。
  • backend/requirements.txt 必须保持纯 ASCII。 pip 24.x 在文件无 BOM 时会用 locale 编码(中文 Windows 是 cp936)解码,一个中文注释就会让首次 pip install 直接抛 UnicodeDecodeError。同一条校验也在启动器测试里。
  • 接受的 Python 版本是 3.11 – 3.13,常量在 scripts/Start-ResumeForge.ps1($MinimumPythonVersion / $MaximumPythonVersion)与 scripts/macos/lib/python.sh(RF_PYTHON_MIN_* / RF_PYTHON_MAX_*),两处要一起改。上限存在的原因是依赖锁定版本还没有新解释器的轮子;升级依赖后要同步改这里和 README、docs/upgrading.md 的说明。
  • 不要手写 cmd /c "…" 命令行。 把 .cmd 直接交给 Start-Process -FilePath,它会自己套好 cmd.exe 的引号;手写的话 -ArgumentList 不加引号而 /s /c 会剥掉首尾引号,路径含空格就起不来。
  • 启动器里调用原生命令要看 stderr。 $ErrorActionPreference = "Stop" 下,任何原生命令写到 stderr 的输出都会变成终止性错误——"预期会失败"的探测(比如在空 venv 上 import)必须先把它降成 Continue 再读 $LASTEXITCODE。macOS 侧的对应物是 set -euo pipefail 与命令替换:cmd | grep 这类管道失败会被 pipefail 放大成函数返回非零,所以取值型函数(如 log_tail)要显式 return 0。
  • 改完必须跑两边的守卫测试:Windows 跑 scripts/tests/Test-Start-ResumeForge.ps1,macOS 跑 bash scripts/tests/test-macos-launcher.sh(Windows 上会因为缺 BSD ps/pgrep 跳过进程相关用例,这是刻意的能力探测,不是失败)。真机首次安装、镜像可用性、下载摘要这些它们覆盖不到的,要在交付说明里写清楚验证到什么程度——本地跑不了就说跑不了,别把"没测"说成"测过了"。
  • uninstall.cmd / scripts/Uninstall-ResumeForge.ps1 是唯一会主动删东西的入口,改它必须跑 scripts/tests/Test-Uninstall-ResumeForge.ps1。三条不能退让的性质:默认只删启动器生成的东西(backend\data 与 backend\.env 要留着,-Purge 才删)、永远不删源码、只在真正的 checkout 里运行。测试全部在临时目录里复制一份脚本来跑,不会碰当前仓库。
  • 往 backend/app/preflight.py 的 _REQUIRED_FILES 加资源时,必须同步加到 scripts/Build-Release.ps1 的 $RequiredFiles。 这是单方向守卫:scripts/tests/Test-Build-Release.ps1 会断言"preflight 要的每一样,打包清单都要点名",漏一项就会让 Windows 的「启动器 + 打包」测试抛 Build-Release.ps1 does not require 'backend/app/data/xxx' 而变红——2026-09-21 就是因为它落后 22 项(ats_keywords.json、feature_catalog.py 与 20 个提示词)让 Windows job 连续多轮失败。注意这份清单是发布前自检网,不是打包过滤:git archive 本来就会带上全部受追踪文件,漏登记只红测试、不丢文件。

桌面安装包(desktop/ 与 scripts/Build-Desktop.ps1)

  • 不变量(ADR-0004):用户数据永远不落 resources/payload。 打包形态下数据目录是 %APPDATA%\ResumeForge\data(壳注入 RF_DATA_DIR);任何"按 BACKEND_DIR/"data" 拼路径" 的新代码都会把它写进安装目录,而 NSIS 升级会先执行旧卸载器、把安装目录整体删除。 2026-10-14 已因此修掉两处(投递/网申浏览器登录态目录)——加数据目录相关路径时先问 "升级安装会不会把这里删掉"。
  • 旧数据抢救钩子不能删:desktop/nsis/legacy-data-rescue.nsh(经 electron-builder.yml nsis.include 接入)在 customInit(早于对旧卸载器的调用)把安装目录里的 backend\data 复制到 $PLUGINSDIR 并逐文件校验,customInstall 再交回新 payload, 由应用的迁移流程完成搬家。跳过判据是旧目录里的 resume_forge.db 与 migrated-away.json(后者由 lib/data-migration.js 在迁移成功后写入)——不要让安装器 去读 %APPDATA% 猜 userData 路径。改动它(或换 NSIS 配置)必须跑 desktop/scripts/test-nsis-rescue.js;.nsh 里有中文就必须 UTF-8 BOM——Unicode NSIS 对无 BOM 的文件按系统码页解码,弹窗文案会变乱码。
  • 安装包 sidecar 必须是两空格格式(<hex>+两个空格+文件名,ASCII、无 * 前缀): 桌面更新器把 sidecar 里的文件名与发布资产名逐字比较,coreutils 的 hash *file 形式会被 判为非法格式而拒绝更新。scripts/Build-Desktop.ps1 -Target win 现在会自动写出与 CI 同格式的 sidecar;手工发版时两个文件一起挂。
  • 桌面离线门禁在 desktop/ 下跑:node --test "scripts/test-*.js"(CI 的 desktop-shell 作业 紧接着真的打一次 NSIS 包,语法错误会在那里暴露)。

版本与发布

  • 不要手工逐个文件改版本号。 在仓库根目录执行 python scripts/bump_version.py:它按自上一个 v* 标签以来的提交类型判定幅度,并同步全部位置。加 --dry-run 只预览不改文件;自动判定不满意时用 --bump major|minor|patch 覆盖。

  • 幅度规则:标题带 !(如 feat!:)或正文含 BREAKING CHANGE → major;feat → minor;fix → patch;docs/chore/test/refactor/style/ci 不推动版本号。提交前缀写错会让发版幅度算错,请继续遵循 Conventional Commits。

  • 版本号有 5 处必须一致:backend/app/config.py 的 app_version、frontend/package.json 的 version、frontend/package-lock.json 的根包版本、README.md 顶部的"当前版本"、CHANGELOG.md 的最新条目。backend/tests/test_version_consistency.py 会在 CI 上校验前四处。

  • 本仓库从未使用过 BREAKING CHANGE 标记,所以自动判定实际上只能产出 minor/patch。改动涉及破坏性变更(如删除已发布功能、不可逆的数据库迁移)时必须显式传 --bump 覆盖——但传哪一个取决于是否已经 1.0:

    当前还在 0.x,破坏性变更传 --bump minor。 这是 semver 对 0.x 的惯例:0.x 阶段本来就不承诺兼容,破坏性变更只让 minor 前进(0.12.0 → 0.13.0),major 位留给"这个项目已经稳定"这个宣告本身。2026-09-26 发 0.13.0 时(删掉「官网采集」与「按岗位找公司」两个已发布功能)就是这么定的,维护者明确选择不借一次删功能冲进 1.0。等正式发布 1.0 之后,破坏性变更才改用 --bump major。

    脚本检测到新增 migration 会提醒复核,但不会替你判断——remove: 不是脚本认的前缀,删功能的提交它一律算"不推动版本号",所以这类发版别信自动判定,直接看本节。

  • 脚本只改文件,不 commit、不打 tag——发布由用户发起(见"工作流程"第 6 条)。跑完按它打印的命令手动提交与打标签。

提交署名(硬性要求)

提交的 author 与 committer 必须是维护者本人的身份,提交信息里不得出现任何 AI 或工具的署名。无论你用的是 Claude、CodeBuddy、Copilot、Cursor 还是别的助手,都适用:

  • 不写 Co-Authored-By:、Generated-by:、Assisted-by:、Signed-off-by: 之类的尾注,也不要写 AI 服务商的邮箱;
  • 需要说明某个改动由 AI 协助完成时,把这句话放进正文的普通句子里——写成 trailer 就会被 GitHub 解析成署名;
  • 不为此修改 git config user.name / user.email。

原因不在署名本身,而在它的副作用:GitHub 会把 Co-Authored-By 里的邮箱解析成账号,把这些提交算作该账号的贡献,于是公开仓库的贡献者列表里会出现 AI 账号。本仓库在 2026-09-16 因此出现过 claude 贡献者,清理方式是改写历史(仅维护者明确授权后执行):

git bundle create runtime\git-history-backup-<时间戳>.bundle --all        # 先备份全部历史
pip install git-filter-repo
git filter-repo --replace-message <表达式文件> --force                     # 文件内容:regex:(?m)^Co-Authored-By: .*<要删的邮箱>[ \t]*\r?\n?==>
git remote add origin https://github.com/magicapple123/ResumeForge.git  # filter-repo 会移除 origin
git push --force-with-lease origin main
git push --force origin <受影响的标签>

改写会改变受影响提交及其所有后代的 hash,已 clone/fork 的人需要重新拉取;改写前创建的 runtime/git-history-backup-*.bundle 是唯一的本地回退点,不要删除它。