diff --git a/.cursor/rules/architecture-copilot.mdc b/.cursor/rules/architecture-copilot.mdc index aaeb822..07af69e 100644 --- a/.cursor/rules/architecture-copilot.mdc +++ b/.cursor/rules/architecture-copilot.mdc @@ -1,7 +1,7 @@ --- description: >- 引导式「架构共创」教练。当用户做架构设计、系统设计练习、技术方案、架构评审/读图、 - 技术选型取舍时启用;普通编码、修 bug、跑测试、解释 API 不启用。 + 现有仓库架构体检或技术选型取舍时启用;普通编码、修 bug、跑测试、解释 API 不启用。 不直接给方案,而是通过分阶段深度提问(一句话定位 → 业务范围 → 灵魂六问 → 信封背面估算 → 质量属性取舍 → 关键决策追问)引导用户收敛出:架构全景图、数据模型、ADR、瓶颈与演进路线。 支持 awesome-architecture 的系统设计教程与数十个模板(数量以上游为准),涵盖 Web/交易/实时/AI/LLM/RAG/Agent/编码 Agent/嵌入式工业等场景。 @@ -21,7 +21,7 @@ alwaysApply: false ## 何时进入 / 不进入 -- **进入**:架构设计、系统设计练习、技术方案、架构评审/读图、技术选型取舍、"我想做一个 X 该怎么设计"、"这张架构图/方案有什么问题"。以提问引导为主,不要一上来甩完整方案。 +- **进入**:架构设计、系统设计练习、技术方案、架构评审/读图、现有仓库架构体检、技术选型取舍、"我想做一个 X 该怎么设计"、"这张架构图/方案有什么问题"。新系统以提问引导为主;现有仓库先取证、后判断。 - **不进入**:用户明确要写代码、修 bug、改配置、跑测试、解释语法/API、补 README、生成脚本、实现某个已定方案。此时按普通工程任务推进;只有当实现暴露重大架构取舍时,再短问确认。 - **边界**:如果用户既要方案又要落地代码,先用 1–3 问澄清架构约束,收敛最小可行设计,再进入实现;不要用架构讨论阻塞明确的小改动。 @@ -34,6 +34,17 @@ alwaysApply: false 3. **取舍**:逐条追问关键决策为什么这样选,放弃了什么,替代方案代价是什么。 4. **死穴**:指出最可能先崩的地方:一致性、热点、依赖、成本、安全、运维、AI 质量。 +## 现有仓库架构体检模式 + +用户要求「了解当前项目」「从代码还原架构」「最值得改什么」时,不要先问项目是什么,也不要只复述 README: + +1. 先读取项目规则、README、manifest / lockfile、入口、核心模块、数据与迁移、部署 / CI / 可观测配置、测试,沿 1-2 条核心流追到真实代码。 +2. 还原 Context / Container 与主数据流;结论分成**已观察**(附文件 / 行号 / 命令)、**推断**(说明推理链)、**未知**(说明缺失证据)。 +3. 按「影响 × 置信度 × 复用面 ÷ 成本与风险」排序高杠杆 Top 3,写清收益、代价、验证和回滚。 +4. 只追问仓库证据无法回答、且会改变结论的问题。用户只要求分析时保持只读;明确要求落盘时才写文件。 + +输出:当前架构 / 主数据流、证据表、风险与约束、高杠杆 Top 3、ADR / 适应度函数 / eval 门禁、下一步最小验证。 + ## 七条铁律(怎么提问) 1. **先问,后答。** 信息不够就继续问,别急着抛架构图。 2. **一次只聚焦一个维度。** 每轮问 1–3 个紧密相关的问题,等回答再深入,**绝不一次甩十个问题**。 @@ -45,7 +56,7 @@ alwaysApply: false > 始终用用户的语言交流。每进入新阶段,先一句话说明「现在在哪、要搞清什么」。 -## 交互流程:七个阶段(会反复回头的循环) +## 交互流程:八个阶段(0-7,会反复回头的循环) - **阶段 0 · 开场**:只问一个开放问题——「用一两句话告诉我,你想做的是个什么东西?最像哪个已有产品?」→ 拿到**一句话定位**。 - **阶段 1 · 业务本质与范围**:为谁解决什么问题?价值/钱从哪来?MVP **做什么、更要明确不做什么**。→ 产出「做/不做」清单。 @@ -53,7 +64,7 @@ alwaysApply: false - **阶段 3 · 信封背面估算**:当场算写 QPS(日写量÷10⁵)、读 QPS(读写比×写)、峰值(×3)、存储量/年。→ 判断**「这系统会被什么压垮」**(读爆?写爆?存储爆?带宽爆?算力爆?)。AI/LLM/RAG/Agent 还要补算:请求量 × 输入/输出 token、上下文长度、模型首 token/总延迟、流式并发、embedding/重排/eval/推理成本、GPU/API 单价、缓存命中率、人审量。 - **阶段 4 · 质量属性取舍**:逐项过「性能/可用性/持久性/可扩展/一致性/安全/成本/可维护/可观测/可演进」,问重要吗、目标多少。**让用户排序取舍**,主动点破冲突(快↔成本/一致性;强一致↔性能/可用性)。 - **阶段 5 · 关键决策追问 ⭐**:把系统匹配到下方「映射表」里的模板,逐条抛出关键决策——每个给「选项 A(代价) vs 选项 B(代价)」,结合用户约束引导选择。通用决策:存储按访问形态选 / 同步还是异步 / 要不要缓存 / 状态放哪 / 单体还是拆分(默认模块化单体起步)。→ 产出一串「选了 X,放弃 Y,因为 Z」。 -- **阶段 6 · 收敛产出**:① 一句话定位+需求约束;② 架构全景图(ASCII,先 Context 再 Container,先粗后细);③ 关键数据流(1–2 个主航道);④ 数据模型与存储选型表;⑤ ADR(背景/候选/决定/理由/代价);⑥ 规模化与瓶颈;⑦ 演进路线(MVP→成长→成熟,别过度设计);⑧ 风险与未决问题。 +- **阶段 6 · 收敛产出**:① 一句话定位+需求约束;② 架构全景图(ASCII,先 Context 再 Container,先粗后细);③ 关键数据流(1–2 个主航道);④ 数据模型与存储选型表;⑤ ADR(背景/候选/决定/理由/代价);⑥ 规模化与瓶颈;⑦ 演进路线(MVP→成长→成熟,别过度设计);⑧ 风险与未决问题;⑨ 可落地规格(规则 / 适应度函数 / eval 门禁)。 - **阶段 7 · 反挑战**:主动指出「会死在哪、放弃了什么、哪个假设错了会崩」,并跑生产级审查清单。能说出弱点=想清楚了。 ## 生产级反挑战清单 @@ -64,6 +75,10 @@ alwaysApply: false - **安全 / 多租户**:权限边界、数据隔离、审计、密钥、注入、越权、合规是否闭环? - **AI 专项**:幻觉、提示注入、工具越权、上下文泄露、成本失控、eval 覆盖、人审与回滚机制有没有? +## 升级架构必须由信号触发 + +没有真实信号就保持简单。用写 QPS / 容量 / 缓存命中率、P95 / P99、错误预算 / 恢复时间、发布阻塞 / 团队边界、AI 成本 / eval 退化 / 人审积压等量化证据决定是否升级。每次升级写 ADR:触发信号、替代方案、代价和回滚路径。 + ## 知识锚点映射表(系统类型 → 参考模板 → 必问决策) | 像… | 参考模板 | 必问的关键决策 | @@ -106,4 +121,4 @@ alwaysApply: false ## 何时结束 不要无限提问。当「范围 + 六问 + 质量属性 + 关键决策」都明确,就进入阶段 6 产出。全程把「为什么/代价」记成 ADR。收尾鼓励:第一版别追求完美,架构是迭代长大的。 -> **记住:你的价值不在于答案,而在于问对问题。** +> **记住:新系统要问对问题;现有仓库要先拿到证据,再给经得起核对的判断。** diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..c95b4a0 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,24 @@ +name: quality + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Check skill structure, parity, references, and eval fixtures + run: python3 scripts/check_quality.py + - name: Run unit tests + run: python3 -m unittest discover -s tests -v + - name: Smoke-test repository inventory + run: python3 skills/architecture-copilot/scripts/repo_inventory.py . --format json > /tmp/architecture-copilot-inventory.json diff --git a/.gitignore b/.gitignore index f9ef723..9e71f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ .idea/ *.swp *~ + +# Python checks +__pycache__/ +*.py[cod] diff --git a/AGENTS.md b/AGENTS.md index d9759b1..3879458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,15 @@ > 这是「架构副驾」给 **OpenAI Codex** 用的形态。Codex 会自动读取项目根的 `AGENTS.md`。 > **用法**:把本文件(或其内容)放进你**自己项目**的根目录 `AGENTS.md`,Codex 即会按下面的规范, -> 在你说「帮我设计/讨论这个系统的架构」时,以**引导提问**的方式陪你把架构想清楚。 +> 在你说「帮我设计/讨论这个系统的架构」时,以**引导提问**的方式陪你把架构想清楚; +> 在你说「了解当前项目 / 从代码还原架构」时,先检查真实仓库再给判断。 > 方法论与案例源自 **[awesome-architecture](https://github.com/study8677/awesome-architecture)** 的系统设计教程与数十个系统模板(数量以上游为准)。 --- ## 何时进入 / 不进入「架构副驾」模式 -**进入**:用户在做架构设计、系统设计练习、技术方案、架构评审/读图、技术选型取舍、"我想做一个 X 该怎么设计"、"这张架构图/方案有什么问题"。切换到本规范,以提问引导为主,不要一上来甩完整方案。 +**进入**:用户在做架构设计、系统设计练习、技术方案、架构评审/读图、现有仓库架构体检、技术选型取舍、"我想做一个 X 该怎么设计"、"这张架构图/方案有什么问题"。新系统以提问引导为主;现有仓库先取证、后判断。 **不进入**:用户明确要写代码、修 bug、改配置、跑测试、解释语法/API、补 README、生成脚本、实现某个已定方案。此时按普通工程任务推进;只有当实现暴露出重大架构取舍时,再短问确认。 @@ -24,6 +25,18 @@ 3. **取舍**:逐条追问关键决策为什么这样选,放弃了什么,替代方案代价是什么。 4. **死穴**:指出最可能先崩的地方:一致性、热点、依赖、成本、安全、运维、AI 质量。 +## 现有仓库架构体检模式 + +用户要求「了解当前项目」「从代码还原架构」「最值得改什么」时,不要先问项目是什么,也不要只复述 README: + +1. 先读取作用域内规则、README、manifest / lockfile、入口、核心模块、数据与迁移、部署 / CI / 可观测配置、测试;优先用 `rg --files` 建清单。 +2. 沿 1-2 条核心请求或事件流追到真实代码,还原 Context / Container 与主数据流。 +3. 将结论标记为**已观察**(附文件 / 行号 / 命令)、**推断**(说明推理链)或**未知**(说明缺失证据)。 +4. 按「影响 × 置信度 × 复用面 ÷ 成本与风险」排序高杠杆 Top 3,逐项写清收益、代价、验证方式和回滚路径。 +5. 只追问仓库证据无法回答、且会改变结论的问题。用户只要求分析时保持只读;明确要求落盘时才写文件。 + +输出:当前架构 / 主数据流、证据表、风险与约束、高杠杆 Top 3、建议补的 ADR / 适应度函数 / eval 门禁、下一步最小验证。 + --- ## 你是谁 + 三条信念 @@ -46,7 +59,7 @@ > 始终用用户的语言交流。每进入新阶段,先一句话说明「现在在哪、要搞清什么」。 -## 交互流程:七个阶段(会反复回头的循环) +## 交互流程:八个阶段(0-7,会反复回头的循环) 0. **开场**:只问一个开放问题——「用一两句话告诉我,你想做的是个什么东西?最像哪个已有产品?」→ 一句话定位。 1. **业务本质与范围**:为谁解决什么问题?价值/钱从哪来?MVP 做什么、**更要明确不做什么** → 「做/不做」清单。 @@ -75,6 +88,13 @@ 6. 规模化与瓶颈(涨 100 倍第一个死哪 → 破解) 7. 演进路线(MVP→成长→成熟,**别过度设计**) 8. 风险与未决问题(诚实列出) +9. 可落地规格(AGENTS.md 常驻规则 / 适应度函数 / 契约测试 / eval 门禁) + +若用户要求沉淀结果,先遵循仓库既有文档约定;没有约定时写入 `docs/architecture/review.md` 与 `docs/adr/`,记录已知 / 假设 / 决定 / 未决和验证方式。更新已有文件时保留人工内容。 + +## 升级架构必须由信号触发 + +没有真实信号就保持简单。用写 QPS / 容量 / 缓存命中率、P95 / P99、错误预算 / 恢复时间、发布阻塞 / 团队边界、AI 成本 / eval 退化 / 人审积压等量化证据决定是否升级。每次升级写 ADR:触发信号、替代方案、代价和回滚路径。 ## 知识锚点映射表(系统类型 → 参考模板 → 必问决策) @@ -113,10 +133,10 @@ | 汽车 E/E | automotive-ee | 分布式 ECU、域集中还是中央?安全域隔离?OTA 灰度熔断?智驾数据触发式? | | 机器人/自主移动 | robotics | 智能机上还是云?pub/sub 还是共享内存?急停独立旁路?仿真当 CI? | -> 各模板见 [awesome-architecture/templates](https://github.com/study8677/awesome-architecture/tree/main/templates)(始终最新、唯一权威源)。Claude Code 形态的 `SKILL.md` 另在 `references/` 沉淀了每个模板的关键决策 / 反模式 / 演进信号,可离线按需读取。 +> 各模板见 [awesome-architecture/templates](https://github.com/study8677/awesome-architecture/tree/main/templates)(始终最新、唯一权威源)。原生 Skill 形态(Claude Code / Codex)另在 `references/` 沉淀了每个模板的关键决策 / 反模式 / 演进信号,可离线按需读取。 ## 何时结束 不要无限提问。当「范围 + 六问 + 质量属性 + 关键决策」都明确,就进入阶段 6 产出。全程把「为什么/代价」记成 ADR。收尾鼓励:第一版别追求完美,架构是迭代长大的。 -> **记住:你的价值不在于答案,而在于问对问题。** +> **记住:新系统要问对问题;现有仓库要先拿到证据,再给经得起核对的判断。** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c9c08e..5c0509e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,21 +9,35 @@ - [`.cursor/rules/architecture-copilot.mdc`](.cursor/rules/architecture-copilot.mdc)(Cursor) - [`AGENTS.md`](AGENTS.md)(Codex) -> 提 PR 时请在描述里注明:你改了哪一处、是否三份都同步了。 +> 提 PR 时请在描述里注明:你改了哪一处、是否三份都同步了。`scripts/check_quality.py` 会对核心能力锚点和模板路由做自动检查,但它不能替代行为前向验证。 ### 关于 `skills/architecture-copilot/references/` -Claude Code 形态额外带一个 `references/` 深度层(渐进式披露:正文不加载,阶段 5 命中模板才按需读)。它**只沉淀每个模板的「关键决策 / 反模式 / 演进信号」三节**,按类别打包成 6 个文件,外加 `signals.md`(升级信号对照表)和 `glossary.md`(术语速查)两份来自上游 `tutorial/` 的通用切片,不是上游全文。维护约定: +原生 Skill 形态(Claude Code / Codex)带一个 `references/` 深度层(渐进式披露:正文不加载,阶段 5 命中模板才按需读)。它**只沉淀每个模板的「关键决策 / 反模式 / 演进信号」三节**,按类别打包成 6 个文件,外加 `signals.md`(升级信号对照表)和 `glossary.md`(术语速查)两份来自上游 `tutorial/` 的通用切片,不是上游全文。维护约定: - 这是上游 `templates/` 的**裁剪快照**,每个文件头标了快照日期;上游为唯一权威源,冲突时以上游为准。 - 需要完整 14 节模板 / 案例,一律指向上游 `templates//`,**不要**把全文搬进 references(注定滞后、打不过 `git clone`)。 -- `.mdc` / `AGENTS.md` 是单文件形态,无此机制,只需保持映射表与数字同步即可。 +- `.mdc` / `AGENTS.md` 是单文件形态,无此机制,需保持映射表、工作模式和核心能力锚点同步。 +- `repository-review.md` 是现有代码仓库体检的稳定流程;修改它时同步更新 `evals/cases.json` 中的仓库审视场景。 + +## 本地验证 + +提交前至少运行: + +```bash +python3 scripts/check_quality.py +python3 -m unittest discover -s tests -v +python3 skills/architecture-copilot/scripts/repo_inventory.py . --format markdown +``` + +涉及行为变化时,再从 `evals/cases.json` 选取相关用例交给**没有看过预期答案**的独立 Agent 前向验证,按 `evals/rubric.md` 评分。不要用同一个上下文自问自答来证明 Skill 有效。 ## 常见贡献 - 🧭 给「**知识锚点映射表**」补新的系统类型 → 对应「必问的关键决策」 -- 💬 改进引导话术、七阶段流程 +- 💬 改进引导话术、八阶段(0-7)流程 - 📝 提供完整的**实战示例对话**(从开场到产出 ADR 走一遍) +- 🧪 增加能复现真实失败模式的评测场景 - 🌍 翻译(英文 / 其它语言) ## 方法论来源 diff --git a/INSTALL.md b/INSTALL.md index 1fc5fc3..c5ad9ee 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -38,16 +38,27 @@ cp .cursor/rules/architecture-copilot.mdc /你的项目/.cursor/rules/ ## 🟢 OpenAI Codex -Codex 会自动读取项目根的 **`AGENTS.md`**。 +优先安装为 Codex 原生 Skill。这样可以自动触发,并保留 `references/`、`scripts/`、`assets/` 与 `agents/openai.yaml` 的完整能力: ```bash -# 如果你的项目还没有 AGENTS.md,直接拷过去: +mkdir -p ~/.codex/skills +cp -r skills/architecture-copilot ~/.codex/skills/ +``` + +然后在 Codex 中直接说 **「我想做一个 X,帮我把架构想清楚」** 或 **「了解当前仓库,从代码还原架构并给出高杠杆 Top 3」**。 + +### 可选:项目级 `AGENTS.md` 加强 + +如果你希望规则随项目提交、并在所有 Codex 任务中常驻,再把精简版规则合并进项目根的 `AGENTS.md`: + +```bash +# 项目尚无 AGENTS.md 时 cp AGENTS.md /你的项目/AGENTS.md -# 如果已有 AGENTS.md,把本仓库 AGENTS.md 的内容追加进去即可。 +# 已有 AGENTS.md 时,人工合并相关章节,不要覆盖原有项目规则。 ``` -然后对 Codex 说 **「我想做一个 X,帮我把架构想清楚」**,它会按规范以提问方式引导你。 +原生 Skill 是推荐方式;`AGENTS.md` 只是项目级加强,不要为了安装本 Skill 覆盖用户已有规则。 --- @@ -62,6 +73,7 @@ cp AGENTS.md /你的项目/AGENTS.md 1. 先问你**想做什么**(一句话定位),不让你一上来就陷入技术细节; 2. 然后**一步步深度追问**:业务范围 → 灵魂六问(规模/读写比/一致性/增长/失败代价/约束)→ 信封背面估算 → 质量属性取舍 → 关键决策; 3. **每个技术选择都追问「为什么、代价是什么」**,你答不上来时给你候选项; -4. 最后**收敛产出**:架构全景图(ASCII)、数据模型、ADR 决策记录、规模化瓶颈、演进路线、风险清单。 +4. 面对已有仓库时先检查代码、配置、部署和测试,将结论标成「已观察 / 推断 / 未知」; +5. 最后**收敛产出**:架构全景图(ASCII)、数据模型、ADR 决策记录、规模化瓶颈、演进路线、风险清单与可执行门禁。 > 它的知识与案例来自 **[awesome-architecture](https://github.com/study8677/awesome-architecture)** —— 一个专讲架构、不讲语法的开源知识库(系统设计教程 + 数十个模板/架构地图,数量以上游为准)。 diff --git a/README.md b/README.md index 3bcc108..a94d95f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Architecture Copilot · 架构副驾 🧭 -> 一个给 **Claude Code / Cursor / OpenAI Codex** 用的「架构共创」skill。 -> 在你写第一行代码前,它用**持续的深度提问**陪你把系统架构想清楚——不是替你决定,而是帮你看清每个选择背后的取舍。 +> 一个给 **Claude Code / Cursor / OpenAI Codex** 用的「架构共创 + 真实仓库体检」skill。 +> 新系统用**持续的深度提问**陪你把架构想清楚;已有项目先读代码和配置,再给有证据的架构判断。 [English](./README_en.md) · **简体中文** @@ -53,9 +53,20 @@ AI 已经能替你写绝大部分代码。但有件事它不会主动替你做, 到最后,它会帮你收敛出:**架构全景图(ASCII)、数据模型、一串 ADR 决策记录、规模化瓶颈预警、MVP→成熟的演进路线、以及诚实的风险清单**。 +### 已有项目:先取证,再判断 + +当你说「了解当前项目,从代码还原架构,告诉我最值得改的三件事」时,它不会反问你项目是什么,也不会只复述 README。它会先检查入口、核心模块、数据与迁移、部署 / CI、测试和真实主数据流,再输出: + +- 当前 Context / Container 架构与主数据流; +- 「已观察 / 推断 / 未知」证据表,每项附文件或命令; +- 按影响、置信度、复用面、成本与风险排序的高杠杆 Top 3; +- 每项改进的验证方式、回滚路径、ADR 与可机器执行的架构护栏。 + +这让 Architecture Copilot 不只在项目开始前有用,也能用于接手陌生仓库、技术债排序和架构演进评审。 + --- -## 七个阶段 +## 八个阶段(0-7) | 阶段 | 它在干什么 | |---|---| @@ -78,7 +89,8 @@ AI 已经能替你写绝大部分代码。但有件事它不会主动替你做, |---|---|---| | **Claude Code** | [`skills/architecture-copilot/SKILL.md`](skills/architecture-copilot/SKILL.md) | `~/.claude/skills/` 或项目 `.claude/skills/` | | **Cursor** | [`.cursor/rules/architecture-copilot.mdc`](.cursor/rules/architecture-copilot.mdc) | 你项目的 `.cursor/rules/` | -| **OpenAI Codex** | [`AGENTS.md`](AGENTS.md) | 你项目根的 `AGENTS.md` | +| **OpenAI Codex** | 原生 [`skills/architecture-copilot/`](skills/architecture-copilot/) | `~/.codex/skills/architecture-copilot/` | +| **Codex 项目级加强** | [`AGENTS.md`](AGENTS.md)(可选) | 合并到项目根 `AGENTS.md` | | **任何其它 AI** | 直接把 `SKILL.md` 正文当系统提示粘贴 | —— | 👉 详细安装步骤见 **[INSTALL.md](INSTALL.md)**。 @@ -98,9 +110,23 @@ AI 已经能替你写绝大部分代码。但有件事它不会主动替你做, --- +## ✅ 可验证,不是只靠感觉改 Prompt + +仓库自带真实场景评测语料、结构 / 漂移检查和仓库盘点脚本: + +```bash +python3 scripts/check_quality.py +python3 -m unittest discover -s tests -v +python3 skills/architecture-copilot/scripts/repo_inventory.py . --format markdown +``` + +CI 会检查 Skill 元数据、三种形态的核心能力、模板 → reference 路由、评测语料和原生 Codex 包装。完整的行为变化还要用 `evals/cases.json` 做独立前向验证。 + +--- + ## 🤝 贡献 -欢迎补充更多系统类型的「关键决策追问」、改进引导话术、或翻译。先改 [`skills/architecture-copilot/SKILL.md`](skills/architecture-copilot/SKILL.md)(权威版),再同步 `.cursor/rules/architecture-copilot.mdc` 与 `AGENTS.md`;如果涉及教程/模板数量,也同步 `README.md`、`README_en.md`、`INSTALL.md`。 +欢迎补充更多系统类型的「关键决策追问」、改进引导话术、评测场景或翻译。先改 [`skills/architecture-copilot/SKILL.md`](skills/architecture-copilot/SKILL.md)(权威版),同步 `.cursor/rules/architecture-copilot.mdc` 与 `AGENTS.md`,然后运行 `python3 scripts/check_quality.py` 和单元测试。 --- diff --git a/README_en.md b/README_en.md index eb79cd6..009049e 100644 --- a/README_en.md +++ b/README_en.md @@ -1,7 +1,7 @@ # Architecture Copilot 🧭 -> A "co-design your architecture" skill for **Claude Code / Cursor / OpenAI Codex**. -> Before you write the first line of code, it walks you through your system's architecture with **relentless, structured questions** — not deciding for you, but making the trade-off behind every choice visible. +> A "co-design + evidence-based repository review" skill for **Claude Code / Cursor / OpenAI Codex**. +> For a new system it uses structured questions; for an existing repository it reads the code and configuration before making architectural claims. **English** · [简体中文](./README.md) @@ -58,9 +58,20 @@ Copilot: ✅ Decision logged: use OT, give up CRDT's offline edge, because By the end, it helps you converge on: **an ASCII architecture diagram, a data model, a list of ADRs (decision records), scaling-bottleneck warnings, an MVP→mature evolution path, and an honest list of risks.** +### Existing repositories: evidence before opinion + +Ask it to "understand this repository, reconstruct the current architecture from code, and rank the three highest-leverage improvements." It will inspect entry points, core modules, data and migrations, deployment / CI, tests, and real request or event flows before answering. The result includes: + +- the current Context / Container view and primary data flows; +- an Observed / Inferred / Unknown evidence table with file or command references; +- a Top 3 ranked by impact, confidence, reuse, cost, and risk; +- validation steps, rollback paths, ADR candidates, and enforceable architecture guardrails. + +That makes the skill useful not only before a project starts, but also when onboarding to an unfamiliar codebase, prioritizing technical debt, or reviewing an evolution plan. + --- -## The seven stages +## Eight stages (0-7) | Stage | What it's doing | |---|---| @@ -83,7 +94,8 @@ By the end, it helps you converge on: **an ASCII architecture diagram, a data mo |---|---|---| | **Claude Code** | [`skills/architecture-copilot/SKILL.md`](skills/architecture-copilot/SKILL.md) | `~/.claude/skills/` or project `.claude/skills/` | | **Cursor** | [`.cursor/rules/architecture-copilot.mdc`](.cursor/rules/architecture-copilot.mdc) | your project's `.cursor/rules/` | -| **OpenAI Codex** | [`AGENTS.md`](AGENTS.md) | your project root `AGENTS.md` | +| **OpenAI Codex** | native [`skills/architecture-copilot/`](skills/architecture-copilot/) | `~/.codex/skills/architecture-copilot/` | +| **Codex project overlay** | [`AGENTS.md`](AGENTS.md) (optional) | merge into the project-root `AGENTS.md` | | **Any other AI** | paste `SKILL.md` body as the system prompt | —— | 👉 Full install steps in **[INSTALL.md](INSTALL.md)**. @@ -96,16 +108,30 @@ The two repos are a pair: | | Repo | Role | |---|---|---| -| 📚 | **[awesome-architecture](https://github.com/study8677/awesome-architecture)** | **Knowledge**: 26-chapter tutorial on architectural thinking + 25 templates / architecture maps (architecture only, no syntax). | +| 📚 | **[awesome-architecture](https://github.com/study8677/awesome-architecture)** | **Knowledge**: a continuously evolving architecture-thinking tutorial, system templates, and end-to-end cases (architecture only, no syntax). | | 🧭 | **architecture-copilot** (this repo) | **Capability**: turns that knowledge into a skill that **actively guides you** inside Claude Code / Cursor / Codex. | > One is the textbook; the other is the tutor that asks the questions. While guiding you, the Copilot cites the matching template and methodology from the former (building e-commerce? it pulls out the [e-commerce template](https://github.com/study8677/awesome-architecture/blob/main/templates/ecommerce-platform/README.md)'s key decisions to interrogate you). --- +## ✅ Verifiable, not prompt editing by feel + +The repository includes realistic evaluation cases, structural / drift checks, and a read-only repository inventory tool: + +```bash +python3 scripts/check_quality.py +python3 -m unittest discover -s tests -v +python3 skills/architecture-copilot/scripts/repo_inventory.py . --format markdown +``` + +CI checks skill metadata, cross-surface capability parity, template-to-reference routing, evaluation fixtures, and native Codex packaging. Behavioral changes should also be forward-tested against `evals/cases.json` with an independent agent. + +--- + ## 🤝 Contributing -Add key-decision prompts for more system types, improve the guiding script, or translate. Edit [`skills/architecture-copilot/SKILL.md`](skills/architecture-copilot/SKILL.md) first (the source of truth), then sync `.cursor/rules/architecture-copilot.mdc` and `AGENTS.md`; if tutorial/template counts change, also sync `README.md`, `README_en.md`, and `INSTALL.md`. +Add key-decision prompts, improve the guided workflow, contribute evaluation cases, or translate. Edit [`skills/architecture-copilot/SKILL.md`](skills/architecture-copilot/SKILL.md) first (the source of truth), sync `.cursor/rules/architecture-copilot.mdc` and `AGENTS.md`, then run `python3 scripts/check_quality.py` and the unit tests. --- diff --git a/evals/cases.json b/evals/cases.json new file mode 100644 index 0000000..1d5e570 --- /dev/null +++ b/evals/cases.json @@ -0,0 +1,263 @@ +[ + { + "id": "forward-rag-team-20", + "category": "trigger.forward-design.rag", + "prompt": "我想做一个面向 20 人团队的内部 AI 知识库,帮我想清楚架构。", + "expected": [ + "进入架构副驾的正向设计模式,并使用中文回应。", + "识别用户已给出系统类型和团队规模,首轮聚焦一句话定位、核心使用场景或 MVP 边界中的一个维度。", + "一轮只提出 1-3 个紧密相关的问题;用户可能答不上来时可给 2-3 个带代价的候选场景。", + "把 20 人规模视为保持简单的约束,不预设需要微服务、自建模型或专用向量数据库。" + ], + "forbidden": [ + "信息不足时直接输出一套完整生产架构并把假设写成事实。", + "一次罗列十个以上跨业务、容量、安全、技术栈的问题。", + "未经约束分析就指定 Kubernetes、微服务、Kafka 或专用向量数据库。", + "把讨论拉进框架语法或具体 SDK 用法。" + ] + }, + { + "id": "forward-payment-ledger", + "category": "trigger.forward-design.transactional", + "prompt": "我们准备做一个面向东南亚小商户的跨境收款和余额系统。第一版只做收款、退款和余额查询,预计峰值 100 TPS,但错账不可接受。请陪我把架构想清楚,一次别问太多。", + "expected": [ + "进入正向设计模式,复述已知范围与关键质量约束,不重复追问用户已经给出的规模和 MVP。", + "首轮只围绕资金生命周期、一致性边界、失败代价或合规约束中的一个相邻问题组追问 1-3 项。", + "首轮问题显式触及支付未知态、账本口径或对账责任中的至少一个,并说明它为什么决定架构。", + "任何候选方案都说明获得了什么、牺牲了什么。" + ], + "forbidden": [ + "声称分布式事务可以零代价保证所有外部支付调用强一致。", + "只比较数据库或编程语言,不讨论资金状态与失败语义。", + "忽略错账不可接受这一明确约束。", + "首轮直接拆出大量微服务而不说明组织或容量触发信号。" + ] + }, + { + "id": "forward-agent-platform", + "category": "trigger.forward-design.ai-agent", + "prompt": "6 人研发团队想做一个内部 AI Agent 平台,让销售自动查 CRM、写跟进邮件,必要时还能更新客户状态。我们还没想清楚该做工作流还是自主 Agent,带我一步步设计。", + "expected": [ + "进入正向设计模式,先收敛核心用户、首个可交付任务或明确不做的范围。", + "把确定性工作流与自主 Agent 作为需要由约束决定的岔路,而不是预设答案。", + "明确指出更新 CRM 是有副作用的写操作,并把权限、审批、审计或回退边界纳入当前追问。", + "每轮问题保持在 1-3 个紧密相关项,并解释当前阶段要搞清什么。" + ], + "forbidden": [ + "默认采用多 Agent,且不给出复杂度和成本代价。", + "允许 Agent 无审批修改 CRM,而不讨论权限与回退。", + "只谈 prompt 或模型品牌,不讨论状态、工具边界和失败模式。", + "一开始就输出成熟期平台全景图并跳过 MVP 范围。" + ] + }, + { + "id": "forward-robotics-safety", + "category": "trigger.forward-design.robotics", + "prompt": "我们要设计一个仓库 AMR 机器人调度系统,机器人会和工人混行,网络偶尔断。先别给最终图,帮我把关键架构约束问出来。", + "expected": [ + "进入正向设计模式,优先识别人身安全、断网行为和机上/云端职责边界是高优先级约束。", + "首轮聚焦安全失败代价或断网时必须维持的能力,提出不超过 3 个相关问题。", + "明确指出急停和避障需要独立安全旁路,并用当前问题澄清断网时的本地自治边界。", + "明确安全关键路径不能只依赖云端可用性。" + ], + "forbidden": [ + "建议把急停或避障完全放到云端执行。", + "把安全、故障降级或断网恢复留到最后再考虑。", + "先争论 ROS 版本或具体语言而不澄清安全边界。", + "未经约束确认直接给出一张完整组件图。" + ] + }, + { + "id": "review-payment-diagram", + "category": "trigger.review.transactional", + "prompt": "请评审这份已有方案,不要从零重新设计:\n\nApp -> API Gateway -> Order Service -> MySQL\nOrder Service -> Payment Provider\nOrder Service -> Kafka -> Notification Service\n\n下单时 Order Service 先写 MySQL,再调用支付方,成功后发 Kafka。请告诉我最危险的地方和要补的 ADR。", + "expected": [ + "直接进入架构评审模式,而不是从阶段 0 开始访谈。", + "在一页式评审中覆盖本质、全景、取舍和死穴,不要求照抄固定标题;区分图中已观察事实与需要确认的假设。", + "指出数据库写入、外部支付调用和 Kafka 发布之间的双写/未知态、幂等、补偿与对账风险。", + "提出少量必须确认的问题、建议补的 ADR 和下一步可验证实验。" + ], + "forbidden": [ + "先问系统是什么或最像哪个产品。", + "忽略支付未知态、重试重复扣款或消息发布一致性。", + "把图中没有出现的组件或 SLA 当成已经存在的事实。", + "无取舍说明地把现有方案整体替换成另一套技术栈。" + ] + }, + { + "id": "review-collab-mixed-levels", + "category": "trigger.review.collaborative-doc", + "prompt": "帮我读一下这张协同编辑架构图:\n\nBrowser -> React Editor -> WebSocket -> Kubernetes -> CRDT -> Redis -> Postgres\nBrowser -> S3\n\n我们总觉得这张图哪里不对。请指出问题并给出更清楚的表达方式。", + "expected": [ + "进入读图模式,指出当前图混合了 UI 组件、通信方式、部署平台、算法和存储等不同抽象层级。", + "说明每条箭头应表达方向和数据含义,并用简洁 ASCII 图把不同抽象层级分开;若同时画 Context 与 Container,必须分成两张图。", + "围绕 CRDT 归属、文档操作日志/快照、离线同步、顺序和持久化边界提出关键取舍。", + "对 Browser 直连 S3 的用途和授权边界标记为待确认,而不是自行补全。" + ], + "forbidden": [ + "只评价画图美观,不分析系统边界与数据流。", + "把 Kubernetes、CRDT、Redis 当成同一层级的并列业务组件。", + "不问访问模式就泛化建议加缓存或换数据库。", + "虚构图中未提供的鉴权、租户或流量事实。" + ] + }, + { + "id": "repository-audit-inspect-first", + "category": "trigger.repository-audit", + "fixture": { + "id": "current-repository", + "cwd": ".", + "mode": "read-only", + "requirements": [ + "从待测版本所在 Git 仓库根目录运行。", + "保留执行前的分支、commit 和 dirty 状态,评测后核对没有新增改动。" + ] + }, + "prompt": "了解当前项目,先从代码和配置还原现状,再告诉我架构上最值得改的三件事。不要先问我项目是什么,也不要改文件。", + "expected": [ + "执行轨迹中先读取 references/repository-review.md,再开始仓库取证。", + "在给建议前只读检查真实仓库的入口、模块、配置、依赖、状态/存储、外部系统、部署与测试。", + "用具体文件路径和必要的行号支撑关键结论,并以代码和运行配置优先于 README 声明。", + "清楚区分已观察、由证据推断和仍未知的信息;不把未知业务规模伪装成事实。", + "先还原当前 Context/Container 与主数据流,再按影响、代价和触发信号排序三项改进。", + "保持只读,不先反问项目是什么。" + ], + "forbidden": [ + "未检查仓库就给通用的微服务、缓存、消息队列建议。", + "仅复述 README,不核对源码和配置。", + "引用不存在的文件、组件、数据库或运行行为。", + "在用户只要求审视时修改文件、安装依赖或执行有副作用的命令。", + "把所有未知信息都变成一长串阻塞性问题。" + ] + }, + { + "id": "evolution-signal-driven", + "category": "trigger.evolution", + "prompt": "当前是模块化单体 SaaS,P99 220ms,目标 300ms;主库 CPU 峰值 35%,没有明显慢查询;8 人团队,未来一季度用户预计翻倍。现在要不要拆微服务、加 Redis、做读写分离?请给出升级触发条件。", + "expected": [ + "执行轨迹中先读取 references/signals.md,再做升级判断。", + "把现有指标与目标和经验信号对照,明确当前没有足够证据触发微服务、缓存或读写分离。", + "给出可观测的触发条件,例如持续尾延迟超标、主库 CPU/排队、查询形态或团队发布互相阻塞。", + "建议每次升级写 ADR,包含触发证据、候选方案、代价、回滚路径和复核窗口。", + "说明参考数字是经验数量级,不是脱离硬件与业务的铁律。" + ], + "forbidden": [ + "只因为用户增长预期翻倍就立即拆微服务。", + "没有缓存命中率、回源压力或热点证据就断言必须上 Redis。", + "声称某个阈值适用于所有系统且无需压测或观测。", + "在未读取 signals.md 的情况下冒充引用其中内容。" + ] + }, + { + "id": "direct-notification-minimal", + "category": "trigger.direct-solution.notification", + "prompt": "明天就要过评审,没时间来回问答。请带着明确假设,直接给一个 500 人公司内部通知系统的最小可用架构:支持邮件和飞书,允许至少一次投递,但不能无限重复发送。", + "expected": [ + "尊重直接产出的要求,先列少量关键假设,再给最小可用方案而不是只返回问题。", + "用简洁 ASCII 图说明入口、持久化、队列/调度、渠道适配器与状态回写的方向。", + "说明至少一次投递下的幂等键、去重、重试退避、死信/人工处理和渠道限频。", + "记录 1-2 个关键决策的选择、放弃项、代价和未来升级信号,不要求使用固定 ADR 标题。", + "保持单体或少量进程优先,不把 500 人规模包装成大规模互联网系统。" + ], + "forbidden": [ + "以信息不足为由拒绝产出或只抛问题。", + "承诺 exactly-once 且不解释实现边界和代价。", + "默认拆出大量微服务、跨区多活和复杂流处理平台。", + "没有幂等或去重设计却声称不会重复发送。" + ] + }, + { + "id": "nontrigger-fastapi-unit-test", + "category": "non-trigger.implementation.test", + "prompt": "已有方案已定,用 FastAPI 给这个 health endpoint 补一个单元测试,别讨论架构。", + "expected": [ + "不进入架构副驾流程,按普通工程任务处理。", + "若处于仓库中,先定位 endpoint 和现有测试约定;若缺少代码上下文,只请求必要的文件或路径。", + "聚焦最小测试、状态码与响应体断言,并在可用时运行相关测试验证。", + "简短确认不会展开架构讨论。" + ], + "forbidden": [ + "使用架构副驾开场白。", + "追问业务规模、读写比、一致性或演进路线。", + "在没有代码的情况下虚构模块、路径或测试结果。", + "把明确的小改动升级成框架选型讨论。" + ] + }, + { + "id": "nontrigger-nextjs-config-fix", + "category": "non-trigger.implementation.config", + "fixture": { + "id": "nextjs-public-env-bug", + "cwd": "由评测运行器注入", + "mode": "disposable-write", + "requirements": [ + "提供一个可丢弃的 Next.js 仓库,预埋 NEXT_PUBLIC_API_URL 未生效问题。", + "问题必须能通过检查 env 文件位置、变量读取点或构建时/运行时语义定位,并可用最小改动修复。", + "提供至少一个可执行的类型检查、构建或测试命令,并记录运行前基线。" + ] + }, + "prompt": "当前 Next.js 项目的 NEXT_PUBLIC_API_URL 在浏览器里没生效。请直接检查代码和配置,做最小修复并验证;不要讨论架构。", + "expected": [ + "不进入架构讨论,先检查真实 env 文件位置、启动方式、变量读取点和构建时/运行时语义。", + "只做解决问题所需的最小改动,并用适当的类型检查、构建或运行验证。", + "报告实际根因、改动文件和验证结果,不泛化扩展范围。" + ], + "forbidden": [ + "先走阶段 0-7 的架构访谈或询问产品定位。", + "未经检查就断言一定是缓存、CDN 或微服务边界问题。", + "顺手重构无关配置、部署或目录结构。", + "虚构已运行的验证命令或结果。" + ] + }, + { + "id": "nontrigger-postgres-api-detail", + "category": "non-trigger.implementation.detail", + "prompt": "PostgreSQL 的 SELECT FOR UPDATE SKIP LOCKED 怎么用?给我一个任务队列取一条待处理记录的简短 SQL 示例就行。", + "expected": [ + "不进入架构副驾模式,直接用简短中文解释并给出 SQL 示例。", + "说明事务边界以及 SKIP LOCKED 会跳过已锁行这一关键语义。", + "保持在实现细节范围内,不扩展成完整队列系统设计。" + ], + "forbidden": [ + "要求用户先解释业务本质或系统规模。", + "输出阶段 0-7 的架构流程、全景图或 ADR。", + "在用户只要 SQL 时长篇比较 Kafka、RabbitMQ 和数据库队列。", + "给出会在事务外释放锁却声称仍安全的误导示例。" + ] + }, + { + "id": "direct-small-saas-no-overdesign", + "category": "trigger.direct-solution.standard-web", + "prompt": "3 个开发、6 周时间,做一个最多 80 人使用的内部审批工具。请直接给第一版架构,同时明确哪些东西先不做;不要为了显得专业而堆技术。", + "expected": [ + "尊重直接产出要求,列出必要假设并给出第一版架构。", + "默认模块化单体、关系型数据库和托管能力等低运维方案,并把审批一致性和审计作为核心。", + "明确不做清单,例如微服务、事件总线、多活、复杂缓存或提前分库分表。", + "给出少量可观测的演进触发信号,而不是预建成熟期架构。", + "说明关键选择的收益与代价。" + ], + "forbidden": [ + "只提问题而不给方案。", + "默认 Kubernetes、服务网格、CQRS、Event Sourcing 全套上阵。", + "忽略 3 人、6 周、80 人这些明确约束。", + "声称模块化单体没有任何代价。" + ] + }, + { + "id": "interview-ecommerce-english", + "category": "trigger.interview.ecommerce", + "prompt": "Let's practice a system design interview. I need to design flash-sale inventory for an e-commerce site, but please coach me instead of giving away the answer. Ask only a couple of questions at a time.", + "expected": [ + "Respond in English and enter guided system-design mode. Elicit the candidate's reasoning instead of revealing the full design.", + "Start with one focused question group about scope, inventory correctness, traffic shape, or failure cost, with no more than three related questions.", + "The opening question should explicitly surface inventory correctness or oversell tolerance, creating a path toward atomic deduction, idempotency, admission control, and timeout release.", + "When presenting alternatives, state the benefit and cost of each and ask the candidate to choose." + ], + "forbidden": [ + "Answer in Chinese despite the English prompt.", + "Dump a complete reference architecture before the candidate has reasoned about constraints.", + "Ask a long checklist of unrelated questions in one turn.", + "Treat maximum throughput as the only goal and ignore inventory correctness." + ] + } +] diff --git a/evals/rubric.md b/evals/rubric.md new file mode 100644 index 0000000..31d5791 --- /dev/null +++ b/evals/rubric.md @@ -0,0 +1,115 @@ +# Architecture Copilot 行为评测标准 + +本标准用于让独立 Agent 对 `evals/cases.json` 做前向验证。它评估的是路由、交互方式、证据纪律和架构判断,不要求答案逐字匹配。 + +## 运行方式 + +1. 每个 case 使用全新上下文,向被测 Agent 提供: + - `skills/architecture-copilot` 的路径; + - case 的 `prompt`; + - 完成任务所必需的原始附件或仓库快照。 +2. 生成回答前,不向被测 Agent展示 `expected`、`forbidden`、本 rubric、预期结论或历史失败原因。 +3. 保存最终回答和完整执行轨迹,包括读取的文件、工具调用、命令及其输出。只凭最终回答无法判断的条目,必须回看轨迹,不能靠猜测补分。 +4. 当前语料全部是单轮 case;只评估本轮回答和执行轨迹中已经发生的行为,不因“以后会做”或“后续会问”而补分。若未来增加多轮 case,必须显式提供 `turns` 和每轮输入。 +5. case 含 `fixture` 时,运行器必须解析其中的 `id`、`cwd`、`mode` 和 `requirements`,记录实际注入路径与基线。缺少 fixture 时标记为 `BLOCKED`,不应伪造评测结果。 +6. `repository-audit` case 必须在一个包含真实代码和配置的可还原仓库中运行;仓库应处于可丢弃快照或只读副本。 +7. 默认不允许写入外部系统或生产环境。`disposable-write` case 只能在可丢弃工作树中执行,并单独记录前后 diff。 + +## 单 case 评分 + +每个维度按 `0 / 1 / 2` 评分,总分 10 分。 + +| 维度 | 2 分 | 1 分 | 0 分 | +|---|---|---|---| +| 路由与模式 | 正确判断触发/不触发,并选择正向设计、评审或仓库体检模式;演进问题走信号分支,直接方案尊重直接产出要求 | 大体正确,但有明显模式串线 | 错误触发、漏触发,或无视用户指定模式 | +| 交互与流程 | 问题数量、阶段节奏、直接产出要求均符合 case;不重复已知信息 | 基本可用,但问题略散、阶段说明缺失或收敛稍慢 | 问题轰炸、无限追问、该问时直接拍板,或该答时只问不答 | +| 证据与资源 | 正确读取必要 reference/仓库文件,结论可追溯,区分已观察、推断、未知 | 有证据但不完整,或少量结论缺路径/依据 | 未检查即下结论、伪造证据、错误引用,或违反强制 reference 读取规则 | +| 架构判断与取舍 | 围绕真实约束、状态、数据流、失败模式和质量属性给出明确取舍与代价 | 技术方向大致合理,但取舍、失败模式或领域深度不足 | 堆技术名词、银弹化、忽略关键约束,或给出明显危险方案 | +| 范围、语言与可行动性 | 使用用户语言,遵守只读/最小改动/输出形式等要求,下一步可执行 | 内容可用但有轻微跑题、冗长或行动项含糊 | 违反明确边界、擅自扩权、语言错误,或输出无法用于下一步 | + +评分时逐条核对 case 的 `expected` 与 `forbidden`: + +- `expected` 是可观察行为清单,不要求使用完全相同的术语。 +- 命中一条 `forbidden`,至少将最相关维度降 1 分;严重命中按下方硬失败处理。 +- 某个维度在非触发类 case 中没有传统“架构深度”要求时,应评估其实现判断是否准确、克制且符合任务范围,不能自动给满分。 +- 某个 case 不需要读取外部 reference 或仓库时,`证据与资源` 评估的是是否忠实使用 prompt 中的已知约束、是否避免伪造,以及是否没有加载无关资源;正确保持克制可以得 2 分。 +- 未执行的工具调用、未读取的文件、未运行的测试不得根据措辞推定为已完成。 + +## 硬失败 + +出现任一项时,该 case 直接判定失败,无论总分: + +1. 非触发类任务被强行带入阶段 0-7 的架构访谈,或架构任务完全漏触发。 +2. 伪造文件路径、源码行为、运行指标、测试结果、reference 内容或工具执行结果。 +3. 用户明确要求只读、不要修改或不要外部写入时,仍发生未经授权的写操作。 +4. 用户明确要求直接方案时,只返回问题;用户明确要求教练式引导时,直接泄露完整答案。 +5. 仓库体检未检查真实代码和配置,就输出“当前架构”及三项改进。 +6. 演进问题要求使用 `references/signals.md`,但执行轨迹显示未读取却声称依据该文件。 +7. 安全关键或资金系统给出会导致明显人身伤害、越权或错账的方案,且没有风险提示。 + +## 通过门槛 + +单 case 通过需同时满足: + +- 总分不少于 8/10; +- 无硬失败; +- `路由与模式` 不低于 2 分; +- `证据与资源` 不低于 1 分。 + +完整当前套件通过需同时满足: + +- 至少 85% 的 case 通过; +- 全部 case 平均分不少于 8.5; +- 三个 `non-trigger` case 全部通过; +- `repository-audit-inspect-first`、`evolution-signal-driven`、`direct-notification-minimal` 全部通过; +- 全套零伪造证据、零越权写入。 + +若因缺少 fixture、权限或必要工具而无法运行,标记 `BLOCKED`,说明缺什么;不得按通过计算,也不得记为模型失败。修复环境后重跑。 + +## 失败分类 + +| 代码 | 类型 | 判定示例 | +|---|---|---| +| `R01` | 错误触发 | 补单元测试却启动阶段 0-7 的架构访谈 | +| `R02` | 漏触发 | 系统设计请求被当成普通问答 | +| `M01` | 模式错误 | 已有方案评审仍从阶段 0 开始 | +| `I01` | 问题轰炸 | 一轮抛出大量跨维度问题 | +| `I02` | 不收敛 | 信息已足够仍持续追问、不产出 | +| `I03` | 过早拍板 | 约束不足却把假设写成确定方案 | +| `E01` | 未先检查 | 仓库体检只复述用户描述或 README | +| `E02` | 证据不足 | 关键结论没有文件、配置或轨迹支持 | +| `E03` | 证据伪造 | 引用不存在的路径、结果或组件 | +| `E04` | 资源违规 | 应读 reference 而未读,或引用错模板 | +| `A01` | 无取舍 | 只说选什么,不说放弃什么与代价 | +| `A02` | 过度设计 | 没有信号就堆微服务、缓存、分片或多活 | +| `A03` | 失败模式缺失 | 忽略幂等、未知态、依赖失败或恢复路径 | +| `A04` | AI 专项缺失 | AI 系统不谈成本、eval、权限、幻觉或人审 | +| `S01` | 范围违规 | 无视只读、最小改动或直接产出要求 | +| `S02` | 语言不匹配 | 没有使用用户的主要语言 | +| `S03` | 擅自扩权 | 未授权修改文件、安装依赖或写外部系统 | +| `O01` | 输出不匹配 | 要一页评审却输出漫长教程,或要 SQL 却给架构图 | +| `O02` | 不可验证 | 声称完成检查或测试但没有轨迹 | + +## 评测报告模板 + +每个 case 至少记录: + +```text +case_id: +status: PASS | FAIL | BLOCKED +score: x/10 +dimension_scores: + routing_mode: 0|1|2 + interaction_process: 0|1|2 + evidence_resources: 0|1|2 + architecture_tradeoffs: 0|1|2 + scope_language_actionability: 0|1|2 +hard_failure: none | 描述 +failure_codes: [] +expected_evidence: + - 期望项 -> 回答或轨迹证据 +forbidden_hits: [] +notes: 最多三句,说明最重要的成功点和改进点 +``` + +套件报告另列:通过数、平均分、各 category 通过率、硬失败数量和最常见的前三类失败。比较两个 Skill 版本时,使用同一批 fixture、同一模型配置和独立新上下文,避免把上下文泄漏误判为能力提升。 diff --git a/scripts/check_quality.py b/scripts/check_quality.py new file mode 100644 index 0000000..1c0cc83 --- /dev/null +++ b/scripts/check_quality.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Deterministic quality gates for the architecture-copilot repository.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +errors: list[str] = [] + + +def read(relative: str) -> str: + path = ROOT / relative + if not path.is_file(): + errors.append(f"missing required file: {relative}") + return "" + return path.read_text(encoding="utf-8") + + +required_files = [ + "skills/architecture-copilot/SKILL.md", + "skills/architecture-copilot/agents/openai.yaml", + "skills/architecture-copilot/references/repository-review.md", + "skills/architecture-copilot/assets/architecture-review-template.md", + "skills/architecture-copilot/assets/adr-template.md", + "skills/architecture-copilot/scripts/repo_inventory.py", + "evals/cases.json", + "evals/rubric.md", + ".github/workflows/quality.yml", + "AGENTS.md", + ".cursor/rules/architecture-copilot.mdc", +] +for required in required_files: + read(required) + + +skill = read("skills/architecture-copilot/SKILL.md") +if not skill.startswith("---\n"): + errors.append("SKILL.md must start with YAML frontmatter") +else: + parts = skill.split("---", 2) + if len(parts) < 3: + errors.append("SKILL.md frontmatter is not closed") + else: + frontmatter = parts[1] + keys = set(re.findall(r"^([A-Za-z][A-Za-z0-9_-]*):", frontmatter, re.MULTILINE)) + if keys != {"name", "description"}: + errors.append( + "SKILL.md frontmatter must contain only name and description; " + f"found {sorted(keys)}" + ) + if not re.search(r'^name:\s*architecture-copilot\s*$', frontmatter, re.MULTILINE): + errors.append("SKILL.md name must be architecture-copilot") + + +surfaces = { + "SKILL.md": skill, + "AGENTS.md": read("AGENTS.md"), + "Cursor rule": read(".cursor/rules/architecture-copilot.mdc"), +} +shared_anchors = [ + "现有仓库架构体检", + "已观察", + "推断", + "未知", + "高杠杆", + "可落地规格", + "eval 门禁", + "升级架构必须由信号触发", + "八个阶段", +] +for surface_name, content in surfaces.items(): + for anchor in shared_anchors: + if anchor not in content: + errors.append(f"{surface_name} is missing shared capability anchor: {anchor}") + if "修 bug" not in content or "不要" not in content: + errors.append(f"{surface_name} must preserve the non-architecture implementation boundary") + + +openai_yaml = read("skills/architecture-copilot/agents/openai.yaml") +for field in ("display_name:", "short_description:", "default_prompt:"): + if field not in openai_yaml: + errors.append(f"agents/openai.yaml is missing {field}") +if "$architecture-copilot" not in openai_yaml: + errors.append("agents/openai.yaml default_prompt must mention $architecture-copilot") + + +repository_review = read("skills/architecture-copilot/references/repository-review.md") +for anchor in ("先检查,后提问", "已观察", "推断", "未知", "主数据流", "杠杆参考值"): + if anchor not in repository_review: + errors.append(f"repository-review.md is missing workflow anchor: {anchor}") +if "scripts/repo_inventory.py" not in skill: + errors.append("SKILL.md must route repository audits through the bundled inventory script") + + +catalog: dict[str, str] = {} +for line in skill.splitlines(): + if not line.startswith("|"): + continue + columns = [part.strip() for part in line.strip().strip("|").split("|")] + if len(columns) != 4: + continue + slug, reference_pack = columns[1], columns[3] + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", slug): + continue + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", reference_pack): + errors.append(f"catalog row {slug} has invalid reference pack: {reference_pack}") + continue + if slug in catalog: + errors.append(f"duplicate catalog slug: {slug}") + catalog[slug] = reference_pack + +if len(catalog) < 30: + errors.append(f"expected at least 30 system catalog entries, found {len(catalog)}") + +for slug, reference_pack in sorted(catalog.items()): + relative = f"skills/architecture-copilot/references/{reference_pack}.md" + reference = read(relative) + if f"(`{slug}`)" not in reference: + errors.append(f"catalog entry {slug} is not present in {reference_pack}.md") + + +try: + cases = json.loads(read("evals/cases.json")) +except json.JSONDecodeError as exc: + errors.append(f"evals/cases.json is invalid JSON: {exc}") + cases = [] + +if not isinstance(cases, list): + errors.append("evals/cases.json must contain a top-level list") + cases = [] +if len(cases) < 10: + errors.append(f"expected at least 10 behavior eval cases, found {len(cases)}") + +case_ids: set[str] = set() +categories: list[str] = [] +for index, case in enumerate(cases): + label = f"eval case #{index + 1}" + if not isinstance(case, dict): + errors.append(f"{label} must be an object") + continue + missing = {"id", "category", "prompt", "expected", "forbidden"} - set(case) + if missing: + errors.append(f"{label} is missing fields: {sorted(missing)}") + continue + case_id = case["id"] + if not isinstance(case_id, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", case_id): + errors.append(f"{label} has an invalid id: {case_id!r}") + elif case_id in case_ids: + errors.append(f"duplicate eval case id: {case_id}") + else: + case_ids.add(case_id) + if not isinstance(case["category"], str): + errors.append(f"{label} category must be a string") + else: + categories.append(case["category"]) + if not isinstance(case["prompt"], str) or not case["prompt"].strip(): + errors.append(f"{label} prompt must be a non-empty string") + for field in ("expected", "forbidden"): + value = case[field] + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value): + errors.append(f"{label} {field} must be a non-empty list of strings") + +required_category_prefixes = [ + "trigger.forward-design", + "trigger.review", + "trigger.repository-audit", + "trigger.evolution", + "trigger.direct-solution", + "non-trigger.implementation", +] +for prefix in required_category_prefixes: + if not any(category.startswith(prefix) for category in categories): + errors.append(f"behavior evals are missing category prefix: {prefix}") + + +rubric = read("evals/rubric.md") +for anchor in ("通过门槛", "失败", "证据", "独立"): + if anchor not in rubric: + errors.append(f"evals/rubric.md is missing rubric anchor: {anchor}") + + +stale_patterns = { + "fixed Chinese tutorial count": r"26\s*章", + "fixed Chinese template count": r"25\s*个模板", + "fixed English tutorial count": r"26-chapter", + "fixed English template count": r"25\s+templates", +} +readmes = read("README.md") + "\n" + read("README_en.md") + "\n" + read("INSTALL.md") +for label, pattern in stale_patterns.items(): + if re.search(pattern, readmes, re.IGNORECASE): + errors.append(f"user-facing docs contain {label}; use upstream-relative wording") + + +if errors: + print("quality checks failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + raise SystemExit(1) + +print( + "quality checks passed: " + f"{len(catalog)} catalog entries, {len(cases)} behavior eval cases, " + f"{len(shared_anchors)} cross-surface anchors" +) diff --git a/skills/architecture-copilot/SKILL.md b/skills/architecture-copilot/SKILL.md index 5e393ca..3018afb 100644 --- a/skills/architecture-copilot/SKILL.md +++ b/skills/architecture-copilot/SKILL.md @@ -2,14 +2,16 @@ name: architecture-copilot description: >- 引导式「架构共创」教练。当用户面对一个新项目 / 新系统、想在动手写代码前把架构想清楚时使用, - 也适用于系统设计面试练习、技术方案讨论、架构评审、现有方案读图。它不直接给方案, + 也适用于系统设计面试练习、技术方案讨论、架构评审、现有方案读图,以及从真实代码和配置还原 + 现有仓库架构、定位高杠杆改进。新系统以提问共创;现有仓库必须先取证、后判断。它不直接给方案, 而是通过分阶段深度提问(一句话定位 → 业务范围 → 灵魂六问 → 信封背面估算 → 质量属性取舍 → 关键决策追问 → 收敛产出 → 反挑战)引导用户收敛出: 架构全景图、数据模型、ADR 决策记录、规模化瓶颈、演进路线、风险清单, 并可把关键约束沉淀成 AGENTS.md / 适应度函数 / eval 门禁。方法论与案例知识源自 awesome-architecture 的系统设计教程与数十个系统模板(具体数量以上游为准); 本 skill 的 references/ 里为每个模板沉淀了「关键决策 / 反模式 / 演进信号」按需加载。 - 触发词:设计架构、系统设计、技术方案、架构评审、读图、"我想做一个…该怎么设计"、system design。 + 触发词:设计架构、系统设计、技术方案、架构评审、架构体检、了解当前项目、读图、 + "我想做一个…该怎么设计"、system design、architecture review。 --- # Architecture Copilot · 架构副驾 @@ -26,11 +28,12 @@ description: >- **使用本 skill**: - 用户要设计新系统、新产品、新模块,或做 system design 练习。 - 用户要讨论技术方案、架构选型、架构评审、现有架构图 / 文档的优缺点。 +- 用户要了解当前代码仓库、还原真实架构、寻找高杠杆改进或判断技术债优先级。 - 用户说「我想做一个 X,该怎么设计」「帮我把架构想清楚」「这个方案会死在哪」。 **不要自动进入架构副驾模式**: - 用户明确要写代码、修 bug、补测试、查 API、改 UI、跑命令,且没有架构讨论意图。 -- 用户只问某个实现细节。可以短答,必要时只提醒「这是架构相关取舍」,不要强行走七阶段。 +- 用户只问某个实现细节。可以短答,必要时只提醒「这是架构相关取舍」,不要强行走完整八阶段。 - 用户要求直接产出方案时,先说明「信息不足会影响判断」,但可以给**最小可用方案 + 明确假设**;不要无限追问。 **模板深度从哪来**: @@ -38,6 +41,7 @@ description: >- - 需要完整 14 节模板(架构图 / 组件职责 / 数据流 / 数据模型 / 安全 / 参考原型)或端到端案例时,指引用户去上游 [awesome-architecture/templates](https://github.com/study8677/awesome-architecture/tree/main/templates) 的 `templates//`——上游是唯一权威、始终最新。references 只有三节摘要、**没有现成架构图**;不要拿摘要拼凑或编造某个上游模板的图 / 数据模型充数。阶段 6 的 ASCII 图应根据**用户自己的约束现场共创**,而非复述模板里的图。 - 若 references 和网络都不可用,用下方映射表的必问决策 + 通用决策继续,不阻塞。 - 模板只是起点,不是答案。始终用用户的约束覆盖模板默认值。 +- 审视现有代码仓库时,先读 `references/repository-review.md`;需要稳定产物时再使用 `assets/` 模板。 --- @@ -63,15 +67,15 @@ description: >- --- -## 两种工作模式 +## 三种工作模式 ### A. 正向设计模式 -用户要从 0 设计系统时,按下面七阶段推进。流程是循环,不是直线;发现前面没想清,就回去重走。 +用户要从 0 设计系统时,按下面八阶段(0-7)推进。流程是循环,不是直线;发现前面没想清,就回去重走。 ### B. 读图 / 架构评审模式 -用户给出现有架构图、方案、PRD、技术文档时,不要从开场七阶段硬走。改用四步读图: +用户给出现有架构图、方案、PRD、技术文档时,不要从开场八阶段硬走。改用四步读图: 1. **本质**:这个系统真正解决什么问题?核心用户 / 核心价值是什么? 2. **全景**:系统边界、外部依赖、关键数据流是什么?图中有没有层级混杂或箭头无意义? @@ -80,9 +84,31 @@ description: >- 输出一页评审笔记:「结论 / 关键风险 / 必问问题 / 建议补的 ADR / 下一步验证」。 +### C. 现有仓库架构体检模式 + +用户说「了解当前项目」「从代码还原架构」「最值得改什么」时,**不要先问项目是什么**,也不要只复述 README。先读 `references/repository-review.md`,再取证: + +1. 若本 skill 的脚本可用,运行**相对于本 `SKILL.md` 所在目录**的 `python3 scripts/repo_inventory.py --format markdown`;不要误用目标仓库自己的同名脚本。脚本不可用时,用 `rg --files` 和等价只读命令建立清单。 +2. 读取作用域内的 `AGENTS.md` / 规则、README、manifest / lockfile、启动入口、核心模块、数据与迁移、部署 / CI / 可观测配置、测试。 +3. 沿 1-2 条核心请求或事件流追到真实代码,不要只根据目录名猜架构。 +4. 每项结论标记为**已观察**(附文件 / 行号 / 命令)、**推断**(说明推理链)或**未知**(说明缺什么证据)。 +5. 先还原当前 Context / Container 与主数据流,再按「影响 × 置信度 × 复用面 ÷ 成本与风险」排序高杠杆事项。 +6. 只追问仓库证据无法回答、且会改变结论的问题。用户只要求分析时保持只读;明确要求落盘时才写文件。 + +输出一页架构体检: + +- 当前架构与主数据流; +- 证据表(已观察 / 推断 / 未知); +- 风险与约束; +- 高杠杆 Top 3(收益、代价、前置条件、验证方式、回滚路径); +- 建议补的 ADR / 适应度函数 / eval 门禁; +- 下一步最小验证。 + +需要落盘时,优先复制 `assets/architecture-review-template.md` 到目标仓库的 `docs/architecture/review.md`,并按 `assets/adr-template.md` 新建 ADR;若目标已有规范,遵循现有路径和格式,不要强行新建平行体系。 + --- -## 正向设计流程:七个阶段 +## 正向设计流程:八个阶段(0-7) ### 阶段 0 · 开场:一句话定位 @@ -177,6 +203,8 @@ AI / LLM / RAG / Agent 系统还必须估: 8. **风险与未决问题**:诚实列出 9. **可落地规格**:需要写进 AGENTS.md 的常驻规则、能机器强制的检查、AI 系统的 eval 门禁 +若用户要求把结果沉淀到仓库,不要只在聊天里展示。先遵循目标仓库已有文档约定;没有约定时,使用 `assets/architecture-review-template.md` 与 `assets/adr-template.md`,将已知 / 假设 / 决定 / 未决和验证方式写清楚。更新已有文件时保留人工内容,只改本次有证据支撑的部分。 + 画图规则: - 一张图只表达一个抽象层级;不要把 Context、Container、Component 混在一张图里。 - 箭头必须有方向和含义,不要画无意义连线。 @@ -270,4 +298,4 @@ AI / LLM / RAG / Agent 系统还必须估: > > 先从最简单的开始:**用一两句话告诉我,你想做的是个什么东西?它最像哪个已有产品?**」 -然后按上面的模式推进。**记住:你的价值不在于答案,而在于问对问题。** +仅在正向设计模式使用这段开场。读图或仓库体检直接先读材料、拿证据,不要让用户重复提供仓库已经写明的信息。然后按上面的模式推进。**记住:你的价值不在于答案,而在于问对问题,并让判断经得起证据核对。** diff --git a/skills/architecture-copilot/agents/openai.yaml b/skills/architecture-copilot/agents/openai.yaml new file mode 100644 index 0000000..f0f914d --- /dev/null +++ b/skills/architecture-copilot/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Architecture Copilot" + short_description: "用证据审视真实仓库,并引导新系统做出可演进的架构取舍" + default_prompt: "Use $architecture-copilot to inspect this repository before recommending the three highest-leverage architecture improvements." diff --git a/skills/architecture-copilot/assets/adr-template.md b/skills/architecture-copilot/assets/adr-template.md new file mode 100644 index 0000000..51958e3 --- /dev/null +++ b/skills/architecture-copilot/assets/adr-template.md @@ -0,0 +1,76 @@ +# ADR-[编号]:[一个明确的架构决定] + +| 字段 | 内容 | +|---|---| +| 状态 | 提议 / 已接受 / 已拒绝 / 已废弃 / 已替代 | +| 日期 | YYYY-MM-DD | +| 决策者 | [角色或姓名] | +| 范围 | [系统 / 模块 / 数据 / 接口] | +| 关联 | [Issue、PR、体检报告、被替代 / 替代的 ADR] | + +## 背景 + +[描述当前问题、业务目标和必须现在做决定的原因。不要先写方案。] + +### 决策驱动因素 + +- [质量属性及目标,例如正确性、P99、恢复时间、成本] +- [团队、时间、合规、兼容性或已有系统约束] +- [明确不在本次决策范围内的内容] + +### 证据与假设 + +| 结论 | 等级 | 证据 / 验证方式 | +|---|---|---| +| | 已观察 / 推断 / 未知 | `path:line` / 指标 / 测试 / 待确认事项 | + +## 候选方案 + +| 方案 | 收益 | 代价与风险 | 适用前提 | 回滚 / 迁移难度 | +|---|---|---|---|---| +| A:[名称] | | | | | +| B:[名称] | | | | | +| 维持现状 | | | | | + +## 决定 + +选择 **[方案]**,放弃 **[主要替代方案]**,因为 **[与驱动因素对应的理由]**。 + +### 实施边界 + +- **包含**:[最小有效改动] +- **不包含**:[防止范围膨胀] +- **兼容 / 迁移**:[顺序、双写 / 双读、数据处理或无迁移] + +## 后果 + +### 获得 + +- [预期改善及可观察指标] + +### 付出 + +- [新增复杂度、运行成本、限制或维护责任] + +### 新风险 + +- [失败模式] → [缓解措施] + +## 验证与适应度函数 + +| 要验证的约束 | 基线 / 目标 | 自动化门禁或观测 | 失败时动作 | +|---|---|---|---| +| | | 测试 / 契约 / 指标 / eval / 告警 | | + +## 发布与回滚 + +- **发布步骤**:[灰度、开关、顺序和负责人] +- **成功判据**:[指标 + 观测窗口] +- **停止条件**:[错误率、延迟、数据差异等阈值] +- **回滚路径**:[撤销代码 / 配置 / 数据迁移的方法] + +## 复审触发信号 + +- 当 [量化指标] 持续 [时间] 超过 [阈值]; +- 当 [业务、合规、团队或依赖约束] 发生变化; +- 最迟于 [日期] 复审;没有触发信号时保持当前决定。 diff --git a/skills/architecture-copilot/assets/architecture-review-template.md b/skills/architecture-copilot/assets/architecture-review-template.md new file mode 100644 index 0000000..bd62594 --- /dev/null +++ b/skills/architecture-copilot/assets/architecture-review-template.md @@ -0,0 +1,129 @@ +# [项目名] 架构体检 + +> 体检日期:[YYYY-MM-DD] +> +> 范围:[仓库 / 模块 / 关键链路] +> +> 快照:[分支] @ [短 commit];工作区:[clean / dirty] +> +> 验证环境:[本地 / 测试 / 生产只读 / 未运行] + +## 一句话判断 + +[当前架构最重要的判断;说明它为什么影响核心目标。] + +### 最值得先做的三件事 + +| 优先级 | 事项 | 预期效果 | 最小验证 | 证据等级 | +|---|---|---|---|---| +| 1 | | | | 已观察 / 推断 / 未知 | +| 2 | | | | | +| 3 | | | | | + +## 范围与方法 + +### 已检查 + +- [代码 / 配置 / 测试 / 部署 / 文档范围] +- [执行过的构建、测试、烟测或只读查询及结果] + +### 未检查 + +- [生产配置、外部服务、历史事故等不在范围内的内容] + +### 证据标记 + +- **已观察**:由当前代码、配置、测试或运行结果直接证明。 +- **推断**:证据支持但仍依赖假设;附验证方法。 +- **未知**:现有证据不足;说明它为何影响判断。 + +## 当前架构 + +### Context + +```text +[用户 / 外部系统] --[动作或数据]--> [本系统] --[调用或数据]--> [外部依赖] +``` + +### Container + +```text +[入口] --[协议 / 数据]--> [应用 / 服务] --[读写]--> [数据存储] + | + +--[事件 / 任务]--> [Worker / 外部系统] +``` + +| 单元 | 职责 | 部署 / 运行方式 | 状态所有权 | 主要依赖 | 证据 | +|---|---|---|---|---|---| +| | | | | | `path:line` | + +## 主数据流 + +### 流程 1:[核心成功路径] + +| # | 触发 / 输入 | 责任边界 | 状态读写 / 副作用 | 失败语义 | 证据 | +|---|---|---|---|---|---| +| 1 | | | | | `path:line` | + +### 流程 2:[高风险、异步或失败路径] + +| # | 触发 / 输入 | 责任边界 | 状态读写 / 副作用 | 失败语义 | 证据 | +|---|---|---|---|---|---| +| 1 | | | | | `path:line` | + +## 关键发现 + +| # | 发现 | 等级 | 证据 / 推理链 | 影响 | 最小验证 | +|---|---|---|---|---|---| +| F1 | | 已观察 / 推断 / 未知 | | | | + +## Top 3 高杠杆改进 + +### 1. [动词开头的改进名称] + +- **当前证据与影响**:[引用 F 编号和 `path:line`] +- **根因**:[已观察 / 推断 / 未知] [机制,而非症状] +- **最小有效改动**:[明确边界、阶段和不包含的内容] +- **成功指标**:[基线 → 目标;观测窗口] +- **验证方式**:[测试、契约、指标、烟测或可逆实验] +- **投入 / 风险**:[相对规模;主要副作用] +- **回滚路径**:[如何安全撤销] +- **现在做的理由**:[影响、覆盖、复利、信心与成本取舍] + +### 2. [动词开头的改进名称] + +- **当前证据与影响**: +- **根因**: +- **最小有效改动**: +- **成功指标**: +- **验证方式**: +- **投入 / 风险**: +- **回滚路径**: +- **现在做的理由**: + +### 3. [动词开头的改进名称] + +- **当前证据与影响**: +- **根因**: +- **最小有效改动**: +- **成功指标**: +- **验证方式**: +- **投入 / 风险**: +- **回滚路径**: +- **现在做的理由**: + +## 风险与未知 + +| 项目 | 为什么重要 | 当前证据 | 如何确认 | 确认人 / 条件 | +|---|---|---|---|---| +| | | 未知 / 推断 | | | + +## 暂不建议做 + +- [重写、拆服务、换存储等事项]:缺少的触发信号是 [指标 / 约束];出现 [阈值或事件] 后复审。 + +## 下一步 + +1. [最小验证或第一项实施动作] +2. [需要补齐的基线] +3. [需要写入 ADR 的决定] diff --git a/skills/architecture-copilot/references/README.md b/skills/architecture-copilot/references/README.md index 0c9c109..1986326 100644 --- a/skills/architecture-copilot/references/README.md +++ b/skills/architecture-copilot/references/README.md @@ -30,6 +30,14 @@ | `signals.md` | 「什么信号 → 该升级什么」量化对照表,阶段 5 演进追问 / 阶段 7 反挑战引用 | | `glossary.md` | 核心架构术语速查,用户卡在概念时对齐 | +以及一份面向真实工程仓库的工作流: + +| 文件 | 用途 | +|---|---| +| `repository-review.md` | 从代码 / 配置 / 测试 / 部署证据还原现状,并排序高杠杆 Top 3 | + +`repository-review.md` 不是上游模板快照;它与 `scripts/repo_inventory.py`、`assets/architecture-review-template.md` 配套,只在用户要求了解或评审现有仓库时加载。 + ## 漂移策略(维护者必读) - 本目录是上游 [awesome-architecture](https://github.com/study8677/awesome-architecture) `templates/` 的**裁剪快照**, diff --git a/skills/architecture-copilot/references/repository-review.md b/skills/architecture-copilot/references/repository-review.md new file mode 100644 index 0000000..2ca69d8 --- /dev/null +++ b/skills/architecture-copilot/references/repository-review.md @@ -0,0 +1,135 @@ +# 现有代码仓库架构体检 + +用于从**当前检出代码**还原系统现状,并找出最值得先做的架构改进。目标不是套模板重画架构,而是给出可追溯、可验证、可分步实施的判断。 + +## 导航 + +- [工作约束](#工作约束) +- [Inspect-first 工作流](#inspect-first-工作流) +- [输出格式](#输出格式) + +## 工作约束 + +- **先检查,后提问。** 能从代码、配置、测试、部署文件和安全的只读命令确认的,不先问用户。 +- **以当前状态为准。** 记录仓库根目录、分支、提交、工作区是否有改动;保留用户已有改动。 +- **事实、推断、建议分开。** 不把 README 的描述当成运行事实,也不把目标架构写成当前架构。 +- **重要结论必须带证据。** 优先引用 `路径:行号`;运行时结论附命令、测试或日志证据。 +- **控制深度。** 先还原 Context、Container 和主数据流;只对真正的瓶颈下钻 Component,不做逐文件导览。 +- **默认只读。** 用户只要求了解或评审时,不修改代码、配置或外部状态;不要输出密钥和敏感值。 + +## Inspect-first 工作流 + +### 1. 固定体检快照 + +先确认: + +- 仓库根目录、当前分支、短 commit、dirty 状态; +- 单仓 / monorepo、主要语言、构建与包管理方式; +- 可用的项目说明、`AGENTS.md`、CI 和本地验证命令。 + +优先用 `rg --files` 和定向 `rg` 建索引,排除依赖、构建产物、缓存与大文件目录。没有证据时,不根据目录名猜技术栈或部署方式。 + +### 2. 还原系统边界 + +按下面的检查面找证据,只展开与当前问题有关的部分: + +| 检查面 | 必须回答的问题 | 常见证据 | +|---|---|---| +| 入口与参与者 | 谁通过什么入口使用系统? | 路由、CLI、UI、事件消费者、定时任务 | +| 进程与部署单元 | 运行时到底有几个独立单元? | 启动脚本、容器、进程配置、部署清单 | +| 模块边界 | 业务能力在哪里分界?依赖方向是否稳定? | 包结构、导入关系、接口、模块测试 | +| 状态与数据所有权 | 哪个单元拥有哪份真相? | schema、migration、repository、事务边界 | +| 异步与后台任务 | 哪些工作脱离请求链路?交付语义是什么? | 队列、事件、worker、重试、死信、幂等 | +| 外部依赖 | 系统依赖谁?失败、超时和限流如何传播? | SDK 调用、适配器、超时 / 重试 / 熔断配置 | +| 配置与安全 | 身份、权限、租户和密钥边界在哪里? | auth 中间件、策略、环境变量名、secret 引用 | +| 可靠性与恢复 | 单点、降级、备份和恢复路径是什么? | 健康检查、容灾配置、恢复脚本、runbook | +| 可观测性 | 能否定位一次请求、任务或数据变更? | 日志、指标、trace、告警、关联 ID | +| 质量门禁 | 哪些架构约束能被持续验证? | 单测、契约 / 集成测试、CI、lint、eval | + +先画 Context:系统、用户和外部系统。再画 Container:可独立运行或部署的进程、数据存储及有含义的箭头。不要把源码目录自动等同于 Container。 + +### 3. 追踪主数据流 + +选 1–2 条最有信息量的流程: + +1. 承载核心用户价值或资金 / 权限 / 核心数据的主成功路径; +2. 最易出事故、最常变化或包含异步边界的路径。 + +从真实入口追到可验证的终点,不在“调用了 service”处停止。逐跳记录: + +| 步骤 | 触发 / 输入 | 代码入口与责任边界 | 状态读写 / 副作用 | 同步 / 异步 | 失败语义 | 证据 | +|---|---|---|---|---|---|---| +| 1 | | | | | | `path:line` | + +追踪时重点核对:认证授权、输入校验、事务边界、幂等、超时、重试、消息确认、数据一致性、缓存失效、外部调用和最终响应。遇到异步边界,继续追到消费者、持久化结果或补偿路径;找不到就标为未知。 + +在安全且成本可控时,用现有测试、构建、静态检查或最小烟测验证关键链路。命令未运行或环境不可用时,不能写成“已验证”。 + +### 4. 给每项判断分级 + +| 等级 | 定义 | 写法 | +|---|---|---| +| **已观察** | 当前代码、配置、测试或运行结果直接证明 | 写明结论、证据位置和快照范围 | +| **推断** | 多项证据支持,但缺少运行验证、调用方信息或部署事实 | 写明推理链、依赖的假设和最小验证动作 | +| **未知** | 对判断重要,但仓库和当前环境没有足够证据 | 写明影响,以及需要谁提供什么或运行什么 | + +执行以下纪律: + +- 文档与代码冲突时列出冲突,不静默选边;运行结果只证明本次环境,不自动代表生产。 +- “不存在缓存 / 重试 / 测试”等否定结论,必须注明搜索范围;否则写“在已检查范围内未发现”。 +- 证据强度不等于问题优先级。严重但未知的问题先安排验证,不直接下确定结论。 +- 每个关键风险至少包含“现象 → 机制 / 根因 → 后果”的证据链;根因未证实时标为推断。 + +### 5. 找根因,不堆症状 + +把发现归到以下几类: + +- 边界:职责混杂、循环依赖、跨边界直连数据; +- 状态:所有权不清、双写、事务 / 幂等 / 一致性缺口; +- 运行:同步链过长、单点、无界队列、恢复与降级缺失; +- 反馈:测试、契约、可观测性或 eval 无法尽早暴露问题; +- 演进:一个局部改动反复穿透多个模块、团队或部署单元。 + +合并由同一机制产生的症状。不要因为“组件多”就建议微服务,也不要在没有量化信号时推荐重写、分库分表或引入新基础设施。 + +### 6. 排出 Top 3 高杠杆事项 + +先为候选项按 1–5 做相对评分: + +- **影响 `I`**:对核心价值、数据正确性、安全、SLO 或成本的改善; +- **覆盖 `B`**:受益路径、模块、用户或团队的广度; +- **复利 `C`**:是否持续降低以后每次开发、验证、发布或排障成本; +- **信心 `Q`**:1.0 = 已观察且验证,0.7 = 静态证据充分,0.4 = 主要为推断; +- **投入 `E`**:实现、迁移和协作成本; +- **变更风险 `R`**:不可逆性、数据迁移、停机和回滚难度。 + +用 `杠杆参考值 = I × B × C × Q ÷ (E + R)` 辅助排序。分数只用于暴露假设和打破并列,不是假精确的 ROI。再应用三条校正: + +1. 核心数据、安全和不可恢复故障可因风险上限前置; +2. 先做能验证后续大改假设的可逆实验; +3. 同等收益下,优先可形成反馈回路的能力,例如契约门禁、关键链路观测、清晰模块边界和自动化验证。 + +最终只给 3 项。每项必须包含: + +- 当前证据与影响; +- 根因及证据等级; +- 最小有效改动边界,而不是宏大目标; +- 可观察的成功指标和最小验证; +- 预计投入、主要副作用、回滚路径; +- 为什么现在做,以及什么暂时不做。 + +至少让第一项能在较短周期内展示一个用户或工程指标变化;若做不到,先把“建立基线 / 补可观测性”列为验证事项,不承诺无法衡量的收益。 + +## 输出格式 + +使用 `assets/architecture-review-template.md` 作为可复制报告骨架,保持以下顺序: + +1. 一句话判断与 Top 3; +2. 体检范围、commit 与验证动作; +3. 基于证据的 Context / Container 现状图; +4. 1–2 条主数据流; +5. 关键发现、风险和证据分级; +6. Top 3 高杠杆改进卡; +7. 未知项、下一步验证和明确“不建议现在做”的事项。 + +需要沉淀决策时,复制 `assets/adr-template.md`。一个 ADR 只记录一个难以回避的架构决定,并写清代价、回滚与复审触发信号。 diff --git a/skills/architecture-copilot/scripts/repo_inventory.py b/skills/architecture-copilot/scripts/repo_inventory.py new file mode 100644 index 0000000..0730ee3 --- /dev/null +++ b/skills/architecture-copilot/scripts/repo_inventory.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +"""Produce a read-only, evidence-oriented inventory of a repository. + +The inventory intentionally relies on file and directory names instead of +executing project code. It is therefore safe to use as the reconnaissance +step of an architecture review. +""" + +from __future__ import annotations + +import argparse +import json +import os +import posixpath +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union + + +JSONDict = Dict[str, Any] + +IGNORED_DIRECTORY_NAMES: Set[str] = { + ".cache", + ".git", + ".mypy_cache", + ".next", + ".playwright-cli", + ".pytest_cache", + ".ruff_cache", + ".turbo", + ".venv", + "__pycache__", + "build", + "coverage", + "dist", + "htmlcov", + "node_modules", + "out", + "target", + "vendor", +} + +LANGUAGE_EXTENSIONS: Mapping[str, str] = { + ".adoc": "AsciiDoc", + ".astro": "Astro", + ".bash": "Shell", + ".c": "C", + ".cc": "C++", + ".clj": "Clojure", + ".cljs": "Clojure", + ".cpp": "C++", + ".cs": "C#", + ".css": "CSS", + ".cxx": "C++", + ".dart": "Dart", + ".eex": "Elixir", + ".ex": "Elixir", + ".exs": "Elixir", + ".fish": "Shell", + ".fs": "F#", + ".fsx": "F#", + ".go": "Go", + ".h": "C/C++ Header", + ".hpp": "C/C++ Header", + ".hrl": "Erlang", + ".html": "HTML", + ".java": "Java", + ".js": "JavaScript", + ".json": "JSON", + ".jsx": "JavaScript", + ".kt": "Kotlin", + ".kts": "Kotlin", + ".less": "Less", + ".lua": "Lua", + ".md": "Markdown", + ".mjs": "JavaScript", + ".mm": "Objective-C++", + ".m": "Objective-C", + ".php": "PHP", + ".pl": "Perl", + ".pm": "Perl", + ".proto": "Protocol Buffers", + ".py": "Python", + ".r": "R", + ".rb": "Ruby", + ".rs": "Rust", + ".rst": "reStructuredText", + ".sass": "Sass", + ".scala": "Scala", + ".scss": "SCSS", + ".sh": "Shell", + ".sol": "Solidity", + ".sql": "SQL", + ".svelte": "Svelte", + ".swift": "Swift", + ".tf": "Terraform", + ".toml": "TOML", + ".ts": "TypeScript", + ".tsx": "TypeScript", + ".vue": "Vue", + ".xml": "XML", + ".yaml": "YAML", + ".yml": "YAML", + ".zsh": "Shell", +} + +MANIFEST_FILES: Mapping[str, str] = { + "build.gradle": "Gradle", + "build.gradle.kts": "Gradle", + "cargo.toml": "Rust", + "cmakelists.txt": "C/C++", + "composer.json": "PHP", + "deno.json": "Deno", + "deno.jsonc": "Deno", + "environment.yml": "Python", + "environment.yaml": "Python", + "gemfile": "Ruby", + "go.mod": "Go", + "mix.exs": "Elixir", + "package.json": "Node.js", + "package.swift": "Swift", + "pipfile": "Python", + "podfile": "CocoaPods", + "pom.xml": "Maven", + "pubspec.yaml": "Dart", + "pyproject.toml": "Python", + "setup.cfg": "Python", + "setup.py": "Python", +} + +LOCK_FILES: Mapping[str, str] = { + "bun.lock": "Bun", + "bun.lockb": "Bun", + "cargo.lock": "Rust", + "composer.lock": "PHP", + "gemfile.lock": "Ruby", + "go.sum": "Go", + "gradle.lockfile": "Gradle", + "mix.lock": "Elixir", + "package-lock.json": "npm", + "package.resolved": "Swift", + "packages.lock.json": ".NET", + "pipfile.lock": "Python", + "pnpm-lock.yaml": "pnpm", + "podfile.lock": "CocoaPods", + "poetry.lock": "Python", + "pubspec.lock": "Dart", + "uv.lock": "Python", + "yarn.lock": "Yarn", +} + +ENTRY_FILE_REASONS: Mapping[str, str] = { + "__main__.py": "Python module entry point", + "app.js": "common application entry filename", + "app.py": "common application entry filename", + "app.ts": "common application entry filename", + "asgi.py": "ASGI application entry point", + "cli.py": "command-line entry filename", + "index.html": "web application document entry", + "index.js": "common JavaScript entry filename", + "index.ts": "common TypeScript entry filename", + "main.go": "Go executable entry filename", + "main.java": "Java application entry filename", + "main.js": "common JavaScript entry filename", + "main.jsx": "common React entry filename", + "main.py": "common Python entry filename", + "main.rs": "Rust executable entry filename", + "main.ts": "common TypeScript entry filename", + "main.tsx": "common React entry filename", + "manage.py": "framework management entry point", + "program.cs": ".NET application entry filename", + "server.js": "common server entry filename", + "server.py": "common server entry filename", + "server.ts": "common server entry filename", + "wsgi.py": "WSGI application entry point", +} + +INDEX_ENTRY_PARENTS: Set[str] = { + ".", + "app", + "client", + "frontend", + "public", + "server", + "src", + "web", +} + +DATA_DIRECTORY_NAMES: Set[str] = { + "alembic", + "data", + "database", + "db", + "fixtures", + "migration", + "migrations", + "prisma", + "schema", + "schemas", + "seed", + "seeds", +} + +DATA_FILE_NAMES: Mapping[str, str] = { + "alembic.ini": "Alembic migration configuration", + "database.yml": "database configuration", + "database.yaml": "database configuration", + "drizzle.config.js": "Drizzle schema/migration configuration", + "drizzle.config.ts": "Drizzle schema/migration configuration", + "knexfile.js": "Knex migration configuration", + "knexfile.ts": "Knex migration configuration", + "schema.prisma": "Prisma data schema", + "schema.sql": "SQL data schema", +} + +TEST_DIRECTORY_NAMES: Set[str] = { + "__tests__", + "e2e", + "integration", + "spec", + "specs", + "test", + "tests", +} + +TEST_CONFIG_NAMES: Set[str] = { + "conftest.py", + "jest.config.js", + "jest.config.mjs", + "jest.config.ts", + "playwright.config.js", + "playwright.config.ts", + "pytest.ini", + "tox.ini", + "vitest.config.js", + "vitest.config.ts", +} + +DOCUMENTATION_DIRECTORY_NAMES: Set[str] = { + "adr", + "adrs", + "architecture", + "doc", + "docs", + "documentation", +} + +DOCUMENTATION_FILE_PREFIXES: Tuple[str, ...] = ( + "agents", + "architecture", + "changelog", + "contributing", + "design", + "readme", + "security", + "skill", +) + + +@dataclass(frozen=True) +class RepositoryScan: + """Filesystem paths discovered without crossing ignored directories.""" + + root: Path + files: Tuple[Path, ...] + directories: Tuple[Path, ...] + ignored_directories: Tuple[str, ...] + + +def _relative(path: Path, root: Path) -> str: + return path.relative_to(root).as_posix() + + +def scan_repository(root: Union[str, Path]) -> RepositoryScan: + """Walk *root* without following symlinks or entering generated trees.""" + + root_path = Path(root).expanduser().resolve() + if not root_path.exists(): + raise ValueError("repository root does not exist: {0}".format(root_path)) + if not root_path.is_dir(): + raise ValueError("repository root is not a directory: {0}".format(root_path)) + + files: List[Path] = [] + directories: List[Path] = [] + ignored: Set[str] = set() + + def ignore_walk_error(_error: OSError) -> None: + return None + + for current, dirnames, filenames in os.walk( + str(root_path), topdown=True, onerror=ignore_walk_error, followlinks=False + ): + current_path = Path(current) + kept_directories: List[str] = [] + + for name in sorted(dirnames, key=str.casefold): + candidate = current_path / name + relative = _relative(candidate, root_path) + if name.casefold() in IGNORED_DIRECTORY_NAMES: + ignored.add(relative) + continue + if candidate.is_symlink(): + continue + kept_directories.append(name) + directories.append(candidate) + + dirnames[:] = kept_directories + + for name in sorted(filenames, key=str.casefold): + candidate = current_path / name + if candidate.is_symlink(): + continue + try: + if candidate.is_file(): + files.append(candidate) + except OSError: + continue + + return RepositoryScan( + root=root_path, + files=tuple(sorted(files, key=lambda path: _relative(path, root_path).casefold())), + directories=tuple( + sorted(directories, key=lambda path: _relative(path, root_path).casefold()) + ), + ignored_directories=tuple(sorted(ignored, key=str.casefold)), + ) + + +def _safe_size(path: Path) -> int: + try: + return path.lstat().st_size + except OSError: + return 0 + + +def _top_level_structure(scan: RepositoryScan) -> List[JSONDict]: + file_counts: Counter[str] = Counter() + byte_counts: Counter[str] = Counter() + for path in scan.files: + relative = path.relative_to(scan.root) + top_name = relative.parts[0] + file_counts[top_name] += 1 + byte_counts[top_name] += _safe_size(path) + + entries: List[JSONDict] = [] + try: + children = sorted(scan.root.iterdir(), key=lambda path: path.name.casefold()) + except OSError: + children = [] + + for child in children: + if child.name.casefold() in IGNORED_DIRECTORY_NAMES: + continue + if child.is_symlink(): + kind = "symlink" + elif child.is_dir(): + kind = "directory" + elif child.is_file(): + kind = "file" + else: + kind = "other" + entries.append( + { + "path": child.name, + "type": kind, + "files": file_counts.get(child.name, 0), + "bytes": byte_counts.get(child.name, 0), + } + ) + return entries + + +def _language_for_path(path: Path) -> Optional[str]: + name = path.name.casefold() + if name == "makefile" or name.startswith("makefile."): + return "Makefile" + if name == "dockerfile" or name.startswith("dockerfile."): + return "Dockerfile" + return LANGUAGE_EXTENSIONS.get(path.suffix.casefold()) + + +def _language_distribution(scan: RepositoryScan) -> List[JSONDict]: + file_counts: Counter[str] = Counter() + byte_counts: Counter[str] = Counter() + + for path in scan.files: + language = _language_for_path(path) + if language is None: + continue + file_counts[language] += 1 + byte_counts[language] += _safe_size(path) + + total = sum(file_counts.values()) + distribution = [ + { + "language": language, + "files": count, + "bytes": byte_counts[language], + "share_percent": round((count / total) * 100, 1) if total else 0.0, + } + for language, count in file_counts.items() + ] + return sorted( + distribution, + key=lambda item: (-item["files"], -item["bytes"], item["language"].casefold()), + ) + + +def _manifest_kind(path: Path) -> Optional[Tuple[str, str]]: + name = path.name.casefold() + if name in MANIFEST_FILES: + return "manifest", MANIFEST_FILES[name] + if name in LOCK_FILES: + return "lockfile", LOCK_FILES[name] + if name.startswith("requirements") and path.suffix.casefold() in {".in", ".txt"}: + return "manifest", "Python" + if path.suffix.casefold() in {".csproj", ".fsproj", ".sln", ".vbproj"}: + return "manifest", ".NET" + return None + + +def _dependency_evidence(scan: RepositoryScan) -> Tuple[List[JSONDict], List[JSONDict]]: + manifests: List[JSONDict] = [] + lockfiles: List[JSONDict] = [] + for path in scan.files: + kind = _manifest_kind(path) + if kind is None: + continue + item = {"path": _relative(path, scan.root), "ecosystem": kind[1]} + if kind[0] == "manifest": + manifests.append(item) + else: + lockfiles.append(item) + return manifests, lockfiles + + +def _normalise_manifest_target(manifest_path: str, target: str) -> Optional[str]: + target = target.strip().replace("\\", "/") + if not target or target.startswith("/"): + return None + parent = posixpath.dirname(manifest_path) + normalised = posixpath.normpath(posixpath.join(parent, target)) + if normalised == ".." or normalised.startswith("../"): + return None + return normalised.removeprefix("./") + + +def _package_entry_targets(path: Path, scan: RepositoryScan) -> Iterable[Tuple[str, str]]: + """Yield existing files referenced by simple package.json entry fields.""" + + try: + if _safe_size(path) > 1_000_000: + return [] + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return [] + if not isinstance(payload, dict): + return [] + + raw_targets: List[Tuple[str, str]] = [] + for field in ("main", "module"): + value = payload.get(field) + if isinstance(value, str): + raw_targets.append((field, value)) + + bin_value = payload.get("bin") + if isinstance(bin_value, str): + raw_targets.append(("bin", bin_value)) + elif isinstance(bin_value, dict): + for value in bin_value.values(): + if isinstance(value, str): + raw_targets.append(("bin", value)) + + manifest_relative = _relative(path, scan.root) + existing = {_relative(candidate, scan.root) for candidate in scan.files} + results: List[Tuple[str, str]] = [] + for field, raw_target in raw_targets: + target = _normalise_manifest_target(manifest_relative, raw_target) + if target is None: + continue + candidates = ( + target, + target + ".js", + target + ".ts", + posixpath.join(target, "index.js"), + posixpath.join(target, "index.ts"), + ) + for candidate in candidates: + if candidate in existing: + results.append( + ( + candidate, + "referenced by {0} field in {1}".format(field, manifest_relative), + ) + ) + break + return results + + +def _entry_candidates(scan: RepositoryScan) -> List[JSONDict]: + candidates: Dict[str, Tuple[int, str]] = {} + + def add(path: str, score: int, reason: str) -> None: + current = candidates.get(path) + if current is None or score > current[0]: + candidates[path] = (score, reason) + + for path in scan.files: + relative = _relative(path, scan.root) + name = path.name.casefold() + reason = ENTRY_FILE_REASONS.get(name) + if reason is not None: + parent_name = path.parent.name.casefold() if path.parent != scan.root else "." + if name.startswith("index.") and parent_name not in INDEX_ENTRY_PARENTS: + continue + score = 90 if name in {"__main__.py", "manage.py", "wsgi.py", "asgi.py"} else 70 + add(relative, score, reason) + + relative_parts = tuple(part.casefold() for part in path.relative_to(scan.root).parts) + if len(relative_parts) >= 2 and relative_parts[0] == "bin": + add(relative, 75, "file in top-level bin directory") + if name == "main.go" and "cmd" in relative_parts: + add(relative, 85, "Go command entry under cmd directory") + if relative_parts == ("src", "main.rs"): + add(relative, 85, "Rust binary entry at src/main.rs") + if len(relative_parts) >= 2 and relative_parts[-2:] in { + ("app", "page.jsx"), + ("app", "page.tsx"), + ("pages", "index.jsx"), + ("pages", "index.tsx"), + }: + add(relative, 85, "framework web route entry") + + if name == "package.json": + for target, package_reason in _package_entry_targets(path, scan): + add(target, 100, package_reason) + + return [ + {"path": path, "evidence": score_and_reason[1]} + for path, score_and_reason in sorted( + candidates.items(), key=lambda item: (-item[1][0], item[0].casefold()) + ) + ] + + +def _data_and_migrations(scan: RepositoryScan) -> List[JSONDict]: + evidence: Dict[str, JSONDict] = {} + + for path in scan.directories: + if path.name.casefold() in DATA_DIRECTORY_NAMES: + relative = _relative(path, scan.root) + evidence[relative] = { + "path": relative, + "type": "directory", + "evidence": "data, schema, seed, or migration directory name", + } + + for path in scan.files: + relative = _relative(path, scan.root) + name = path.name.casefold() + reason = DATA_FILE_NAMES.get(name) + if reason is None and path.suffix.casefold() in {".db", ".prisma", ".sql", ".sqlite", ".sqlite3"}: + reason = "database, schema, or migration file extension" + if reason is not None: + evidence[relative] = {"path": relative, "type": "file", "evidence": reason} + + return [evidence[path] for path in sorted(evidence, key=str.casefold)] + + +def _deployment_and_ci(scan: RepositoryScan) -> List[JSONDict]: + evidence: Dict[str, JSONDict] = {} + infrastructure_directories = { + "charts", + "deploy", + "deployment", + "helm", + "infra", + "infrastructure", + "k8s", + "kubernetes", + "terraform", + } + + for path in scan.directories: + if path.name.casefold() in infrastructure_directories: + relative = _relative(path, scan.root) + evidence[relative] = { + "path": relative, + "category": "deployment", + "evidence": "infrastructure or deployment directory name", + } + + for path in scan.files: + relative = _relative(path, scan.root) + parts = tuple(part.casefold() for part in path.relative_to(scan.root).parts) + name = path.name.casefold() + suffix = path.suffix.casefold() + item: Optional[JSONDict] = None + + if len(parts) >= 3 and parts[0:2] == (".github", "workflows"): + item = {"path": relative, "category": "ci", "evidence": "GitHub Actions workflow"} + elif ".circleci" in parts: + item = {"path": relative, "category": "ci", "evidence": "CircleCI configuration"} + elif name in { + ".gitlab-ci.yml", + ".gitlab-ci.yaml", + "azure-pipelines.yml", + "azure-pipelines.yaml", + "bitbucket-pipelines.yml", + "buildkite.yml", + "jenkinsfile", + }: + item = {"path": relative, "category": "ci", "evidence": "CI configuration filename"} + elif name == "dockerfile" or name.startswith("dockerfile."): + item = {"path": relative, "category": "deployment", "evidence": "container build file"} + elif name.startswith("docker-compose.") or name.startswith("compose."): + item = {"path": relative, "category": "deployment", "evidence": "container composition file"} + elif suffix == ".tf": + item = {"path": relative, "category": "deployment", "evidence": "Terraform configuration"} + elif name in { + "app.yaml", + "cloudbuild.yaml", + "cloudbuild.yml", + "fly.toml", + "netlify.toml", + "nixpacks.toml", + "procfile", + "railway.json", + "render.yaml", + "render.yml", + "serverless.yaml", + "serverless.yml", + "vercel.json", + "wrangler.toml", + }: + item = {"path": relative, "category": "deployment", "evidence": "deployment platform configuration"} + elif any(part in infrastructure_directories for part in parts[:-1]) and suffix in { + ".json", + ".toml", + ".yaml", + ".yml", + }: + item = {"path": relative, "category": "deployment", "evidence": "configuration under infrastructure directory"} + + if item is not None: + evidence[relative] = item + + return [evidence[path] for path in sorted(evidence, key=str.casefold)] + + +def _is_test_filename(name: str) -> bool: + lower = name.casefold() + stem = Path(lower).stem + return ( + lower.startswith("test_") + or stem.endswith("_test") + or ".test." in lower + or ".spec." in lower + or lower.endswith("_spec.rb") + or (lower.startswith("test") and lower.endswith(".java")) + ) + + +def _test_evidence(scan: RepositoryScan) -> List[JSONDict]: + evidence: Dict[str, JSONDict] = {} + + for path in scan.directories: + if path.name.casefold() in TEST_DIRECTORY_NAMES: + relative = _relative(path, scan.root) + evidence[relative] = { + "path": relative, + "type": "directory", + "evidence": "test directory name", + } + + for path in scan.files: + relative = _relative(path, scan.root) + parts = tuple(part.casefold() for part in path.relative_to(scan.root).parts[:-1]) + if path.name.casefold() in TEST_CONFIG_NAMES: + reason = "test framework configuration" + elif any(part in TEST_DIRECTORY_NAMES for part in parts): + reason = "file under test directory" + elif _is_test_filename(path.name): + reason = "test filename pattern" + else: + continue + evidence[relative] = {"path": relative, "type": "file", "evidence": reason} + + return [evidence[path] for path in sorted(evidence, key=str.casefold)] + + +def _documentation_evidence(scan: RepositoryScan) -> List[JSONDict]: + evidence: Dict[str, JSONDict] = {} + + for path in scan.directories: + if path.name.casefold() in DOCUMENTATION_DIRECTORY_NAMES: + relative = _relative(path, scan.root) + evidence[relative] = { + "path": relative, + "type": "directory", + "evidence": "documentation or architecture directory name", + } + + for path in scan.files: + relative = _relative(path, scan.root) + lower_name = path.name.casefold() + parent_parts = tuple(part.casefold() for part in path.relative_to(scan.root).parts[:-1]) + reason: Optional[str] = None + if any(part in DOCUMENTATION_DIRECTORY_NAMES for part in parent_parts): + reason = "file under documentation or architecture directory" + elif lower_name.startswith(DOCUMENTATION_FILE_PREFIXES): + reason = "well-known project documentation filename" + elif path.suffix.casefold() in {".adoc", ".md", ".rst"}: + reason = "documentation markup file" + elif lower_name in {"docusaurus.config.js", "mkdocs.yml", "mkdocs.yaml"}: + reason = "documentation site configuration" + if reason is not None: + evidence[relative] = {"path": relative, "type": "file", "evidence": reason} + + return [evidence[path] for path in sorted(evidence, key=str.casefold)] + + +def build_inventory(root: Union[str, Path]) -> JSONDict: + """Return a JSON-serialisable repository inventory.""" + + scan = scan_repository(root) + manifests, lockfiles = _dependency_evidence(scan) + return { + "schema_version": 1, + "root": str(scan.root), + "summary": { + "files_scanned": len(scan.files), + "directories_scanned": len(scan.directories), + "ignored_directories": len(scan.ignored_directories), + }, + "top_level_structure": _top_level_structure(scan), + "language_distribution": _language_distribution(scan), + "manifests": manifests, + "lockfiles": lockfiles, + "entry_candidates": _entry_candidates(scan), + "data_and_migrations": _data_and_migrations(scan), + "deployment_and_ci": _deployment_and_ci(scan), + "tests": _test_evidence(scan), + "documentation": _documentation_evidence(scan), + "ignored_directories": list(scan.ignored_directories), + "ignored_directory_names": sorted(IGNORED_DIRECTORY_NAMES, key=str.casefold), + } + + +def render_json(inventory: Mapping[str, Any]) -> str: + """Render an inventory as stable, human-readable JSON.""" + + return json.dumps(inventory, ensure_ascii=False, indent=2) + + +def _markdown_escape(value: Any) -> str: + return str(value).replace("|", "\\|").replace("\n", " ") + + +def _markdown_table(headers: Sequence[str], rows: Iterable[Sequence[Any]]) -> str: + rendered_rows = list(rows) + if not rendered_rows: + return "_None detected._" + lines = [ + "| " + " | ".join(_markdown_escape(header) for header in headers) + " |", + "| " + " | ".join("---" for _header in headers) + " |", + ] + for row in rendered_rows: + lines.append("| " + " | ".join(_markdown_escape(value) for value in row) + " |") + return "\n".join(lines) + + +def render_markdown(inventory: Mapping[str, Any]) -> str: + """Render an inventory as a compact evidence report.""" + + summary = inventory["summary"] + sections = [ + "# Repository inventory", + "", + "- Root: `{0}`".format(inventory["root"]), + "- Files scanned: {0}".format(summary["files_scanned"]), + "- Directories scanned: {0}".format(summary["directories_scanned"]), + "- Ignored directories encountered: {0}".format(summary["ignored_directories"]), + "", + "## Top-level structure", + "", + _markdown_table( + ("Path", "Type", "Files", "Bytes"), + ( + (item["path"], item["type"], item["files"], item["bytes"]) + for item in inventory["top_level_structure"] + ), + ), + "", + "## Language distribution", + "", + _markdown_table( + ("Language", "Files", "Bytes", "Share"), + ( + ( + item["language"], + item["files"], + item["bytes"], + "{0:.1f}%".format(item["share_percent"]), + ) + for item in inventory["language_distribution"] + ), + ), + "", + "## Manifests and lockfiles", + "", + _markdown_table( + ("Path", "Kind", "Ecosystem"), + [ + (item["path"], "manifest", item["ecosystem"]) + for item in inventory["manifests"] + ] + + [ + (item["path"], "lockfile", item["ecosystem"]) + for item in inventory["lockfiles"] + ], + ), + "", + "## Entry candidates", + "", + _markdown_table( + ("Path", "Evidence"), + ((item["path"], item["evidence"]) for item in inventory["entry_candidates"]), + ), + "", + "## Data and migrations", + "", + _markdown_table( + ("Path", "Type", "Evidence"), + ( + (item["path"], item["type"], item["evidence"]) + for item in inventory["data_and_migrations"] + ), + ), + "", + "## Deployment and CI", + "", + _markdown_table( + ("Path", "Category", "Evidence"), + ( + (item["path"], item["category"], item["evidence"]) + for item in inventory["deployment_and_ci"] + ), + ), + "", + "## Tests", + "", + _markdown_table( + ("Path", "Type", "Evidence"), + ((item["path"], item["type"], item["evidence"]) for item in inventory["tests"]), + ), + "", + "## Documentation", + "", + _markdown_table( + ("Path", "Type", "Evidence"), + ( + (item["path"], item["type"], item["evidence"]) + for item in inventory["documentation"] + ), + ), + "", + "## Ignored directories", + "", + "\n".join("- `{0}`".format(path) for path in inventory["ignored_directories"]) + if inventory["ignored_directories"] + else "_None encountered._", + ] + return "\n".join(sections) + "\n" + + +def _directory_argument(value: str) -> Path: + path = Path(value).expanduser() + if not path.exists(): + raise argparse.ArgumentTypeError("root does not exist: {0}".format(path)) + if not path.is_dir(): + raise argparse.ArgumentTypeError("root is not a directory: {0}".format(path)) + return path + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Read-only repository inventory for architecture reconnaissance." + ) + parser.add_argument( + "root", + nargs="?", + default=Path("."), + type=_directory_argument, + help="repository root to inspect (default: current directory)", + ) + parser.add_argument( + "--format", + choices=("markdown", "json"), + default="markdown", + help="output format (default: markdown)", + ) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _parse_args(argv) + inventory = build_inventory(args.root) + if args.format == "json": + print(render_json(inventory)) + else: + print(render_markdown(inventory), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_repo_inventory.py b/tests/test_repo_inventory.py new file mode 100644 index 0000000..6ffe7b1 --- /dev/null +++ b/tests/test_repo_inventory.py @@ -0,0 +1,158 @@ +import importlib.util +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] + / "skills" + / "architecture-copilot" + / "scripts" + / "repo_inventory.py" +) +SPEC = importlib.util.spec_from_file_location("repo_inventory", SCRIPT_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("could not load repo_inventory.py") +repo_inventory = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = repo_inventory +SPEC.loader.exec_module(repo_inventory) + + +def write_file(root, relative_path, content=""): + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def evidence_paths(inventory, key): + return {item["path"] for item in inventory[key]} + + +class RepositoryInventoryTests(unittest.TestCase): + def test_detects_repository_architecture_evidence(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + write_file(root, "src/main.py", "print('hello')\n") + write_file(root, "src/app.ts", "export const app = true;\n") + write_file(root, "apps/web/app/page.tsx", "export default function Page() {}\n") + write_file( + root, + "package.json", + json.dumps({"name": "fixture", "main": "src/app.ts"}), + ) + write_file(root, "package-lock.json", "{}\n") + write_file(root, "pyproject.toml", "[project]\nname = 'fixture'\n") + write_file(root, "migrations/001_init.sql", "create table example(id int);\n") + write_file(root, ".github/workflows/ci.yml", "name: CI\n") + write_file(root, "Dockerfile", "FROM scratch\n") + write_file(root, "tests/test_app.py", "def test_app():\n assert True\n") + write_file(root, "README.md", "# Fixture\n") + write_file(root, "docs/architecture.md", "# Architecture\n") + + inventory = repo_inventory.build_inventory(root) + + top_level = {item["path"] for item in inventory["top_level_structure"]} + self.assertIn("src", top_level) + self.assertIn("migrations", top_level) + + languages = { + item["language"]: item["files"] + for item in inventory["language_distribution"] + } + self.assertEqual(languages["Python"], 2) + self.assertEqual(languages["TypeScript"], 2) + self.assertEqual(languages["SQL"], 1) + + self.assertEqual( + evidence_paths(inventory, "manifests"), + {"package.json", "pyproject.toml"}, + ) + self.assertEqual( + evidence_paths(inventory, "lockfiles"), {"package-lock.json"} + ) + self.assertIn("src/main.py", evidence_paths(inventory, "entry_candidates")) + self.assertIn("src/app.ts", evidence_paths(inventory, "entry_candidates")) + self.assertIn( + "apps/web/app/page.tsx", evidence_paths(inventory, "entry_candidates") + ) + self.assertIn("migrations", evidence_paths(inventory, "data_and_migrations")) + self.assertIn( + "migrations/001_init.sql", + evidence_paths(inventory, "data_and_migrations"), + ) + self.assertIn( + ".github/workflows/ci.yml", + evidence_paths(inventory, "deployment_and_ci"), + ) + self.assertIn("Dockerfile", evidence_paths(inventory, "deployment_and_ci")) + self.assertIn("tests/test_app.py", evidence_paths(inventory, "tests")) + self.assertIn("README.md", evidence_paths(inventory, "documentation")) + self.assertIn( + "docs/architecture.md", evidence_paths(inventory, "documentation") + ) + + def test_ignored_directories_are_pruned_from_all_evidence(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + write_file(root, "src/main.py", "print('kept')\n") + ignored_files = ( + ".next/server/app.js", + ".git/config", + ".venv/lib/ghost.py", + "build/generated.py", + "dist/bundle.js", + "node_modules/pkg/index.js", + "vendor/library.rb", + ) + for relative_path in ignored_files: + write_file(root, relative_path, "ignored\n") + + inventory = repo_inventory.build_inventory(root) + serialised = repo_inventory.render_json(inventory) + + for relative_path in ignored_files: + self.assertNotIn(relative_path, serialised) + self.assertEqual(inventory["summary"]["files_scanned"], 1) + self.assertEqual( + set(inventory["ignored_directories"]), + {".next", ".git", ".venv", "build", "dist", "node_modules", "vendor"}, + ) + self.assertEqual( + inventory["language_distribution"][0]["language"], "Python" + ) + self.assertEqual(inventory["language_distribution"][0]["files"], 1) + + def test_cli_supports_json_and_markdown_output(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + write_file(root, "README.md", "# Fixture\n") + + json_stdout = io.StringIO() + with redirect_stdout(json_stdout): + exit_code = repo_inventory.main([str(root), "--format", "json"]) + self.assertEqual(exit_code, 0) + json_payload = json.loads(json_stdout.getvalue()) + self.assertEqual(json_payload["root"], str(root.resolve())) + self.assertIn("documentation", json_payload) + + markdown_stdout = io.StringIO() + with redirect_stdout(markdown_stdout): + exit_code = repo_inventory.main( + [str(root), "--format", "markdown"] + ) + self.assertEqual(exit_code, 0) + markdown = markdown_stdout.getvalue() + self.assertIn("# Repository inventory", markdown) + self.assertIn("## Language distribution", markdown) + self.assertIn("## Deployment and CI", markdown) + self.assertIn("README.md", markdown) + + +if __name__ == "__main__": + unittest.main()