修复全量审查问题;设置接口PATCH化;回传指纹加固
CI / test (push) Successful in 32s

This commit is contained in:
2026-07-22 16:51:23 +08:00
parent 0614ef22af
commit f51fb6c722
66 changed files with 3997 additions and 687 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.6
0.6.7
+1
View File
@@ -47,6 +47,7 @@ go func() {
- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。
- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。
- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。
- 经租户出口的**所有** HTTP 外呼(不只 SDK 调用,含 SAML 元数据抓取这类裸 http.Client)都必须复用该租户代理;代理配置非法时**失败关闭**报错,不得静默直连暴露服务器 IP(2026-07-22 审查 #7,federation.go `metadataHTTPClient`)。
- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。
## 出站连接复用与批量删除(2026-07 删桶超时复盘)
+17 -60
View File
@@ -1,75 +1,32 @@
# Database Guidelines
# 数据库规范
> Database patterns and conventions for this project.
> GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 `internal/model/`,连接与 AutoMigrate 在 `internal/database/`。
---
## 租户级数据删除
## Overview
<!--
Document your project's database conventions here.
Questions to answer:
- What ORM/query library do you use?
- How are migrations managed?
- What are the naming conventions for tables/columns?
- How do you handle transactions?
-->
(To be filled by the team)
---
## Query Patterns
<!-- How should queries be written? Batch operations? -->
### 租户级数据删除
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
### 大集合谓词用子查询,禁止展开 IN 列表
## 大集合谓词用子查询,禁止展开 IN 列表
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
- 例外:**同表自删**(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go `cleanupTable`,每批 10000,单批失败记日志退出)。
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
---
## Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
## Migrations
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
<!-- How to create and run migrations -->
**Cause**:GORM `Save``UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
(To be filled by the team)
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`
---
## Naming Conventions
<!-- Table names, column names, index names -->
(To be filled by the team)
---
## Common Mistakes
<!-- Database-related mistakes your team has made -->
### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
**Cause**:GORM `Save``UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
## Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
@@ -79,7 +36,7 @@ Questions to answer:
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
### Common Mistake: 需要"条件更新多列含 NULL"时的写法
## Common Mistake: 需要"条件更新多列含 NULL"时的写法
```go
// 正确:零写库条件 + 显式 NULL
@@ -88,13 +45,13 @@ db.Model(&model.AiChannel{}).
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
```
### `serializer:json` 字段走 map Updates 时手动 marshal
## `serializer:json` 字段走 map Updates 时手动 marshal
- 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化;
-`Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测;
- 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。
### Common Mistake: 进程内缓存键漏掉查询维度
## Common Mistake: 进程内缓存键漏掉查询维度
**Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。
+4
View File
@@ -41,3 +41,7 @@ func sanitizeURLError(err error) error {
- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As``common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
## 外部响应体必须限长,超限报错而非静默截断
读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。
+91
View File
@@ -0,0 +1,91 @@
# IdP 图标上传与清理契约
## 1. Scope / Trigger
- 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。
- 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**,
孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。
## 2. Signatures
```text
POST /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>
DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>&fileName=<required>
```
```typescript
uploadIdpIcon(id: number, file: File, domainId?: string): Promise<IdpIconUpload>
deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void>
interface IdpIconUpload {
url: string // 写入 IdP iconUrl 的公网地址
fileName: string // 仅用于清理的存储标识
}
```
上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调):
```text
POST /storage/v1/Images multipart: file + fileName
DELETE /storage/v1/Images query: fileName
```
## 3. Contracts
- POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制;
路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。
- 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成
`idp-icon-<32 lowercase hex><lowercase ext>`:既避免并发/迟到响应下同名互相
覆盖,又让存储名成为不可猜测的清理凭据。
- 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg),
不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管,
传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起
(2026-07 曾过度实现完整解码套件后精简)。
- DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.<allowed ext>`,
不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。
- 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon,
用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。
- IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP;
回滚成功按普通失败,回滚失败/无法确认返回 `201`
`setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`;
内部原因只按 requestId 写服务端日志,不进响应。
- 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置,
`primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。
## 4. Validation & Error Matrix
| 条件 | HTTP |
| --- | --- |
| 缺少 multipart `file`、文件为空或文件名非法 | 400 |
| 文件本体超过 1 MiB | 413 |
| 扩展名不支持或与文件头魔数不符 | 415 |
| DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 |
| 上游 DELETE 返回 404 | 204 |
| IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` |
| 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 |
## 5. Tests / Verification Required
- 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。
- 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游;
DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。
- 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。
- 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支;
后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。
- 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。
## 6. Wrong vs Correct
```typescript
// Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。
onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId))
// Correct:上传时捕获原始元组,清理只按元组走,失败静默。
pendingIcon = { ...uploaded, cfgId, domainId }
void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {})
```
反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 +
卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验——
复杂度与威胁模型不匹配,已于 2026-07 精简移除。
+3 -3
View File
@@ -19,9 +19,9 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 部分已填 |
| [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 |
| [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 |
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
---
@@ -32,7 +32,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
- [ ] 复用已有封装,不重复造轮子(`../guides/code-reuse-thinking-guide.md`)
- [ ] 复用已有封装,不重复造轮子(动手前先 grep 相似函数 / 常量 / 模式)
## Quality Check
@@ -1,51 +0,0 @@
# Logging Guidelines
> How logging is done in this project.
---
## Overview
<!--
Document your project's logging conventions here.
Questions to answer:
- What logging library do you use?
- What are the log levels and when to use each?
- What should be logged?
- What should NOT be logged (PII, secrets)?
-->
(To be filled by the team)
---
## Log Levels
<!-- When to use each level: debug, info, warn, error -->
(To be filled by the team)
---
## Structured Logging
<!-- Log format, required fields -->
(To be filled by the team)
---
## What to Log
<!-- Important events to log -->
(To be filled by the team)
---
## What NOT to Log
<!-- Sensitive data, PII, secrets -->
(To be filled by the team)
+7
View File
@@ -17,3 +17,10 @@
- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。
- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
## 日志回传链路(logrelay)资源命名与描述纪律
- **命名派生**:Topic 前缀、IAM Policy 名、SCH Connector DisplayName 一律由 `relayResourceNames(tenancyOCID)`(见 `internal/oci/logrelay_names.go`)基于 `SHA-256(tenancyOCID)[:4]` 派生 `<8hex>-audit` / `<8hex>-audit-p`。**不得在 `logrelay.go` 里再引入品牌明文**(`oci-portal` / `ociportal` / `logs` 等),否则跨租户恒定字面量会成为 Oracle 内部风控识别「共用同一套 oci-portal」的强指纹;背景与证据分档见调研档案 `docs/oci-sdk-caller-fingerprint.md`
- **描述文本**:Topic 与 Policy 的 `Description` 用集中常量 `relayTopicDescNew` / `relayPolicyDescNew`(中性英文),不允许写 `oci-portal 日志回传:...` 等品牌/中文明文;SCH Connector 干脆不设 `Description`,避免恒定文案。
- **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。
- **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。
@@ -27,3 +27,7 @@
- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析
后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal`
RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。
## 多字段设置接口用字段级 PATCH,不用全量 PUT
「改一存一」交互的设置面板(安全设置、OAuth provider 等)服务端用字段级 PATCH:DTO 全指针字段,nil=沿用现值,只落库出现的字段(合并到现值后整体校验);全量 PUT 下并发编辑各自基于旧快照,后到请求会回滚他人字段、复活已清空配置(2026-07-22 审查 #18,security.go `SecurityPatch` / oauthconfig.go `UpdateOAuthInput`)。
+20 -1
View File
@@ -6,6 +6,26 @@
- 断言失败时输出 `got/want` 对比,便于定位。
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`
## 上传接口边界测试
- “文件最大 N 字节”与 HTTP 请求体上限不是同一个契约:multipart boundary、
part header 和文件名会额外占空间。路由级 body limit 必须为封装开销留余量,
service 再对文件字节数执行精确上限。
- 新增或修改上传接口时,完整路由测试至少覆盖:恰好 N 字节成功、N+1 返回
413、空文件返回 400、伪造扩展名返回 415。
- 内容校验深度与威胁模型匹配:管理员向自己租户上传、由上游域名托管的低价值
资源,扩展名白名单 + 魔数嗅探即可,不引入完整解码/结构校验
(2026-07 IdP 图标曾过度实现后精简,见 idp-icon-upload.md)。
- 临时远端资源使用服务端随机名,测试相同客户端文件名不会复用上游对象;清理接口
只接受该功能生成的不可猜测标识,不能因共享 `images/` 前缀删除其他业务资产。
## 多步远端写入测试
- `创建 A → 配置 B` 这类流程至少覆盖:创建失败、B 失败且 A 回滚成功、B 失败且
A 回滚失败/状态未知。只有确认回滚成功才能返回普通失败。
- 部分成功响应必须有稳定机器码和关联 ID,前端据此保留已被 A 引用的临时资源;
底层上游错误只进服务端日志,测试断言不会泄露到 HTTP body。
## 常见坑:`:memory:` SQLite 每个连接是独立库
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`
@@ -20,4 +40,3 @@ sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连
```
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
@@ -1,223 +0,0 @@
# Code Reuse Thinking Guide
> **Purpose**: Stop and think before creating new code - does it already exist?
---
## The Problem
**Duplicated code is the #1 source of inconsistency bugs.**
When you copy-paste or rewrite existing logic:
- Bug fixes don't propagate
- Behavior diverges over time
- Codebase becomes harder to understand
---
## Before Writing New Code
### Step 1: Search First
```bash
# Search for similar function names
grep -r "functionName" .
# Search for similar logic
grep -r "keyword" .
```
### Step 2: Ask These Questions
| Question | If Yes... |
|----------|-----------|
| Does a similar function exist? | Use or extend it |
| Is this pattern used elsewhere? | Follow the existing pattern |
| Could this be a shared utility? | Create it in the right place |
| Am I copying code from another file? | **STOP** - extract to shared |
---
## Common Duplication Patterns
### Pattern 1: Copy-Paste Functions
**Bad**: Copying a validation function to another file
**Good**: Extract to shared utilities, import where needed
### Pattern 2: Similar Components
**Bad**: Creating a new component that's 80% similar to existing
**Good**: Extend existing component with props/variants
### Pattern 3: Repeated Constants
**Bad**: Defining the same constant in multiple files
**Good**: Single source of truth, import everywhere
### Pattern 4: Repeated Payload Field Extraction
**Bad**: Multiple consumers cast the same JSON/event fields locally:
```typescript
const description = (ev as { description?: string }).description;
const context = (ev as { context?: ContextEntry[] }).context;
```
This is duplicated contract logic even when the code is only two lines. Each
consumer now has its own definition of what a valid payload means.
**Good**: Put the decoder, type guard, or projection next to the data owner:
```typescript
if (isThreadEvent(ev)) {
renderThreadEvent(ev);
}
```
**Rule**: If the same untyped payload field is read in 2+ places, create a
shared type guard / normalizer / projection before adding a third reader.
---
## When to Abstract
**Abstract when**:
- Same code appears 3+ times
- Logic is complex enough to have bugs
- Multiple people might need this
**Don't abstract when**:
- Only used once
- Trivial one-liner
- Abstraction would be more complex than duplication
---
## After Batch Modifications
When you've made similar changes to multiple files:
1. **Review**: Did you catch all instances?
2. **Search**: Run grep to find any missed
3. **Consider**: Should this be abstracted?
### Reducers Should Use Exhaustive Structure
When state is derived from action-like values (`action`, `kind`, `status`,
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
```typescript
// BAD - action-specific state transitions are hard to audit
if (action === "opened") { ... }
else if (action === "comment") { ... }
else if (action === "status") { ... }
// GOOD - one reducer owns the transition table
switch (event.action) {
case "opened":
...
return;
case "comment":
...
return;
}
```
This matters when the event log is the source of truth. A reducer is the
documented replay model; display code and commands should not duplicate pieces
of that replay model.
---
## Checklist Before Commit
- [ ] Searched for existing similar code
- [ ] No copy-pasted logic that should be shared
- [ ] No repeated untyped payload field extraction outside a shared decoder
- [ ] Constants defined in one place
- [ ] Similar patterns follow same structure
- [ ] Reducer/action transitions live in one reducer or command dispatcher
---
## Gotcha: Python if/elif/else Exhaustive Check
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
**Example** (`cli_adapter.py`):
```python
# BAD: "gemini" falls through to else, returns "claude"
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
else:
return "claude" # gemini silently gets "claude"!
# GOOD: explicit branch for every platform
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
elif self.platform == "gemini":
return "gemini"
else:
return "claude"
```
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
---
## Gotcha: Asymmetric Mechanisms Producing Same Output
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
**Prevention**:
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
- When migrating directory structures, search for ALL code paths that reference the old structure
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
---
## Template File Registration (Trellis-specific)
When adding new files to `src/templates/trellis/scripts/`:
**Single registration point**: `src/templates/trellis/index.ts`
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
2. Add to `getAllScripts()` Map
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
### Quick Checklist for New Scripts
```bash
# After adding a new .py file, verify it's in getAllScripts():
grep -l "newFileName" src/templates/trellis/index.ts # Should match
```
### Template Sync Convention
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
```bash
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
```
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.