diff --git a/README.md b/README.md index b8cf0ee..14d4c05 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 coding-start(瘦入口 skill)校验 Plan 终结闸 → 启动 Workflow - coding.mjs Router → 解析 docs/08 § 二/§ 三 + git tag,列出待跑模块 + coding.mjs Router → 解析 docs/08 § 二/§ 三 + milestone/* / req-done/* git tag,列出待跑模块 │ ├─ B-后端(按模块循环,每模块一个里程碑 tag;功能链顺序 for-await,单工作树串行 commit) │ runBranchSetup(module-) ← JS 编排:detect default → wt clean → exists? → @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 ``` Plan 全部完成后由你显式触发;详细职责见下方 Skill 清单。详细流程见上方阶段 B 流程图。 -4. **中途恢复**:任何时候重跑 `/erp-workflow:coding-start`——`coding.mjs` 的 Router 根据 docs/08 § 二/§ 三 里程碑字段 + 本地 git tag 跳到当前该做的模块/阶段。 +4. **中途恢复**:任何时候重跑 `/erp-workflow:coding-start`——`coding.mjs` 的 Router 根据 docs/08 § 二/§ 三 里程碑字段 + 本地 `milestone/*` / `req-done/*` tag 跳到当前该做的模块/阶段。 ## 目录结构 @@ -117,7 +117,7 @@ erp-workflow-plugin/ | Skill | 作用 | 谁调用 | |---|---|---| | `plan-start` | **A 阶段入口 + Plan 终结硬闸**。读 docs/08 § 一 找第一个未勾 A 子项 → 派发对应 A skill(含 A6 → `frontend-scope-lock`);A 全部完成时校验 5 项前移闸门(REQ 真实数据、`.env.local` secrets 全锁 + `config-vars.yaml` 配置字段全锁、docs/04 § 零 命令齐、docs/05+02 已评审、A6 前端 scope 已锁),全过才提示运行 `/erp-workflow:coding-start`,否则指出缺口不放行 | **用户手动** `/erp-workflow:plan-start` | -| `coding-start` | **B 阶段瘦入口**(`allowed-tools: Read Glob Workflow`)。校验 Plan 终结闸(docs/08 § 一 全勾、git 在默认分支、工作树干净)→ 读 docs/08 § 二/§ 三 + `git tag -l 'milestone/*'` 概述进度 → 调用 `Workflow({scriptPath:"${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", args:{projectRoot}})` 启动整个编码阶段 → 告知"已在后台启动" | **用户手动** `/erp-workflow:coding-start` | +| `coding-start` | **B 阶段瘦入口**(`allowed-tools: Read Glob Workflow Bash(git ...) Bash(pwd)`)。校验 Plan 终结闸(docs/08 § 一 全勾、git 在默认分支、工作树干净)→ 读 docs/08 § 二/§ 三 + `git tag -l 'milestone/*'` 概述阶段进度(Workflow Router 再用 `req-done/*` 判定功能级 resume)→ 调用 `Workflow({scriptPath:"${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", args:{projectRoot}})` 启动整个编码阶段 → 告知"已在后台启动" | **用户手动** `/erp-workflow:coding-start` | ### Plan 阶段 A skill(A0~A6,共 7 个) @@ -157,7 +157,7 @@ erp-workflow-plugin/ | skeleton-gen | `docs-07-env-template.md` | docs/07 环境配置大纲(只记规则/约定,不记具体值;配置值指向 config-vars.yaml + .env.local) | | skeleton-gen | `docs-09-structure-template.md` | docs/09 目录结构大纲 | | skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(安全 env 解析,无 shell-source);schema apply 交给 Flyway | -| skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位 {{TEST_CMD}} / {{E2E_CMD}},`spawnSync(shell:true)` 跨平台执行) | +| skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) | | skeleton-gen | `env-local-template` | 凭据模板(DB_* + JWT_SECRET);A2 据 config-vars.yaml `secrets_ref` 追加项目专属 secret 键 | | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.env.local`、`.tmp/`、构建产物等) | | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 | @@ -173,9 +173,9 @@ erp-workflow-plugin/ ## 前置依赖 -- **Node.js ≥ 18**:`lib/*.mjs` 助手 + `workflows/coding.mjs` + 生成进目标项目的 `scripts/*.mjs` 均为 ESM;CC 运行时自带。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 打印安装指引并 halt(不自动安装) -- **MySQL 8.x** 实例已就绪(推荐本地 / `*.local` host;A4 `db-init` 的安全守护要求 host 在白名单且 schema 名含 `test`/`dev`/`local`,避免误删生产库) -- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 安全 env 解析 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库 +- **Node.js ≥ 18**:`lib/*.mjs` 助手 + 生成进目标项目的 `scripts/*.mjs` 为 Node ESM;`workflows/coding.mjs` 是 Claude Workflow 运行时脚本(由 `Workflow` 工具执行,不作为普通 `node` CLI 入口)。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 打印安装指引并 halt(不自动安装) +- **MySQL 8.x** 实例已就绪(推荐本地 / `*.local` host;A4 `db-init` 的安全守护要求 host 在白名单且 schema 名含 `test` 或以 `_dev` / `_local` / `_ci` 结尾,避免误删生产库) +- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 安全 env 解析 apply V1;`apply-ddl.mjs` 与生成的 `scripts/setup-test-db.mjs` 都会校验 host 白名单 + schema 名测试库特征,生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库 - **Spring Boot + Flyway**(**必需**):pom.xml 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用 - **本地 git 仓库**(纯本地,无需远程):A0 `project-init` 执行 `git init`;B 阶段每模块由 `coding.mjs` 的 milestone stage 本地 `git merge --no-ff` 进默认分支并 `git tag -a milestone/`,完成信号由 `git tag -l` 判定。**不依赖任何远程仓库 / push / GitLab** - **本地可运行 `mvn test` / `pnpm test`**:测试命令由 A1 写入 docs/04 § 零,生成的 `scripts/test.mjs` 由 `skeleton-gen` 产出,`coding.mjs` 的 testGate stage 调用 diff --git a/lib/apply-ddl.mjs b/lib/apply-ddl.mjs index e2dea69..fe14e34 100644 --- a/lib/apply-ddl.mjs +++ b/lib/apply-ddl.mjs @@ -55,6 +55,11 @@ export function parseEnv(text) { export async function applyDDL({ envPath, ddlPath }) { const { readFileSync } = await import('node:fs') + const env = parseEnv(readFileSync(envPath, 'utf8')) + const ddl = readFileSync(ddlPath, 'utf8') + const { host, port, user, password, database } = resolveDbConfig(env, envPath) + assertSafeDbTarget({ host, database, env, label: 'apply-ddl' }) + let mysql try { ;({ default: mysql } = await import('mysql2/promise')) @@ -62,10 +67,6 @@ export async function applyDDL({ envPath, ddlPath }) { throw new MysqlUnavailableError() } - const env = parseEnv(readFileSync(envPath, 'utf8')) - const ddl = readFileSync(ddlPath, 'utf8') - const { host, port, user, password, database } = resolveDbConfig(env, envPath) - const conn = await mysql.createConnection({ host, port, @@ -100,9 +101,37 @@ export function resolveDbConfig(env, envPath = '.env.local') { if (!database) { throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`) } + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`apply-ddl: DB_PORT 非法 — ${envPath} 中端口必须是 1..65535 的整数`) + } return { host, port, user, password, database } } +/** + * Fail closed for direct DDL application. setup-test-db.mjs has the same guard + * before DROP+CREATE; apply-ddl repeats it so direct CLI use cannot hit prod. + * + * @param {{host:string, database:string, env?:Record, label?:string}} opts + * @returns {true} + */ +export function assertSafeDbTarget({ host, database, env = {}, label = 'apply-ddl' }) { + const extraHosts = String(env.TEST_DB_ALLOWED_HOSTS || '') + .split(/[\s,]+/) + .filter(Boolean) + const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts] + if (!allowedHosts.includes(host)) { + throw new Error(`${label}: 拒绝连接非白名单 host (${host});如确认是测试库,请在 .env.local 设置 TEST_DB_ALLOWED_HOSTS`) + } + if (!/^[A-Za-z0-9_]+$/.test(database)) { + throw new Error(`${label}: DB_SCHEMA 只能包含字母、数字、下划线,当前为 ${JSON.stringify(database)}`) + } + const looksLikeTest = /test/i.test(database) || /_dev$/i.test(database) || /_local$/i.test(database) || /_ci$/i.test(database) + if (!looksLikeTest) { + throw new Error(`${label}: schema '${database}' 不像测试/开发库(需含 test 或以 _dev/_local/_ci 结尾)`) + } + return true +} + /** Distinct error type so the CLI can emit a friendly install hint. */ export class MysqlUnavailableError extends Error { constructor() { diff --git a/lib/apply-ddl.test.mjs b/lib/apply-ddl.test.mjs index d2208a0..7bc2b90 100644 --- a/lib/apply-ddl.test.mjs +++ b/lib/apply-ddl.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { parseEnv, resolveDbConfig } from './apply-ddl.mjs' +import { assertSafeDbTarget, parseEnv, resolveDbConfig } from './apply-ddl.mjs' test('parseEnv ignores comments, trims, keeps special chars literally', () => { const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') @@ -84,3 +84,35 @@ test('resolveDbConfig applies sane defaults for host/port/user/password', () => assert.equal(c.user, 'root') assert.equal(c.password, '') }) + +test('resolveDbConfig rejects invalid ports', () => { + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: 'abc' }), /DB_PORT/) + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: '70000' }), /DB_PORT/) +}) + +test('assertSafeDbTarget allows local and explicitly allowlisted test targets', () => { + assert.equal(assertSafeDbTarget({ host: 'localhost', database: 'erp_test' }), true) + assert.equal( + assertSafeDbTarget({ + host: 'mysql.dev.internal', + database: 'erp_dev', + env: { TEST_DB_ALLOWED_HOSTS: 'mysql.dev.internal' }, + }), + true + ) +}) + +test('assertSafeDbTarget rejects prod-looking or injectable targets', () => { + assert.throws( + () => assertSafeDbTarget({ host: 'prod.db.internal', database: 'erp_test' }), + /非白名单 host/ + ) + assert.throws( + () => assertSafeDbTarget({ host: 'localhost', database: 'erp_prod' }), + /不像测试\/开发库/ + ) + assert.throws( + () => assertSafeDbTarget({ host: 'localhost', database: 'erp_test`; DROP DATABASE prod; --' }), + /只能包含/ + ) +}) diff --git a/skills/coding/coding-start/SKILL.md b/skills/coding/coding-start/SKILL.md index d5dbe2c..35649da 100644 --- a/skills/coding/coding-start/SKILL.md +++ b/skills/coding/coding-start/SKILL.md @@ -2,7 +2,7 @@ name: coding-start description: B 阶段(Coding)瘦入口。校验 Plan 终结闸(docs/08 §一 A0~A6 全勾、已在本地默认分支、工作树干净)后,读取 docs/08 §二/§三 概述模块/前端进度,然后调用 workflows/coding.mjs Workflow 全自动、静默地跑完整个编码阶段(后端+前端功能循环、测试闸、里程碑 tag),跑完或 halt 时返回最终状态。本入口不写任何文件、不做编码决策。 user-invocable: true -allowed-tools: Read Glob Workflow +allowed-tools: Read Glob Workflow Bash(pwd) Bash(git rev-parse *) Bash(git status *) Bash(git branch *) Bash(git tag *) --- **所有输出必须使用中文。** @@ -44,11 +44,13 @@ allowed-tools: Read Glob Workflow - 任一未勾 → 缺口:`Plan 未完成(<未勾项>)→ 先运行 /erp-workflow:plan-start`。 2. **当前在本地默认分支(main / master)** - - 本入口无 Bash,无法直接查 git。改为信任用户:在放行横幅中**显式要求**用户确认当前已在默认分支。若用户已说明不在默认分支,则拦截。 - - (权威的分支/tag 状态由 `coding.mjs` 的 milestone stage 在 merge 时校验。) + - 用 `Bash(git rev-parse --show-toplevel)` 确认当前目录在 git 仓库中,并记录 stdout 作为 `projectRoot`。 + - 用 `Bash(git branch --show-current)` 读取当前分支。 + - 用 `Bash(git rev-parse --verify refs/heads/main)` / `refs/heads/master` 依次检测本地默认分支,取第一个存在者;两者都不存在则缺口:`找不到本地默认分支 main/master`。 + - 当前分支不等于默认分支 → 缺口:`当前分支 不是默认分支 `。 3. **工作树干净(Plan 产物已 commit)** - - 同样无法用 Bash 直接查。在放行横幅中**显式要求**用户确认工作树干净、Plan 产物已提交。 + - 用 `Bash(git status --porcelain)` 检查,stdout 非空即缺口:`工作树不干净(列出前 20 行 dirty 路径)`。 任一缺口 → 输出拦截横幅,逐条列出缺口与回填位置,**停下**,不启动 Workflow: @@ -65,7 +67,7 @@ allowed-tools: Read Glob Workflow ### 步骤 3:概述进度(信息提要) -仅当步骤 2 的 § 一 校验通过后,`Read` `docs/08 § 二`(后端模块元数据 + `里程碑:` 字段)与 `§ 三`(前端阶段 `整体里程碑:` 字段),概述: +仅当步骤 2 的 § 一 校验通过后,`Read` `docs/08 § 二`(后端模块元数据 + `里程碑:` 字段)与 `§ 三`(前端阶段 `整体里程碑:` 字段),并用 `Bash(git tag -l "milestone/*")` 读取本地里程碑 tag,概述: - 后端:每个模块的 `里程碑:` 是否已是 `milestone/`(已完成)还是 `—`(待跑)。 - 前端:`§ 三 整体里程碑:` 是否已是 `milestone/frontend-phase`(已完成)还是 `—`(待跑)。 @@ -74,12 +76,12 @@ allowed-tools: Read Glob Workflow ### 步骤 4:启动 Coding Workflow -用 `Workflow` 工具调用编码编排脚本。`projectRoot` 必须是绝对路径(coding.mjs 顶部对相对路径硬校验,传 `.` 会立即 halt)。 +用 `Workflow` 工具调用编码编排脚本。`projectRoot` 必须使用步骤 2 里 `git rev-parse --show-toplevel` 得到的绝对路径(coding.mjs 顶部对相对路径硬校验,传 `.` 会立即 halt)。 ``` Workflow({ scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", - args: { projectRoot: "<当前项目根绝对路径>" } + args: { projectRoot: "" } }) ``` @@ -93,9 +95,9 @@ Workflow({ 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」> - ⚠️ 请确认(本入口无法程序化核对): - • 当前已在本地默认分支(main / master) - • 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit + 已程序化校验: + ✓ 当前在本地默认分支(main / master) + ✓ 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块 halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。 diff --git a/skills/plan/db-init/SKILL.md b/skills/plan/db-init/SKILL.md index cfb242d..cca2ba4 100644 --- a/skills/plan/db-init/SKILL.md +++ b/skills/plan/db-init/SKILL.md @@ -2,7 +2,7 @@ name: db-init description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 全量校验 DDL ↔ docs/03 一致性 → 验证 MySQL 连接 → 调 scripts/setup-test-db.mjs 复用三层防护并 DROP+CREATE 空库 → 用 lib/apply-ddl.mjs apply V1。 user-invocable: false -allowed-tools: Read Write Edit Glob Skill Bash(node *) Bash(mysql *) +allowed-tools: Read Write Edit Glob Skill Bash(node *) --- **所有输出必须使用中文。** @@ -58,10 +58,34 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ 用 `Glob` 确认 `.env.local` 存在(不存在 → 提示重跑 A2 `skeleton-gen` 并停下)。用 `Read` 逐行解析 `KEY=VALUE`(跳过空行 / `#` 注释,**不做 shell-source / 变量展开**),校验 `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_SCHEMA` 5 项均非空——任一缺失 → 打印缺失字段并停下。 -用解析出的值跑连通性自检: +用解析出的值跑连通性自检。必须用 Node `spawnSync('mysql', args, {shell:false})`,不要把密码拼进 shell 命令;空密码也要传 `--password=`,避免 `mysql -p` 进入交互式等待。 ```bash -mysql -h -P -u -p -e "SELECT 1;" +node -e ' +const { spawnSync } = require("node:child_process"); +const { readFileSync } = require("node:fs"); +const env = {}; +for (const raw of readFileSync(".env.local", "utf8").split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if (value.length >= 2 && ((value[0] === "\"" && value.at(-1) === "\"") || (value[0] === "'"'"'" && value.at(-1) === "'"'"'"))) value = value.slice(1, -1); + env[key] = value; +} +const args = [ + `--host=${env.DB_HOST}`, + `--port=${env.DB_PORT || "3306"}`, + `--user=${env.DB_USER}`, + `--password=${env.DB_PASSWORD || ""}`, + "-e", + "SELECT 1;" +]; +const r = spawnSync("mysql", args, { stdio: "inherit" }); +process.exit(r.status === null ? 1 : r.status); +' ``` 成功 → 进入步骤 C;失败 → 打印具体错误(认证 / 主机不可达 / 端口拒接)并停下。 @@ -78,7 +102,7 @@ node scripts/setup-test-db.mjs #### C.2 把 V1 灌入已清空的 schema -调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它用纯 JS 解析 `.env.local`(**不** shell-source,消除注入),再经 mysql2 把 DDL 灌入 schema。 +调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它用纯 JS 解析 `.env.local`(**不** shell-source,消除注入),复用 host 白名单 + schema 名安全闸,再经 mysql2 把 DDL 灌入 schema。 ```bash node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" .env.local sql/migrations/V1__initial_schema.sql diff --git a/skills/plan/project-init/SKILL.md b/skills/plan/project-init/SKILL.md index fda717f..ab03e04 100644 --- a/skills/plan/project-init/SKILL.md +++ b/skills/plan/project-init/SKILL.md @@ -70,9 +70,11 @@ if (missing.length) { ### C. 初始化 Git(如尚未初始化) -用 `Glob` 检查 `.git/` 目录是否存在。 -- 不存在 → 用 `Bash` 执行 `git init`。 -- 已存在 → 跳过。 +用 `Bash` 执行 `git rev-parse --is-inside-work-tree`。 +- 退出码 `0` 且 stdout 为 `true` → 已在 git 仓库中,跳过。 +- 非 `0` → 用 `Bash` 执行 `git init`。 + +不要用 `.git/` 目录是否存在判断:git worktree / 子模块 / 某些托管环境会使用 `.git` 文件,目录探测会误判。 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选(A0 子项 + A0 顶层): - ` - [ ] Git 已初始化` diff --git a/skills/plan/project-init/templates/docs-08-initial-template.md b/skills/plan/project-init/templates/docs-08-initial-template.md index eed4e6f..d15886e 100644 --- a/skills/plan/project-init/templates/docs-08-initial-template.md +++ b/skills/plan/project-init/templates/docs-08-initial-template.md @@ -2,7 +2,7 @@ > 全流程进度跟踪。CC 每完成一项产出就勾选一项。 > - **§ 一 Plan(A0~A6)**:`plan-start` 找第一个未勾 A 子项分发到对应 skill -> - **§ 二 Coding(模块)**:分发以 `docs/02-开发计划.md § 二 开发顺序清单` 为准;`coding-start` 按 docs/02 顺序扫描,对每个 REQ 所属模块查询本 § 二的 `里程碑:` 字段 + 本地 `git tag -l 'milestone/'`,找第一个未打里程碑模块分发。本 § 二 行序无语义,仅作模块元数据表 +> - **§ 二 Coding(模块)**:分发以 `docs/02-开发计划.md § 二 开发顺序清单` 为准;`coding.mjs` Router 按 docs/02 顺序扫描,对每个 REQ 所属模块查询本 § 二的 `里程碑:` 字段 + 本地 `git tag -l 'milestone/'` 判定模块完成,并用 `git tag -l 'req-done/'` 判定功能级跳过。本 § 二 行序无语义,仅作模块元数据表 ## 一、Plan 阶段(一次性) @@ -49,11 +49,11 @@ ## 二、Coding 阶段(后端模块循环) -(A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。) +(A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/` 之间变化,完成由本地 `git tag -l 'milestone/'` 判定。功能行 checkbox 只作可视化,真正的功能级 resume 由 `req-done/` tag 判定。后端模块全部打里程碑后自动进入 § 三 前端阶段。) ## 三、Coding 阶段(前端整体) -(FE 业务功能清单在 Plan 期 A6 `frontend-scope-lock` 由 prototype/ + docs/01 + docs/05 推导后写入下方"功能:"项;Coding 阶段 `coding.mjs` 的 Router 把全部未完成 FE 聚合为单一 `frontend-phase` 阶段,排在所有后端模块之后。整个前端阶段 1 个里程碑 tag,分支 `frontend-phase`。) +(FE 业务功能清单在 Plan 期 A6 `frontend-scope-lock` 由 prototype/ + docs/01 + docs/05 推导后写入下方"功能:"项;Coding 阶段 `coding.mjs` 的 Router 把缺少 `req-done/` tag 的 FE 聚合为单一 `frontend-phase` 阶段,排在所有后端模块之后。整个前端阶段 1 个里程碑 tag,分支 `frontend-phase`。) - 整体里程碑: — - 功能: diff --git a/skills/plan/scope-lock/SKILL.md b/skills/plan/scope-lock/SKILL.md index 8f64147..e618bb2 100644 --- a/skills/plan/scope-lock/SKILL.md +++ b/skills/plan/scope-lock/SKILL.md @@ -2,7 +2,7 @@ name: scope-lock description: A1 计划范围锁定——引导用户填写项目概述 + 技术栈 + 需求索引,并按模块子目录生成 REQ 卡片骨架(CC 推断 req_id/title/goal/rules/constraints/acceptance;输入/输出 字段表为结构化 6 列表单由人工逐行填真实数据);末尾执行 A1 终结校验:每张 REQ 卡片字段含真实数据、配置字段名锁进 config-vars.yaml(非敏感填值 + secrets_ref 键名引用 .env.local)、build/lint/unit/e2e 命令锁进 docs/04 §零,缺则当场 AskUserQuestion 问清。 user-invocable: false -allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bash(node *) Bash(rm *) +allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bash(node *) --- **所有输出必须使用中文。** @@ -27,7 +27,7 @@ allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bas 1. 用 `Grep` 校验 `docs/01-需求清单/index.md` 无 `【人工填写:` 残留;有则回步骤 C。 2. 用 `Read` 读 `index.md` 解析模块索引。 -3. **每模块/每 REQ 渲染**:`Write` vars.json → `mkdir -p` → `node render.mjs`。`` / `<模块名>` / `` 按 `index.md` 实际值替换。 +3. **每模块/每 REQ 渲染**:`mkdir -p` → `Write` vars.json → `node render.mjs`。`` / `<模块名>` / `` 按 `index.md` 实际值替换。 ```bash # 模块头:先 Write docs/01-需求清单/-<模块名>/_module.vars.json,内容形如 diff --git a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs index 675562f..838cdc5 100644 --- a/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs +++ b/skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs @@ -46,6 +46,16 @@ const DB_USER = env.DB_USER ?? '' const DB_PASSWORD = env.DB_PASSWORD ?? '' const DB_SCHEMA = env.DB_SCHEMA ?? '' +if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { + console.error(`[setup-test-db] DB_PORT 非法: ${DB_PORT}(必须是 1..65535 的整数)`) + process.exit(1) +} + +if (!/^[A-Za-z0-9_]+$/.test(DB_SCHEMA)) { + console.error(`[setup-test-db] DB_SCHEMA 只能包含字母、数字、下划线,当前为: ${DB_SCHEMA}`) + process.exit(1) +} + // 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 // 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。 const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '') @@ -85,10 +95,10 @@ const sql = `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` const mysqlArgs = [ - `-h${DB_HOST}`, - `-P${DB_PORT}`, - `-u${DB_USER}`, - `-p${DB_PASSWORD}`, + `--host=${DB_HOST}`, + `--port=${DB_PORT}`, + `--user=${DB_USER}`, + `--password=${DB_PASSWORD}`, '-e', sql, ] diff --git a/workflows/coding.mjs b/workflows/coding.mjs index 1accbf1..a54ef53 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -164,21 +164,23 @@ function routerPrompt(root) { '', '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。', '', - '## 读取来源(账本 = docs/08 + git tag,二者一致才算完成)', + '## 读取来源(账本 = docs/08 + git tag,里程碑和功能级完成都以 tag 为真值)', '1. `docs/08-模块任务管理.md § 二`(后端模块元数据):逐个模块取 `id`(英文蛇形 module id)、本模块的 REQ 列表(按 `docs/02-开发计划.md § 二 开发顺序清单` 的顺序,A5 约束保证同模块 REQ 连续),以及该模块的 `里程碑:` 字段。', '2. `docs/08-模块任务管理.md § 三`(前端阶段元数据):取 `整体里程碑:` 字段,以及 `功能:` 项下所有 `- [ ] FE-NN ...` / `- [x] FE-NN ...` 行(FE 清单)。前端 item 形如 `FE-NN`。', '3. `git -C tag -l "milestone/*"`:列出已打的里程碑 tag。', + '4. `git -C tag -l "req-done/*"`:列出已通过 review 并落地的功能级完成 tag。`docs/08` checkbox 只作可视化,不作为跳过功能的真值。', '', '## 完成判定(每个模块独立)', '- 后端模块 `done = true` 当且仅当:§二 该模块 `里程碑:` 字段 == `milestone/` **且** `git tag -l "milestone/"` 能查到该 tag。任一缺失 → `done = false`。', '- 前端 item(FE-NN)归属一个"逻辑前端模块"。前端阶段整体 `done` 当且仅当 §三 `整体里程碑:` == `milestone/frontend-phase` 且 `git tag -l "milestone/frontend-phase"` 存在。', + '- 后端 REQ / 前端 FE 的功能级完成判定:仅当 `git tag -l "req-done/"` 能查到该 tag 才视为已 approve。不要因为存在 review markdown 或 docs/08 checkbox 已勾就跳过;若 tag 缺失,必须把该 id 放回待跑列表。', '', '## 输出(必须符合下发的 JSON schema)', '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:', ' - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。', - ' - `done`: 该模块是否已完成(按上面的判定)。', - ' - `reqs`: **仅后端模块**填本模块**未完成**后端 REQ 的有序列表(已 `verdict=approve`,见 `docs/superpowers/reviews/*-.md` 的 REQ 跳过);模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。', - ' - `feItems`: **仅前端聚合模块**填——把**全部模块**的**未完成**前端 FE-NN 汇总为一个有序列表(已 approve 的 FE 跳过)放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。', + ' - `done`: 该模块/前端阶段是否已完成(按上面的 milestone 判定)。', + ' - `reqs`: **仅后端模块**填本模块**缺少 `req-done/` tag** 的后端 REQ 有序列表;模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。', + ' - `feItems`: **仅前端聚合模块**填——把**全部模块**缺少 `req-done/` tag 的前端 FE-NN 汇总为一个有序列表放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。', '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。', '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。', '', @@ -467,7 +469,7 @@ function detectDefaultBranchPromptM() { '# 检测本地默认分支', microStepContract(), '', - `用 \`git -C ${ROOT} rev-parse --verify \` 依次试 \`main\` / \`master\`,取第一个 exit=0 的为默认分支。`, + `用 \`git -C ${ROOT} rev-parse --verify refs/heads/\` 依次试 \`main\` / \`master\`,取第一个 exit=0 的为默认分支。`, '## 输出(DEFAULT_BRANCH_SCHEMA)', '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`', '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。', @@ -491,7 +493,7 @@ function checkBranchExistsPromptM(branch) { `# 本地分支 \`${branch}\` 是否存在`, microStepContract(), '', - `跑 \`git -C ${ROOT} rev-parse --verify ${branch}\`(用 2>/dev/null 抑制 stderr)。`, + `跑 \`git -C ${ROOT} rev-parse --verify refs/heads/${branch}\`(用 2>/dev/null 抑制 stderr)。`, '## 输出(EXISTS_SCHEMA)', '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`', ].join('\n') @@ -634,8 +636,8 @@ function readDocs08CheckboxPromptM(fe, id) { const section = fe ? '§ 三' : '§ 二' const kind = fe ? '功能' : 'REQ' const locator = fe - ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。` - : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section},找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。` + ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。` + : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。` return [ `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, microStepContract(), @@ -644,18 +646,21 @@ function readDocs08CheckboxPromptM(fe, id) { '## 输出(CHECKBOX_STATE_SCHEMA)', `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, - '- 找不到:`{ "found": false }`', + '- 找不到:`{ "found": false, "state": "unchecked" }`(state 仍必填,避免 schema 失败掩盖真实缺口)。', ].join('\n') } -function writeDocs08CheckboxPromptM(fe, id, phase) { +function writeDocs08CheckboxPromptM(fe, id, phase, lineNumber) { const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}` + const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber)) + ? `先 Read \`${ROOT}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行去掉行首空白后以 \`- [ ] ${id} \` 开头;不满足则返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual="}\`。然后只替换第 ${lineNumber} 行的第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。` + : `定位 docs/08 ${scope} 中去掉行首空白后以 \`- [ ] ${id} \` 开头的唯一一行,只替换该行第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。` return [ `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`, microStepContract(), '', `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`, - `1. Edit \`${ROOT}/docs/08-模块任务管理.md\`:把以 \`- [ ] ${id} \` 开头的整行替换为对应的 \`- [x] ${id} ...\`(保留原行 id 之后的全部文本,仅 \`[ ]\` → \`[x]\`,精确字符串替换;只动一处)。`, + `1. ${lineGuard}`, `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`, `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`, '## 输出(ACTION_RESULT_SCHEMA)', @@ -1019,7 +1024,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`) } if (cb.state === 'unchecked') { - const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) + const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`) } return { id, phase, approved:true, rounds:round } @@ -1122,8 +1127,8 @@ const pending = haltedAtIdx >= 0 : [] // Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。 -// 注:顶层 `return` 在 CommonJS 中合法,但在 ESM 中非法。本脚本被 Workflow 运行时以 ESM 方式 -// (dynamic import)加载时,运行时会把脚本体包进 async function 再执行,于是顶层 `return` 实际成为 -// Workflow 的结果通道(与 `export const meta` 并存)。**不要**改成 `export default {...}` —— 那 -// 会破坏返回值契约,Workflow 拿不到 results / pending。 +// 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行, +// 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。 +// 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`, +// 否则 Workflow 拿不到 results / pending。 return { results, pending }