diff --git a/docs/guide/SDD.md b/docs/guide/SDD.md new file mode 100644 index 000000000..bb2822e7c --- /dev/null +++ b/docs/guide/SDD.md @@ -0,0 +1,477 @@ +--- +title: SDD 规格驱动开发 +group: 研发 +order: 6 +--- + +# SDD + Harness 实践指南(dt-react-component) + +**一句话:** 人维护 `*.brief.md` → AI 生成 `*.spec.yaml` → 派生**测试、API、实现(④)、Demo(②′ 人工触发)、Harness(②″ 可选)**。 + +**试点组件:** [Copy](../../src/copy/Copy.brief.md) + +## 流程总览 + +### 架构:谁维护什么 + +```mermaid +flowchart TB + subgraph Human["人维护"] + B["*.brief.md
行为契约 · Props 语义"] + DEMO_REF["index.md 示例引用
title / description"] + end + + subgraph AI["AI 生成 · 人审"] + S["*.spec.yaml
机器 SSOT"] + end + + subgraph Derived["从 spec 派生"] + IMPL["index.tsx
Prompt ④"] + TEST["__tests__/*.test.tsx
Prompt ②"] + API["index.md API 表
脚本自动"] + DEMOGEN["demos/*.tsx
Prompt ②′ 人工触发"] + HAR["demos/harness.tsx
Prompt ②″ 可选"] + end + + B -->|"Prompt ①"| S + S --> IMPL + S --> TEST + S --> API + S -.->|"人工命令 AI"| DEMOGEN + S -.-> HAR + DEMOGEN --> DEMO_REF +``` + +### 首次建链(新组件 / 试点接入) + +```mermaid +flowchart TD + Start([开始]) --> S1["写 Component.brief.md"] + S1 --> S2["Prompt ① → spec.yaml"] + S2 --> R1{"审 spec diff
审查三问 #1"} + R1 -->|不通过| S1 + R1 -->|通过| S3["index.md API 区加 @generated 标记
(示例区不动)"] + S3 --> S4["Prompt ④ → index.tsx
新组件写初版 / 存量最小 diff"] + S4 --> R2{"人审实现 diff
审查第四问"} + R2 -->|不通过| S4 + R2 -->|通过| S5["Prompt ② → 单元测试"] + S5 --> S6["pnpm test"] + S6 --> R3{"测试全绿?"} + R3 -->|否| S4 + R3 -->|是| S7["pnpm spec:generate --artifact api"] + S7 --> S8["npm run dev 验收"] + S8 --> OPTD{"需要新 demo?"} + OPTD -->|是| S10["Prompt ②′ 生成 demo
人审 · 手贴 index.md 引用"] + OPTD -->|否| OPT{"需要 Harness?"} + S10 --> OPT + OPT -->|是| S9["Prompt ②″ → harness.tsx"] + OPT -->|否| Done([试点建链完成]) + S9 --> Done +``` + +### 改需求级联(日常维护) + +```mermaid +flowchart TD + Start([需求变更]) --> S1["改 brief.md"] + S1 --> S2["Prompt ③ 或 ①
regenerate spec.yaml"] + S2 --> R1{"审 spec diff"} + R1 -->|不通过| S1 + R1 -->|通过| S3["Prompt ④
index.tsx 最小 diff"] + S3 --> R2{"人审实现"} + R2 -->|不通过| S3 + R2 -->|通过| S4["Prompt ②
regenerate 测试"] + S4 --> S5["pnpm test"] + S5 --> R3{"全绿?"} + R3 -->|否| S3 + R3 -->|是| S6["pnpm spec:generate --artifact api"] + S6 --> OPT1{"regen harness?"} + OPT1 -->|是| S7["Prompt ②″"] + OPT1 -->|否| OPT2{"demo 要更新?"} + S7 --> OPT2 + OPT2 -->|是| S8["Prompt ②′ 生成 demo
人审 · 手贴 index.md 引用"] + OPT2 -->|否| Review + S8 --> Review["审查三问 + 第四问 → PR"] + Review --> Done([完成]) +``` + +### 产物更新方式一览 + +```mermaid +flowchart LR + SPEC["spec.yaml"] + + SPEC -->|"Prompt ④ · 人审"| IMPL["index.tsx"] + SPEC -->|"Prompt ② · regenerate"| TEST["测试"] + SPEC -->|"脚本自动"| API["index.md API"] + SPEC -->|"Prompt ②′ · 人工触发"| DEMO["demos/*.tsx"] + SPEC -.->|"Prompt ②″ · 可选"| HAR["harness"] + DEMO -->|"人贴引用"| IDX["index.md 示例区"] + + style API fill:#e6f7ff + style DEMO fill:#fff7e6 + style IMPL fill:#f6ffed + style IDX fill:#fff7e6 +``` + +### 全库推广(opt-in) + +```mermaid +flowchart TD + Start([团队决策]) --> Q1{"组件类型?"} + Q1 -->|"有行为分支 · 常改"| P0["P0:Copy / StatusTag / FilterRules"] + Q1 -->|"antd 二次封装"| P1["P1:Drawer / PopConfirm / Catalogue"] + Q1 -->|"纯展示 · 少分支"| Skip["暂缓 SDD"] + Q1 -->|"新建组件"| P2["P2:直接走首次建链"] + + P0 --> Onboard["执行「首次建链」流程"] + P1 --> Onboard + P2 --> Onboard + Onboard --> Done["该组件目录出现
brief + spec + @generated API"] + Done --> Loop["改需求走「级联」流程"] +``` + +### 开发者入口:我该走哪条路? + +```mermaid +flowchart TD + Start([开发者接到任务]) --> Q1{"改动类型?"} + + Q1 -->|"首次为组件接入 SDD
(如 Drawer)"| A["场景 A · 首次建链"] + Q1 -->|"已有 brief,要改行为"| B["场景 B · 改需求级联"] + Q1 -->|"新组件从零开发"| C["场景 C · 新组件"] + Q1 -->|"只改样式 / 文案"| D["不走 SDD
直接改代码 + 现有测试"] + Q1 -->|"只补 demo 展示"| E["Prompt ②′ demo
brief 可不动"] + + A --> Branch["git checkout -b feat/sdd-{component}"] + C --> Branch + B --> BriefEdit["改 Component.brief.md"] + BriefEdit --> Cascade + Branch --> Cascade["见下方详细流程"] +``` + +### 场景 A / C:首次建链(Drawer、新组件) + +```mermaid +flowchart TD + Start([开始]) --> S1["1. 建分支 feat/sdd-{component}"] + S1 --> S2["2. 写 src/{c}/{Component}.brief.md
含 Props · 场景 · Antd继承 · outOfScope"] + S2 --> S3["3. Prompt ① → {Component}.spec.yaml"] + S3 --> R1{"4. 审 spec
props 仅封装层?
extendsAntd 正确?"} + R1 -->|否| S2 + R1 -->|是| S4["5. index.md API 加 @generated 标记
示例区不动"] + S4 --> S5["6. pnpm spec:generate -- --artifact impl
Prompt ④ → 人审 index.tsx"] + S5 --> S6["7. pnpm spec:generate -- --artifact test
Prompt ② → pnpm test"] + S6 --> R2{"测试全绿?"} + R2 -->|否| S5 + R2 -->|是| S7["8. pnpm spec:generate -- --artifact api
(可先 --dry-run)"] + S7 --> S8["9. npm run dev 验收"] + S8 --> OPT1{"需要新 demo?"} + OPT1 -->|是| S9["10. pnpm spec:generate -- --artifact demo
②′ → 人审 → 手贴 index.md"] + OPT1 -->|否| OPT2{"需要 Harness?"} + S9 --> OPT2 + OPT2 -->|是| S10["pnpm spec:generate -- --artifact harness"] + OPT2 -->|否| PR + S10 --> PR["11. PR:brief + spec + 测试 + API 区块
+ index.tsx(如有)"] + PR --> Done([完成]) +``` + +### 场景 B:已有 brief · 组件行为变更 + +```mermaid +flowchart TD + Start([行为变更]) --> S1["1. 改 Component.brief.md"] + S1 --> S2["2. Prompt ① 或 ③
regenerate spec.yaml"] + S2 --> R1{"3. 审 spec diff"} + R1 -->|否| S1 + R1 -->|是| S3["4. pnpm spec:generate -- --artifact impl
Prompt ④ · 人审 index.tsx"] + S3 --> S4["5. pnpm spec:generate -- --artifact test
Prompt ②"] + S4 --> S5["6. pnpm test"] + S5 --> R2{"全绿?"} + R2 -->|否| S3 + R2 -->|是| S6["7. pnpm spec:generate -- --artifact api"] + S6 --> OPT1{"新行为要 demo 展示?"} + OPT1 -->|是| S7["8. pnpm spec:generate -- --artifact demo
人审 · 手贴 index.md"] + OPT1 -->|否| OPT2{"regen harness?"} + S7 --> OPT2 + OPT2 -->|是| S8["Prompt ②″"] + OPT2 -->|否| Review + S8 --> Review["9. 审查清单 → PR"] + Review --> Done([完成]) +``` + +### spec:generate 与各 Prompt 对应关系 + +```mermaid +flowchart LR + subgraph Manual["SDD.md 手动复制"] + P1["Prompt ①
brief → spec"] + P3["Prompt ③
级联统筹"] + end + + subgraph CLI["pnpm spec:generate -- --component X"] + IMPL["--artifact impl
Prompt ④"] + TEST["--artifact test
Prompt ②"] + DEMO["--artifact demo
Prompt ②′"] + API["--artifact api
脚本自动写 API"] + HAR["--artifact harness
Prompt ②″"] + end + + P1 --> SPEC["*.spec.yaml"] + SPEC --> IMPL + SPEC --> TEST + SPEC --> DEMO + SPEC --> API + SPEC --> HAR + P3 -.->|"编排顺序"| IMPL +``` + +## 设计原则(低变更) + +| 区块 | 谁维护 | SDD 介入方式 | +| ------------------------------------------------------------- | ------------------------- | ---------------------------------------------- | +| `index.md` **示例引用**(title / description / ``) | 人贴入 | **②′ 输出建议行**,不自动改 index.md | +| `demos/*.tsx` | AI 生成 + **人审** | **Prompt ②′**(人工按需触发,非级联必跑) | +| `index.md` **API 表** | spec 派生 | **自动** — `pnpm spec:generate --artifact api` | +| `index.tsx` **实现** | spec 驱动 + **人审 diff** | **Prompt ④** | +| `__tests__/*.test.tsx` | spec 派生 | **Prompt ②** | +| `demos/harness.tsx` | spec 派生(可选) | **Prompt ②″** | + +### 为什么 `index.tsx` 不用一键 regenerate? + +| 产物 | 与 spec 关系 | 更新方式 | +| ----------- | ------------------------------------- | ----------------------------- | +| 测试 / API | spec 的**直接翻译** | 可整段 regenerate | +| `index.tsx` | spec 的**一种实现**(多种写法都合法) | **Prompt ④ 最小 diff + 人审** | + +spec 约束「该怎么表现」;实现里的 hooks 拆分、性能、样式细节仍由人把关,避免覆盖已有工程决策。 + +SDD 为 **opt-in**:仅带 `*.brief.md` 的组件走此流程,其它组件零影响。 + +## 目录约定 + +```plain +src/{component}/ +├── {Component}.brief.md # 人维护 · 行为 + Props 语义 +├── {Component}.spec.yaml # AI 生成 · 人审 +├── index.tsx # Prompt ④ 按 spec 最小更新 · 人审 +├── index.md # 示例手维护;API 表为 @generated 区块 +├── __tests__/{component}.test.tsx +└── demos/ + ├── basic.tsx … # Prompt ②′ 生成 · 人审 + └── harness.tsx # 可选 +``` + +## 工作流 + +### 首次建链 + +```plain +① 写 {Component}.brief.md +② Prompt ① → spec.yaml → 审 spec diff +③ Prompt ④ → index.tsx 最小 diff(新组件则实现初始版本)→ 人审 +④ Prompt ② → 测试 → pnpm test +⑤ pnpm spec:generate -- --component {Name} --artifact api +⑥ (按需)Prompt ②′ → demos/*.tsx → 人审 → 手贴 index.md 引用 +⑦ (可选)Prompt ②″ → demos/harness.tsx +``` + +### 改需求(级联 · Prompt ③) + +```plain +改 brief + → Prompt ③ / ① regenerate spec.yaml → 审 spec + → Prompt ④ 更新 index.tsx(最小 diff)→ 人审 + → Prompt ② regenerate 测试 → pnpm test + → pnpm spec:generate -- --artifact api + → (按需)Prompt ②′ demo → 人审 → 手贴 index.md + → (可选)regen harness +``` + +**推荐顺序:** brief → spec → **实现(④)** → 测试(②)→ API(脚本)。先改实现,测试用于验证。 + +## Prompt 模板 + +将 `Copy` / `copy` 替换为目标组件名。 + +### Prompt ① brief + 源码 → spec + +```text +阅读 src/copy/Copy.brief.md 与 src/copy/index.tsx, +生成 src/copy/Copy.spec.yaml。 + +含:props(**仅封装层**)、extendsAntd(若继承 antd/rc)、 +scenarios、outOfScope、testFixtures、harnessMeta、linkedDemos。 + +antd 继承参考 src/drawer/index.md;brief 未写清处标 aiSupplement: true。 +@generated-from: Copy.brief.md +``` + +### Prompt ② spec → 测试 + +```text +根据 src/copy/Copy.spec.yaml 生成 src/copy/__tests__/copy.test.tsx。 +Jest + Testing Library;每个 scenarios.id 一个 it(...);不测 outOfScope。 +@generated-from: Copy.spec.yaml +``` + +### Prompt ②′ spec + 设计意图 → Demo(人工触发 · 人审) + +**不在自动级联内**——当你需要新增/改版 demo 时,先填「设计意图」,再在 Cursor 发送: + +```text +为 Copy 组件生成 dumi demo。 + +【设计意图 — 发送前填写】 +- demo 文件:demos/tooltip-variants.tsx(新建或覆盖) +- 展示目的:对比 tooltip 字符串 / 对象 / 函数三种写法 +- 重点 props:text, tooltip +- 对应 scenarioId(可选):tooltip-string, tooltip-object +- index.md 卡片 title:Tooltip 多种写法 +- index.md 卡片 description:字符串、对象、函数形式配置 tooltip + +【上下文 — AI 必读】 +- src/copy/Copy.brief.md +- src/copy/Copy.spec.yaml +- src/copy/index.tsx +- 风格参考:src/copy/demos/basic.tsx + +【生成要求】 +1. 写出 demos/tooltip-variants.tsx,import { Copy } from 'dt-react-component' +2. 按「重点 props」组合展示,取值符合 spec.props 语义;不测 outOfScope +3. 遵循 RC demo 风格(BlockHeader / Space / 示例长文本等),单文件聚焦一个主题 +4. 每个子示例用小标题区分,便于 dumi 卡片内阅读 +5. **不要**直接修改 index.md;在回复末尾输出: + - 建议粘贴的 `` 一行 + - 若需更新 spec,输出 linkedDemos 片段供人粘贴 +6. 生成后自查:npm run dev 下该 demo 可独立运行 +``` + +获取 Prompt 骨架: + +```bash +pnpm spec:generate -- --component Copy --artifact demo +``` + +**与 harness 的区别:** ②′ 面向**文档读者**(好看、有叙事);②″ 面向**维护者验 spec**(控件 + 核对清单)。 + +### Prompt ②″ spec → Harness(可选) + +```text +根据 src/copy/Copy.spec.yaml 生成 src/copy/demos/harness.tsx。 +不要修改 index.md 的「示例」区块。 +``` + +### Prompt ④ spec → 实现(最小 diff · 人审) + +```text +阅读 src/copy/Copy.spec.yaml 与 src/copy/index.tsx。 + +1. 逐条对比 spec.scenarios 与当前实现,列出「已满足 / 缺失 / 行为不一致」 +2. 对 index.tsx 做**最小 diff**以满足 spec,要求: + - 只改与 scenarios / props 语义相关的代码 + - 不碰 outOfScope + - 不做无关重构(不重排 import、不改命名风格、不拆 hooks 除非 spec 要求) + - 保持 ICopyProps 与 spec.props 一致 +3. 若 spec 与 brief 冲突,在回复中指出,**不要擅自改 spec** +4. 改完后简述:每个 scenario.id 对应改了哪段逻辑 +``` + +**新组件(无 index.tsx 或空壳):** 同上 Prompt,第 2 步改为「按 spec 实现 index.tsx 初版」,仍遵循 outOfScope 与项目风格。 + +### Prompt ③ 组件变更 → 级联 + +```text +见 git diff(brief / index.tsx / 测试): + +1. 建议 Copy.brief.md 还需改哪几段 +2. regenerate Copy.spec.yaml 要点 +3. 按顺序列出需执行的 Prompt: + - ④ index.tsx(最小 diff) + - ② 测试 + - pnpm spec:generate -- --component Copy --artifact api + - (按需)②′ demo + - (可选)②″ harness +4. demo 是否需 Prompt ②′(对照 linkedDemos / 新 props 展示) +5. 审查三问 + 实现审查(见下)检查要点 +``` + +## 实现审查(Prompt ④ 之后 · 第四问) + +在「审查三问」基础上,改 `index.tsx` 后追加: + +4. **实现 diff 是否仅满足 spec,无 scope 外改动?** 每个变更能否对应到 scenario.id 或 props 语义? + +## index.md API 标记 + +首次接入时在 `### API` 下增加标记(**示例区块不动**): + +```markdown +### API + + + +| 参数 | 说明 | 类型 | 默认值 | +| ... | + + +``` + +```bash +pnpm spec:generate -- --component Copy --artifact api +pnpm spec:generate -- --component Copy --artifact api --dry-run +``` + +### 继承 antd props(参考 Drawer) + +API 表只列封装层 props;`extendsAntd` 自动生成 `:::info` 块: + +```yaml +extendsAntd: + component: Drawer + docUrl: https://4x.ant.design/components/drawer-cn/#API + version: '4.x' + omit: "Omit" # 可选 +``` + +## CLI + +```bash +pnpm spec:generate -- --component Copy # 全部 artifact + Prompt +pnpm spec:generate -- --component Copy --artifact impl # Prompt ④ +pnpm spec:generate -- --component Copy --artifact test # Prompt ② +pnpm spec:generate -- --component Copy --artifact demo # Prompt ②′ +pnpm spec:generate -- --component Copy --artifact api # 自动写 API +pnpm spec:generate -- --component Copy --artifact harness +``` + +| artifact | 行为 | +| --------- | ----------------------------------------------------------- | +| `api` | **自动**写 `index.md` API 区块 | +| `impl` | 打印 **Prompt ④**(最小 diff 改 `index.tsx`,人审) | +| `test` | 打印 Prompt ② | +| `demo` | 打印 **Prompt ②′**(生成 demos/\*.tsx,**人工触发**,人审) | +| `harness` | 打印 Prompt ②″(可选) | + +## 审查清单 + +1. spec 是否忠实反映 brief? +2. 测试 / API 是否覆盖 spec.props 与 scenarios,且未碰 outOfScope? +3. `pnpm test` 是否绿?API 表与 `index.tsx` interface 是否一致? +4. **(改 demo 时)** props 展示与 spec 一致?单 demo 聚焦一主题?index.md 引用已手贴? +5. **(改实现时)** index.tsx diff 是否最小、且每条变更可对应 scenario? + +## 验证清单(Copy 试点) + +- [ ] `Copy.brief.md` 入库 +- [ ] `Copy.spec.yaml` 审阅通过 +- [ ] Prompt ④ 对照 spec 审过 `index.tsx` +- [ ] `copy.test.tsx` 从 spec 派生,`pnpm test` 绿 +- [ ] `pnpm spec:generate -- --component Copy --artifact api` +- [ ] (按需)Prompt ②′ 生成 demo,`npm run dev` 目视验收 +- [ ] 完成一次 brief 变更 → ④ + ② + api 级联 + +## 延伸阅读 + +- 方法论:`AI Coding 2.0:SDD + Harness 实践指南` +- batch Phase 1:`ResGroupSelector.brief.md` / `ResGroupSelector.spec.yaml` diff --git a/package.json b/package.json index e46dbf8f2..51ddc3887 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "lint:es": "eslint \"src/**/*.{js,jsx,ts,tsx}\" \".dumi/**/*.{js,jsx,ts,tsx}\"", "prepublishOnly": "father doctor && npm run build", "deploy": "npm run docs:build && gh-pages -d docs-dist", - "release": "./scripts/release.sh" + "release": "./scripts/release.sh", + "spec:generate": "node scripts/generate-from-spec.js" }, "authors": [ "dtinsight UED" diff --git a/scripts/generate-from-spec.js b/scripts/generate-from-spec.js new file mode 100644 index 000000000..4ae479d2e --- /dev/null +++ b/scripts/generate-from-spec.js @@ -0,0 +1,424 @@ +#!/usr/bin/env node +/** + * SDD spec → artifact generator + * + * - api: deterministic sync of index.md API table from spec.props + * - test / harness: print Cursor prompts (codegen TBD) + * + * Usage: + * pnpm spec:generate -- --component Copy + * pnpm spec:generate -- --component Copy --artifact api + * pnpm spec:generate -- --component Copy --artifact test|impl|demo|api|harness|all + */ + +const fs = require('fs'); +const path = require('path'); + +const GENERATED_START = (component) => ``; +const GENERATED_END = ''; + +const ARTIFACTS = { + test: { + label: '单元测试', + output: (dir, _name, kebab) => path.join(dir, '__tests__', `${kebab}.test.tsx`), + prompt: (name) => + `根据 src/${toKebab(name)}/${name}.spec.yaml 生成 src/${toKebab( + name + )}/__tests__/${toKebab(name)}.test.tsx。\n` + + `Jest + Testing Library;每个 scenarios.id 一个 it(...);不测 outOfScope。\n` + + `@generated-from: ${name}.spec.yaml`, + write: false, + }, + api: { + label: 'index.md API 表', + output: (dir) => path.join(dir, 'index.md'), + prompt: (name) => + `(已由脚本自动生成)若需人工修复:根据 src/${toKebab( + name + )}/${name}.spec.yaml 的 props 段,` + + `更新 index.md 中 ${GENERATED_START(name)} 与 ${GENERATED_END} 之间的 API 表格。`, + write: true, + }, + impl: { + label: '组件实现 index.tsx(Prompt ④ · 最小 diff · 人审)', + output: (dir) => path.join(dir, 'index.tsx'), + prompt: (name) => + `阅读 src/${toKebab(name)}/${name}.spec.yaml 与 src/${toKebab(name)}/index.tsx。\n` + + `1. 列出 scenarios 与当前实现的差异\n` + + `2. 对 index.tsx 做**最小 diff**以满足 spec,不碰 outOfScope\n` + + `3. 不做无关重构;保持现有代码风格\n` + + `4. 若 spec 与 brief 冲突,指出而非擅自改 spec`, + write: false, + }, + demo: { + label: 'dumi Demo(Prompt ②′ · 人工触发 · 人审)', + output: (dir, _name, kebab) => path.join(dir, 'demos', `${kebab}-demo.tsx`), + prompt: (name) => buildDemoPrompt(name), + write: false, + }, + harness: { + label: 'Harness 交互验证(可选)', + output: (dir) => path.join(dir, 'demos', 'harness.tsx'), + prompt: (name) => + `根据 src/${toKebab(name)}/${name}.spec.yaml 生成 demos/harness.tsx。\n` + + `含 props 控件、预设场景按钮、预览区、规格核对清单。\n` + + `不要修改 index.md 的「示例」区块;可选在文档底部增加维护者入口。`, + write: false, + }, +}; + +function toKebab(name) { + return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); +} + +/** + * Prompt ②′ — demo 由人按需触发,填完「设计意图」段落后交给 AI。 + * @see docs/guide/SDD.md + */ +function buildDemoPrompt(name) { + const kebab = toKebab(name); + return ( + `为 src/${kebab}/ 生成 dumi demo。先阅读:\n` + + `- ${name}.brief.md、${name}.spec.yaml、index.tsx\n` + + `- 现有 demo 风格参考:demos/basic.tsx(或其它已有 demo)\n\n` + + `【使用前请填写以下设计意图,再发送本 Prompt】\n` + + `- demo 文件:demos/__________.tsx(新建或覆盖)\n` + + `- 展示目的:__________(如「对比 tooltip 三种写法」)\n` + + `- 重点 props:__________(如 tooltip, button, disabled)\n` + + `- 对应 scenarioId(可选):__________\n` + + `- index.md 卡片 title:__________\n` + + `- index.md 卡片 description:__________\n\n` + + `生成要求:\n` + + `1. 按上述 props 组合写可运行 demo,import { ${name} } from 'dt-react-component'\n` + + `2. 遵循 RC 现有 demo 风格(BlockHeader / Space / 示例文案等),单 demo 聚焦一个主题\n` + + `3. props 取值与 spec.props 语义一致,不展示 outOfScope 行为\n` + + `4. 输出末尾附:建议写入 index.md 的 一行(**不要**自动改 index.md)\n` + + `5. 若为新场景,建议在 spec linkedDemos 追加映射(输出 yaml 片段供人粘贴)` + ); +} + +function parseArgs(argv) { + const result = { component: null, artifact: 'all', dryRun: false }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--component' && argv[i + 1]) { + result.component = argv[++i]; + } else if (argv[i] === '--artifact' && argv[i + 1]) { + result.artifact = argv[++i]; + } else if (argv[i] === '--dry-run') { + result.dryRun = true; + } else if (argv[i] === '--help' || argv[i] === '-h') { + result.help = true; + } + } + return result; +} + +function printHelp() { + console.log(` +SDD spec → artifact generator + +Usage: + pnpm spec:generate -- --component [--artifact test|impl|demo|api|harness|all] [--dry-run] + +Examples: + pnpm spec:generate -- --component Copy + pnpm spec:generate -- --component Copy --artifact api + pnpm spec:generate -- --component Copy --artifact demo + +Artifacts: + api sync index.md API table (automatic) + impl Prompt ④ — index.tsx minimal diff + test Prompt ② — unit tests + demo Prompt ②′ — dumi demo (manual trigger, human review) + harness Prompt ②″ — harness panel (optional) + +Docs: docs/guide/SDD.md +`); +} + +function loadSpec(specPath) { + if (!fs.existsSync(specPath)) { + return null; + } + const raw = fs.readFileSync(specPath, 'utf8'); + const idMatches = raw.match(/^\s*-\s*id:/gm); + return { + raw, + path: specPath, + scenarioCount: idMatches ? idMatches.length : 0, + props: parsePropsFromSpec(raw), + extendsAntd: parseExtendsAntdFromSpec(raw), + }; +} + +function unquote(value) { + return value.trim().replace(/^['"]|['"]$/g, ''); +} + +/** + * Parse a flat YAML mapping block (structured subset, no external deps). + */ +function parseFlatYamlBlock(raw, blockName) { + const lines = raw.split('\n'); + const result = {}; + let inBlock = false; + + for (const line of lines) { + if (new RegExp(`^${blockName}:\\s*$`).test(line)) { + inBlock = true; + continue; + } + if (!inBlock) { + continue; + } + if (line.trim() && !line.startsWith(' ') && !line.startsWith('\t')) { + break; + } + const fieldMatch = line.match(/^\s{2}([a-zA-Z_][\w]*):\s*(.*)$/); + if (fieldMatch) { + const [, key, value = ''] = fieldMatch; + result[key] = unquote(value); + } + } + + return Object.keys(result).length ? result : null; +} + +/** + * Parse spec.props block — nested prop definitions. + */ +function parsePropsFromSpec(raw) { + const lines = raw.split('\n'); + const props = {}; + let inProps = false; + let currentProp = null; + const baseIndent = 2; + + for (const line of lines) { + if (/^props:\s*$/.test(line)) { + inProps = true; + continue; + } + if (!inProps) { + continue; + } + if (line.trim() && !line.startsWith(' ') && !line.startsWith('\t')) { + break; + } + const propMatch = line.match(/^(\s*)([a-zA-Z_][\w]*):\s*$/); + if (propMatch && propMatch[1].length === baseIndent) { + currentProp = propMatch[2]; + props[currentProp] = {}; + continue; + } + if (currentProp) { + const fieldMatch = line.match(/^\s+(type|description|default|required):\s*(.+)?$/); + if (fieldMatch) { + const [, key, value = ''] = fieldMatch; + props[currentProp][key] = unquote(value); + } + } + } + + return props; +} + +function parseExtendsAntdFromSpec(raw) { + return parseFlatYamlBlock(raw, 'extendsAntd'); +} + +function formatDefault(value) { + if (value === undefined || value === null || value === '' || value === 'null') { + return '--'; + } + return value; +} + +function escapeTableCell(value) { + return String(value).replace(/\|/g, '\\|'); +} + +function buildApiTable(props) { + const header = ['| 参数 | 说明 | 类型 | 默认值 |', '| --- | --- | --- | --- |']; + const rows = Object.entries(props).map(([name, meta]) => { + const desc = escapeTableCell(meta.description || '--'); + const type = meta.type ? `\`${escapeTableCell(meta.type)}\`` : '--'; + const def = escapeTableCell(formatDefault(meta.default)); + return `| ${name} | ${desc} | ${type} | ${def} |`; + }); + return [...header, ...rows].join('\n'); +} + +/** + * Antd inheritance notice — follows Drawer index.md (:::info block). + * @see src/drawer/index.md + */ +function buildExtendsAntdNotice(extendsAntd) { + if (!extendsAntd || !extendsAntd.component) { + return ''; + } + + const version = extendsAntd.version || '4.x'; + const { component, docUrl, omit, notice } = extendsAntd; + + let body = notice; + if (!body) { + if (omit) { + body = `其余参数继承 antd${version} 的 \`${omit}\``; + if (docUrl) { + body += `
详见 [antd${version} 的 ${component}](${docUrl})`; + } + } else if (docUrl) { + body = `其余属性继承 [antd${version} 的 ${component}](${docUrl})`; + } else { + body = `其余属性继承 antd${version} 的 ${component}`; + } + } + + return `\n\n:::info\n${body}\n:::`; +} + +function buildApiBlock(props, extendsAntd) { + return buildApiTable(props) + buildExtendsAntdNotice(extendsAntd); +} + +function syncIndexMdApi(indexPath, component, apiTable, dryRun) { + if (!fs.existsSync(indexPath)) { + throw new Error(`index.md not found: ${indexPath}`); + } + + const content = fs.readFileSync(indexPath, 'utf8'); + const start = GENERATED_START(component); + const startIdx = content.indexOf(start); + const endIdx = content.indexOf(GENERATED_END); + + if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) { + throw new Error( + `index.md missing API markers. Add:\n\n${start}\n${apiTable}\n${GENERATED_END}\n\nSee docs/guide/SDD.md` + ); + } + + const before = content.slice(0, startIdx + start.length); + const after = content.slice(endIdx); + const next = `${before}\n\n${apiTable}\n\n${after}`; + + if (dryRun) { + console.log('\n--- API block (dry-run) ---\n'); + console.log(apiTable); + return { updated: false, dryRun: true }; + } + + if (next === content) { + return { updated: false, unchanged: true }; + } + + fs.writeFileSync(indexPath, next, 'utf8'); + return { updated: true }; +} + +function runApiArtifact(component, componentDir, spec, dryRun) { + const props = spec.props; + const propNames = Object.keys(props); + + if (!propNames.length) { + throw new Error('spec.yaml has no parseable props — check props: block format'); + } + + const apiBlock = buildApiBlock(props, spec.extendsAntd); + const indexPath = path.join(componentDir, 'index.md'); + const result = syncIndexMdApi(indexPath, component, apiBlock, dryRun); + const relIndex = path.relative(process.cwd(), indexPath); + const extendsLabel = spec.extendsAntd?.component + ? `, extends antd ${spec.extendsAntd.component}` + : ''; + + if (result.dryRun) { + console.log(`[api] Would update ${relIndex} (${propNames.length} props${extendsLabel})`); + return; + } + if (result.unchanged) { + console.log( + `[api] ${relIndex} API block already up to date (${propNames.length} props${extendsLabel})` + ); + return; + } + console.log(`[api] Updated ${relIndex} API block (${propNames.length} props${extendsLabel})`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help || !args.component) { + printHelp(); + process.exit(args.help ? 0 : 1); + } + + const component = args.component; + const kebab = toKebab(component); + const componentDir = path.join(process.cwd(), 'src', kebab); + const briefPath = path.join(componentDir, `${component}.brief.md`); + const specPath = path.join(componentDir, `${component}.spec.yaml`); + + console.log(`\nSDD generate — ${component}\n`); + + if (!fs.existsSync(briefPath)) { + console.warn(`⚠ Missing brief: src/${kebab}/${component}.brief.md`); + } else { + console.log(`✓ Brief: src/${kebab}/${component}.brief.md`); + } + + const spec = loadSpec(specPath); + if (!spec) { + console.error(`\n✗ Spec not found: src/${kebab}/${component}.spec.yaml`); + console.error(' Run Prompt ① in Cursor first (see docs/guide/SDD.md).\n'); + process.exit(1); + } + + console.log( + `✓ Spec: src/${kebab}/${component}.spec.yaml (${spec.scenarioCount} scenario(s), ${ + Object.keys(spec.props).length + } prop(s))` + ); + + const artifactKeys = args.artifact === 'all' ? Object.keys(ARTIFACTS) : [args.artifact]; + const unknown = artifactKeys.filter((k) => !ARTIFACTS[k]); + if (unknown.length) { + console.error(`\n✗ Unknown artifact: ${unknown.join(', ')}`); + console.error(' Valid: test, impl, demo, api, harness, all\n'); + process.exit(1); + } + + console.log(''); + + artifactKeys.forEach((key) => { + const art = ARTIFACTS[key]; + if (art.write) { + try { + runApiArtifact(component, componentDir, spec, args.dryRun); + } catch (err) { + console.error(`[api] ✗ ${err.message}\n`); + process.exit(1); + } + return; + } + + const out = art.output(componentDir, component, kebab); + const relOut = path.relative(process.cwd(), out); + const exists = fs.existsSync(out); + + console.log(`[${key}] ${art.label}`); + console.log(` Output: ${relOut}${exists ? ' (exists)' : ''}`); + console.log(` Prompt:\n ${art.prompt(component).replace(/\n/g, '\n ')}\n`); + }); + + console.log('--- Notes ---'); + console.log('• demo:Prompt ②′ 人工按需触发,人审 demo 后手贴 index.md 引用'); + console.log( + '• 改 props 后:brief → regen spec → pnpm spec:generate -- --component ' + + component + + ' --artifact api' + ); + console.log(''); +} + +main(); diff --git a/src/copy/Copy.brief.md b/src/copy/Copy.brief.md new file mode 100644 index 000000000..8ccb0ad4d --- /dev/null +++ b/src/copy/Copy.brief.md @@ -0,0 +1,94 @@ +# Copy 组件行为说明 + +> **维护入口**:改需求时先改本文件,再 regenerate `Copy.spec.yaml` 及派生产物。 +> **实现代码**:`index.tsx` 通过 **Prompt ④** 按 spec 做最小 diff 更新,**人审 diff** 后合入;不整文件 regenerate。 + +## 干什么 + +点击复制区域,将 `text` 写入剪贴板;默认带 Tooltip 提示,复制成功后触发回调或弹出成功提示。 + +## Props 语义 + +| Prop | 说明 | +| --------------------- | -------------------------------------------------------------------------------------- | +| `text` | 要复制的字符串(必填) | +| `disabled` | `true` 时点击不触发复制、不调用 `onCopy`,并加上禁用样式 | +| `button` | 自定义触发节点,默认 `CopyOutlined` 图标 | +| `tooltip` | Tooltip 配置;支持字符串、`TooltipProps` 对象;`false` / 其它假值时不展示 Tooltip 内容 | +| `onCopy` | 复制成功回调,参数为 `text`;默认 `message.success(locale.copied)` | +| `className` / `style` | 透传到复制区域 `.dtc-copy` | + +## Antd 继承 + +**无。** Copy 不对外继承 antd 组件 props(内部使用 Tooltip,不在 API 表列出 antd Tooltip props)。spec 中**不设** `extendsAntd`。 + +## 场景(应有行为) + +### 1. default-render + +- 渲染 `.dtc-copy` 可点击区域 +- 默认使用 `CopyOutlined` 图标作为 button + +### 2. click-copy + +- `disabled=false` 时点击 → `CopyUtils.copy(text, callback)` 被调用 +- 传入 `onCopy` 时,复制成功后调用 `onCopy(text)` +- 剪贴板内容为 `text`(在测试环境可通过 mock 验证回调参数) + +### 3. disabled + +- `disabled=true` 时点击不调用 `CopyUtils.copy`、不调用 `onCopy` +- 复制区域带 `dtc-copy--disabled` class + +### 4. custom-button + +- 传入 `button` 时渲染自定义节点(如文本按钮) +- 点击自定义节点同样触发复制逻辑 + +### 5. tooltip-string + +- `tooltip="复制文本"` 等字符串时,Tooltip 展示对应 title + +### 6. tooltip-object + +- `tooltip={{ title: '复制文本' }}` 时,Tooltip 按对象配置展示 + +### 7. tooltip-falsy + +- `tooltip={false}` 等假值时,不展示 Tooltip 浮层(与 `demos/custom.tsx` 一致) + +## 不测什么(outOfScope) + +- `CopyUtils` 内部实现、clipboard 权限失败、浏览器兼容性 +- Tooltip 动画时序、`message` 样式与精确 locale 文案 +- `ConfigProvider` / 多语言切换的集成行为 +- E2E 真实剪贴板权限(单测用 mock / `userEvent` 即可) + +## 文档与派生产物 + +| 产物 | 维护方式 | +| ---------------------------- | ------------------------------------------------------------------------------------- | +| `index.tsx` | **Prompt ④**:spec 驱动最小 diff,`pnpm spec:generate -- --artifact impl` 打印 Prompt | +| `demos/*.tsx` | **Prompt ②′** AI 生成 + 人审(按需触发) | +| `index.md` 示例 `` | **人贴**(②′ 输出建议行) | +| `index.md` API 表 | **自动**:`pnpm spec:generate -- --artifact api` | +| `__tests__/copy.test.tsx` | **Prompt ②** | +| `demos/harness.tsx` | 可选,**Prompt ②″** | + +改需求顺序:**brief → spec → ④ 实现 → ② 测试 → api 脚本 →(按需)②′ demo**。 + +`linkedDemos`(写入 spec):`click-copy` → `demos/basic.tsx`,`custom-button` → `demos/custom.tsx`,`disabled` → `demos/disabled.tsx` + +## 给 AI 的项目提示 + +- **组件路径**:`src/copy/index.tsx` +- **实现更新**:Prompt ④ 或 `pnpm spec:generate -- --component Copy --artifact impl` +- **Demo 生成**:Prompt ②′ 或 `pnpm spec:generate -- --component Copy --artifact demo` +- **测试栈**:Jest + `@testing-library/react` + `@testing-library/user-event` + `jest-dom` +- **测试目录**:`src/copy/__tests__/copy.test.tsx`(spec 派生时可覆盖或对齐现有用例) +- **Mock 建议**: + - `@dtinsight/dt-utils` → `CopyUtils.prototype.copy = jest.fn((text, cb) => cb?.())` + - `antd` → `message.success = jest.fn()`(若测默认 onCopy) +- **运行**:`pnpm exec jest copy.test.tsx --no-coverage` +- **API 同步**:`pnpm spec:generate -- --component Copy --artifact api` +- **样式前缀**:`dtc-copy` diff --git a/src/copy/Copy.spec.yaml b/src/copy/Copy.spec.yaml new file mode 100644 index 000000000..8cccd278d --- /dev/null +++ b/src/copy/Copy.spec.yaml @@ -0,0 +1,125 @@ +# @generated-from: Copy.brief.md +component: Copy + +# 无 extendsAntd — Copy 不继承 antd 组件 props + +props: + text: + type: string + required: true + description: 需要复制的文本 + button: + type: React.ReactNode + description: 自定义按钮 + default: '``' + className: + type: string + description: 样式 class + disabled: + type: boolean + description: 是否禁用 + default: false + style: + type: React.CSSProperties + description: 样式 + tooltip: + type: "TooltipProps['title'] | TooltipProps" + description: 配置提示信息 + default: '`复制`' + onCopy: + type: '(text: string) => void' + description: 复制后的回调函数 + default: "`() => message.success('复制成功')`" + +scenarios: + - id: default-render + description: 默认渲染可点击区域 + given: + text: hello + expect: + - renders .dtc-copy + + - id: click-copy + description: 点击触发复制 + given: + text: hello + disabled: false + expect: + - CopyUtils.copy called with hello + - onCopy invoked with hello + + - id: disabled + description: 禁用时不触发复制 + given: + text: hello + disabled: true + expect: + - CopyUtils.copy not called + - element has dtc-copy--disabled + + - id: custom-button + description: 自定义 button 节点 + given: + text: hello + button: 复制文本 + expect: + - renders custom button + - click triggers copy + + - id: tooltip-string + description: tooltip 字符串 + given: + text: hello + tooltip: 复制文本 + expect: + - Tooltip title is 复制文本 + + - id: tooltip-object + description: tooltip 对象 + given: + text: hello + tooltip: + title: 复制文本 + expect: + - Tooltip configured with title + + - id: tooltip-falsy + description: tooltip 假值时不展示浮层 + given: + text: hello + tooltip: false + expect: + - no visible Tooltip overlay + +linkedDemos: + - scenarioId: click-copy + demo: demos/basic.tsx + - scenarioId: custom-button + demo: demos/custom.tsx + - scenarioId: disabled + demo: demos/disabled.tsx + +outOfScope: + - CopyUtils implementation + - clipboard permission errors + - Tooltip animation timing + - exact locale strings + +testFixtures: + mocks: + CopyUtils: "jest.fn((text, cb) => cb?.())" + message.success: jest.fn() + +harnessMeta: + controls: + - prop: text + type: string + - prop: disabled + type: boolean + - prop: tooltip + type: string + presets: + - default-render + - click-copy + - disabled + - tooltip-falsy diff --git a/src/copy/index.md b/src/copy/index.md index 7d60e6f6a..8df7f91f6 100644 --- a/src/copy/index.md +++ b/src/copy/index.md @@ -20,12 +20,16 @@ demo: ### API + + | 参数 | 说明 | 类型 | 默认值 | | --------- | ---------------- | --------------------------------------- | ----------------------------------- | +| text | 需要复制的文本 | `string` | -- | | button | 自定义按钮 | `React.ReactNode` | `` | -| className | 样式 | `string` | -- | +| className | 样式 class | `string` | -- | | disabled | 是否禁用 | `boolean` | false | | style | 样式 | `React.CSSProperties` | -- | -| text | 需要复制的文本 | `string` | -- | | tooltip | 配置提示信息 | `TooltipProps['title'] \| TooltipProps` | `复制` | | onCopy | 复制后的回调函数 | `(text: string) => void` | `() => message.success('复制成功')` | + +