diff --git a/claude-specification/project/CLAUDE.md b/claude-specification/project/CLAUDE.md index 79ebfc2..eeeac35 100644 --- a/claude-specification/project/CLAUDE.md +++ b/claude-specification/project/CLAUDE.md @@ -128,6 +128,46 @@ cora/ | 发布 | `Release` | `Releases` | | Webhook | `Webhooks` | - | +### Spec Normalizer(服务注册时声明) + +Spec 质量问题通过 `NormalizeRule` 在服务注册时声明式修正,**禁止**在 `command.go` 中为特定服务编写 if/else 补丁。 + +```go +// internal/registry/builtin.go — 服务注册示例 +addBuiltin(r, cfg, builtinDef{ + name: "myservice", + specData: assets.MyServiceSpec, + normalize: spec.NormalizeRule{ + // 资源别名:tag kebab 形式 → 规范资源名 + ResourceAlias: map[string]string{ + "repositories": "repos", + }, + // Tag 重分配:path 前缀匹配 → 修正 tag + TagReassign: []spec.TagReassignRule{ + {PathPrefix: "/repos/{o}/{r}/pulls", Tag: "Pulls"}, + }, + // GitCode 风格 path-encoded operationId verb 提取 + OpIDVerbExtract: true, + // 为无 tag 的操作添加 tag + AddMissingTags: []spec.MissingTagRule{ + {PathPrefix: "/createPad", Tag: "pad"}, + }, + }, +}) +``` + +**规则类型**: + +| 规则 | 用途 | 何时使用 | +|------|------|---------| +| `ResourceAlias` | tag → 规范资源名映射 | 同一概念不同命名(repositories→repos) | +| `TagReassign` | path 前缀 → 修正 tag | spec 作者错标 tag 时 | +| `OpIDVerbExtract` | 提取 path-encoded opId 的 verb | GitCode 风格 `get_api_v5_*` opId | +| `AddMissingTags` | 为无 tag 操作分配 tag | spec 中部分操作缺少 tag 时 | +| `TagRemap` | 直接重命名 tag | 多词 tag("Pull Requests"→"Pulls") | + +**禁止**:在 `command.go` 的 `resourceName()`、`verbName()`、`Build()` 等函数中硬编码特定服务的修正逻辑。 + --- ## 日志规范(项目特有) @@ -240,6 +280,7 @@ go run ./cmd/cora -- [flags] # 直接运行 | 日志系统设计 | `spec/logging-design.md` | | API Token 调研 | `spec/api-token-investigation.md` | | CLI 设计参考 | `spec/reference-cli-design-patterns.md` | +| Spec Normalizer 设计 | `spec/spec-normalizer-design.md` | --- diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index d215bcf..1be8ab9 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -8,6 +8,44 @@ import ( "github.com/opensourceways/cora/internal/spec" ) +var ( + // gitcodeNormalize fixes known quality issues in the GitCode OpenAPI spec. + gitcodeNormalize = spec.NormalizeRule{ + ResourceAlias: map[string]string{ + "repositories": "repos", + "organizations": "orgs", + "pull-requests": "pulls", + }, + TagReassign: []spec.TagReassignRule{ + {PathPrefix: "/api/v5/repos/{owner}/{repo}/pulls", Tag: "Pulls"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/issues", Tag: "Issues"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/labels", Tag: "Labels"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/branches", Tag: "Branch"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/tags", Tag: "Tag"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/releases", Tag: "Release"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/milestones", Tag: "Milestone"}, + }, + OpIDVerbExtract: true, + } + + // etherpadNormalize assigns tags to untagged operations. + etherpadNormalize = spec.NormalizeRule{ + AddMissingTags: []spec.MissingTagRule{ + {PathPrefix: "/appendText", Tag: "pad"}, + {PathPrefix: "/copyPad", Tag: "pad"}, + {PathPrefix: "/getAttributePool", Tag: "pad"}, + {PathPrefix: "/getPadID", Tag: "pad"}, + {PathPrefix: "/getRevisionChangeset", Tag: "pad"}, + {PathPrefix: "/getSavedRevisionsCount", Tag: "pad"}, + {PathPrefix: "/getStats", Tag: "pad"}, + {PathPrefix: "/listSavedRevisions", Tag: "pad"}, + {PathPrefix: "/movePad", Tag: "pad"}, + {PathPrefix: "/restoreRevision", Tag: "pad"}, + {PathPrefix: "/saveRevision", Tag: "pad"}, + }, + } +) + const ( etherpadName = "etherpad" gitcodeName = "gitcode" @@ -31,17 +69,19 @@ func registerBuiltins(r *Registry, cfg *config.Config) { } addBuiltin(r, cfg, builtinDef{ - name: etherpadName, - specData: assets.EtherpadSpec, - cacheDir: cacheDir, - ttl: ttl, + name: etherpadName, + specData: assets.EtherpadSpec, + cacheDir: cacheDir, + ttl: ttl, + normalize: etherpadNormalize, }) addBuiltin(r, cfg, builtinDef{ - name: gitcodeName, - specData: assets.GitcodeSpec, - cacheDir: cacheDir, - ttl: ttl, + name: gitcodeName, + specData: assets.GitcodeSpec, + cacheDir: cacheDir, + ttl: ttl, + normalize: gitcodeNormalize, }) addBuiltin(r, cfg, builtinDef{ @@ -67,10 +107,11 @@ func registerBuiltins(r *Registry, cfg *config.Config) { } type builtinDef struct { - name string - specData []byte - cacheDir string - ttl time.Duration + name string + specData []byte + cacheDir string + ttl time.Duration + normalize spec.NormalizeRule } func addBuiltin(r *Registry, cfg *config.Config, b builtinDef) { @@ -82,10 +123,11 @@ func addBuiltin(r *Registry, cfg *config.Config, b builtinDef) { } r.entries[b.name] = &Entry{ - Name: b.name, - BaseURL: baseURL, - SpecURL: "", // embedded spec — no remote URL needed - loader: spec.NewEmbeddedLoader(b.name, b.specData, b.cacheDir, b.ttl), + Name: b.name, + BaseURL: baseURL, + SpecURL: "", // embedded spec — no remote URL needed + loader: spec.NewEmbeddedLoader(b.name, b.specData, b.cacheDir, b.ttl), + normalizer: spec.NewNormalizer(b.normalize), } // Ensure cfg.Services contains an entry for this built-in so the executor diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 3a67eb2..ab21ec7 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -21,8 +21,9 @@ type Entry struct { // BaseURL is the API root address from the config. BaseURL string // SpecURL is the OpenAPI spec source from the config. - SpecURL string - loader *spec.Loader + SpecURL string + loader *spec.Loader + normalizer *spec.Normalizer // nil = no normalization } // Registry maps service names → Entry. @@ -113,9 +114,17 @@ func (r *Registry) Entries() []*Entry { return entries } -// LoadSpec fetches (or returns cached) OpenAPI spec for the entry. +// LoadSpec fetches (or returns cached) OpenAPI spec for the entry, +// applying any registered normalization rules. func (e *Entry) LoadSpec(ctx context.Context) (*openapi3.T, error) { - return e.loader.Load(ctx) + raw, err := e.loader.Load(ctx) + if err != nil { + return nil, err + } + if e.normalizer != nil { + return e.normalizer.Normalize(raw), nil + } + return raw, nil } // LoadCached reads the spec from the local cache only — no network call. diff --git a/internal/spec/normalizer.go b/internal/spec/normalizer.go new file mode 100644 index 0000000..5398898 --- /dev/null +++ b/internal/spec/normalizer.go @@ -0,0 +1,178 @@ +package spec + +import ( + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// NormalizeRule declares transformations to apply to an OpenAPI spec +// before command generation. All fields are optional; zero value = no-op. +type NormalizeRule struct { + // TagRemap renames a tag. Keys are old names, values are new names. + // e.g. {"Pull Requests" → "Pulls"}. + TagRemap map[string]string + + // ResourceAlias maps a kebab-cased tag to a canonical resource name. + // Applied after TagRemap. + // e.g. {"pull-requests" → "pulls", "repositories" → "repos"}. + ResourceAlias map[string]string + + // TagReassign moves operations matching a path prefix to a target tag. + // Used when spec authors tag an operation with the wrong parent resource. + // Only the first tag (used as resource name) is reassigned. + // e.g. {PathPrefix: "/repos/{o}/{r}/pulls", Tag: "Pulls"}. + TagReassign []TagReassignRule + + // OpIDVerbExtract enables HTTP-method-prefix extraction for path-encoded + // operationIds (e.g. GitCode's get_api_v5_… → "get"/"list"). + OpIDVerbExtract bool + + // AddMissingTags assigns a tag to operations that have no tags, + // using the path to infer the appropriate tag. + // e.g. {PathPattern: "/createPad*", Tag: "pad", Method: "GET"}. + AddMissingTags []MissingTagRule +} + +// TagReassignRule maps a path prefix to a target tag. +type TagReassignRule struct { + PathPrefix string // e.g. "/api/v5/repos/{owner}/{repo}/pulls" + Tag string // target tag, e.g. "Pulls" +} + +// MissingTagRule assigns a tag when an operation has none. +type MissingTagRule struct { + PathPrefix string // empty = match any path + Method string // empty = match any method + Tag string // tag to assign +} + +// Normalizer applies NormalizeRule to an OpenAPI spec. +type Normalizer struct { + rules NormalizeRule +} + +// NewNormalizer creates a Normalizer for the given rules. +func NewNormalizer(rules NormalizeRule) *Normalizer { + return &Normalizer{rules: rules} +} + +// Normalize applies all registered transformations to the spec, returning +// a mutated copy. The builder should always use the normalized spec. +func (n *Normalizer) Normalize(spec *openapi3.T) *openapi3.T { + if spec == nil || spec.Paths == nil { + return spec + } + + n.applyTagRemap(spec) + n.applyResourceAlias(spec) + n.applyTagReassign(spec) + n.applyAddMissingTags(spec) + + return spec +} + +// ── tag remap ─────────────────────────────────────────────────────────── + +func (n *Normalizer) applyTagRemap(spec *openapi3.T) { + if len(n.rules.TagRemap) == 0 { + return + } + for _, pathItem := range spec.Paths.Map() { + if pathItem == nil { + continue + } + for _, op := range pathItem.Operations() { + if op == nil { + continue + } + for i, tag := range op.Tags { + if newTag, ok := n.rules.TagRemap[tag]; ok { + op.Tags[i] = newTag + } + } + } + } +} + +// ── resource alias ────────────────────────────────────────────────────── + +func (n *Normalizer) applyResourceAlias(spec *openapi3.T) { + if len(n.rules.ResourceAlias) == 0 { + return + } + for _, pathItem := range spec.Paths.Map() { + if pathItem == nil { + continue + } + for _, op := range pathItem.Operations() { + if op == nil || len(op.Tags) == 0 { + continue + } + kebab := strings.ToLower(strings.ReplaceAll(op.Tags[0], " ", "-")) + if canonical, ok := n.rules.ResourceAlias[kebab]; ok { + op.Tags[0] = canonical + } + } + } +} + +// ── tag reassign ──────────────────────────────────────────────────────── + +func (n *Normalizer) applyTagReassign(spec *openapi3.T) { + if len(n.rules.TagReassign) == 0 { + return + } + // Sort by path length descending so longer (more specific) prefixes + // are matched before shorter ones. + sorted := make([]TagReassignRule, len(n.rules.TagReassign)) + copy(sorted, n.rules.TagReassign) + sort.Slice(sorted, func(i, j int) bool { + return len(sorted[i].PathPrefix) > len(sorted[j].PathPrefix) + }) + + for path, pathItem := range spec.Paths.Map() { + if pathItem == nil { + continue + } + for _, rule := range sorted { + if strings.HasPrefix(path, rule.PathPrefix) { + for _, op := range pathItem.Operations() { + if op != nil && len(op.Tags) > 0 { + op.Tags[0] = rule.Tag + } + } + break // first (longest) match wins + } + } + } +} + +// ── add missing tags ──────────────────────────────────────────────────── + +func (n *Normalizer) applyAddMissingTags(spec *openapi3.T) { + if len(n.rules.AddMissingTags) == 0 { + return + } + for path, pathItem := range spec.Paths.Map() { + if pathItem == nil { + continue + } + for method, op := range pathItem.Operations() { + if op == nil || len(op.Tags) > 0 { + continue + } + for _, rule := range n.rules.AddMissingTags { + if rule.Method != "" && !strings.EqualFold(rule.Method, method) { + continue + } + if rule.PathPrefix != "" && !strings.HasPrefix(path, rule.PathPrefix) { + continue + } + op.Tags = []string{rule.Tag} + break + } + } + } +} diff --git a/internal/spec/normalizer_test.go b/internal/spec/normalizer_test.go new file mode 100644 index 0000000..d153c7a --- /dev/null +++ b/internal/spec/normalizer_test.go @@ -0,0 +1,116 @@ +package spec + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func newSpec(paths map[string]*openapi3.PathItem) *openapi3.T { + p := openapi3.NewPaths() + for path, item := range paths { + p.Set(path, item) + } + return &openapi3.T{Info: &openapi3.Info{Title: "Test"}, Paths: p} +} + +func newOp(tags []string) *openapi3.Operation { + op := openapi3.NewOperation() + op.Tags = tags + return op +} + +func TestNormalizer_TagRemap(t *testing.T) { + spec := newSpec(map[string]*openapi3.PathItem{ + "/issues": {Get: newOp([]string{"Pull Requests"})}, + }) + n := NewNormalizer(NormalizeRule{ + TagRemap: map[string]string{"Pull Requests": "Pulls"}, + }) + n.Normalize(spec) + + op := spec.Paths.Value("/issues").Get + if op.Tags[0] != "Pulls" { + t.Errorf("TagRemap: got %q, want %q", op.Tags[0], "Pulls") + } +} + +func TestNormalizer_ResourceAlias(t *testing.T) { + spec := newSpec(map[string]*openapi3.PathItem{ + "/repos": {Get: newOp([]string{"Repositories"})}, + }) + n := NewNormalizer(NormalizeRule{ + ResourceAlias: map[string]string{"repositories": "repos"}, + }) + n.Normalize(spec) + + op := spec.Paths.Value("/repos").Get + if op.Tags[0] != "repos" { + t.Errorf("ResourceAlias: got %q, want %q", op.Tags[0], "repos") + } +} + +func TestNormalizer_TagReassign(t *testing.T) { + spec := newSpec(map[string]*openapi3.PathItem{ + "/api/v5/repos/{o}/{r}/issues": {Get: newOp([]string{"Users"})}, + "/api/v5/repos/{o}/{r}/pulls": {Get: newOp([]string{"Users"})}, + }) + n := NewNormalizer(NormalizeRule{ + TagReassign: []TagReassignRule{ + {PathPrefix: "/api/v5/repos/{o}/{r}/pulls", Tag: "Pulls"}, + {PathPrefix: "/api/v5/repos/{o}/{r}/issues", Tag: "Issues"}, + }, + }) + n.Normalize(spec) + + isTag := spec.Paths.Value("/api/v5/repos/{o}/{r}/issues").Get.Tags[0] + prTag := spec.Paths.Value("/api/v5/repos/{o}/{r}/pulls").Get.Tags[0] + + if isTag != "Issues" { + t.Errorf("issues tag: got %q, want %q", isTag, "Issues") + } + if prTag != "Pulls" { + t.Errorf("pulls tag: got %q, want %q", prTag, "Pulls") + } +} + +func TestNormalizer_AddMissingTags(t *testing.T) { + op := openapi3.NewOperation() + op.Tags = nil // no tags + op.OperationID = "createPadUsingGET" + + spec := newSpec(map[string]*openapi3.PathItem{ + "/createPad": {Get: op}, + }) + n := NewNormalizer(NormalizeRule{ + AddMissingTags: []MissingTagRule{ + {PathPrefix: "/createPad", Tag: "pad"}, + }, + }) + n.Normalize(spec) + + result := spec.Paths.Value("/createPad").Get + if len(result.Tags) == 0 || result.Tags[0] != "pad" { + t.Errorf("AddMissingTags: got %v, want [pad]", result.Tags) + } +} + +func TestNormalizer_NoRules_Noop(t *testing.T) { + spec := newSpec(map[string]*openapi3.PathItem{ + "/issues": {Get: newOp([]string{"Issues"})}, + }) + n := NewNormalizer(NormalizeRule{}) + n.Normalize(spec) + + op := spec.Paths.Value("/issues").Get + if op.Tags[0] != "Issues" { + t.Errorf("no-op normalizer changed tags: got %q", op.Tags[0]) + } +} + +func TestNormalizer_NilSpec(t *testing.T) { + n := NewNormalizer(NormalizeRule{TagRemap: map[string]string{"A": "B"}}) + // must not panic + n.Normalize(nil) + n.Normalize(&openapi3.T{}) +} diff --git a/spec/spec-normalizer-design.md b/spec/spec-normalizer-design.md new file mode 100644 index 0000000..d04eb16 --- /dev/null +++ b/spec/spec-normalizer-design.md @@ -0,0 +1,197 @@ +# Spec Normalizer 设计 + +> 目标:在 OpenAPI spec 加载后、命令生成前,插入一层声明式 spec 修正,将硬编码补丁从 `command.go` 中移出。 + +## 问题 + +`command.go` 中存在 6 处硬编码逻辑(~60 行),专门处理 spec 质量问题: + +- `isShortSynonym()` / `canonicalTag` / `normalizeTag()` — tag 同义词映射 +- `resourceFromPath()` — path 段→资源名映射 +- `isPathEncodedOpID()` / Priority 3.5 — GitCode operationId 格式 +- `priorNonParam <= 4` — 版本化 API 路径容差 + +**根因**:spec 质量不够 → 代码中打补丁。应该在上游(spec 层)修正。 + +## 设计 + +### 架构 + +``` +Spec 加载 Normalizer Builder + │ │ │ + ▼ ▼ ▼ +openapi3.T ──→ Normalizer.Normalize() ──→ openapi3.T ──→ Build() + (raw) │ (clean) │ + │ ▼ + └─ 每个服务注册的 rules Cobra 命令树 +``` + +### 核心接口 + +```go +// internal/spec/normalizer.go + +// NormalizeRule describes one transformation to apply to a spec. +type NormalizeRule struct { + // TagRemap renames a tag: {"Pull Requests" → "Pulls"}. + TagRemap map[string]string + + // ResourceAlias maps a kebab-cased tag to a canonical resource name: + // {"pull-requests" → "pulls", "repositories" → "repos"}. + ResourceAlias map[string]string + + // TagReassign moves operations matching a path prefix to a target tag: + // e.g. {path_prefix: "/repos/{o}/{r}/pulls", tag: "Pulls"} + TagReassign []TagReassignRule + + // OpIDVerbExtract enables HTTP-method-prefix extraction for path-encoded + // operationIds (GitCode style: get_api_v5_…). + OpIDVerbExtract bool + + // StripVersionPrefix removes leading version-like segments (api/v5) from + // path-derived names so they don't inflate priorNonParam counts. + // Applied internally by the normalizer to clean path representations. + StripVersionPrefix bool +} + +type TagReassignRule struct { + PathPrefix string // e.g. "/repos/{owner}/{repo}/pulls" + Tag string // target tag, e.g. "Pulls" +} + +// Normalizer applies a set of rules to an OpenAPI spec. +type Normalizer struct { + rules NormalizeRule +} + +func NewNormalizer(rules NormalizeRule) *Normalizer +func (n *Normalizer) Normalize(spec *openapi3.T) *openapi3.T +``` + +### 规则注册 + +每个服务在 `internal/registry/builtin.go` 中注册自己的 normalizer: + +```go +addBuiltin(r, cfg, builtinDef{ + name: gitcodeName, + specData: assets.GitcodeSpec, + cacheDir: cacheDir, + ttl: ttl, + normalize: NormalizeRule{ + ResourceAlias: map[string]string{ + "repositories": "repos", + "organizations": "orgs", + "pull-requests": "pulls", + }, + TagReassign: []TagReassignRule{ + {PathPrefix: "/api/v5/repos/{owner}/{repo}/pulls", Tag: "Pulls"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/issues", Tag: "Issues"}, + {PathPrefix: "/api/v5/repos/{owner}/{repo}/labels", Tag: "Labels"}, + }, + OpIDVerbExtract: true, + }, +}) + +addBuiltin(r, cfg, builtinDef{ + name: githubName, + specData: assets.GithubSpec, + cacheDir: cacheDir, + ttl: ttl, + normalize: NormalizeRule{}, // GitHub spec is clean, no rules needed +}) +``` + +### 调用点 + +在 `Build()` 之前,spec 经过 normalizer: + +```go +// registry/builtin.go 的 addBuiltin() +loader := spec.NewEmbeddedLoader(...) +normalizer := spec.NewNormalizer(b.normalize) + +// ... later, when loading: +rawSpec, _ := loader.Load(ctx) +cleanSpec := normalizer.Normalize(rawSpec) +cmd := builder.Build(svcName, cleanSpec, ...) +``` + +## Normalizer 实现细节 + +### TagRemap + +```go +// 遍历所有 operation,替换 tag 名称 +for _, pathItem := range spec.Paths.Map() { + for _, op := range pathItem.Operations() { + for i, tag := range op.Tags { + if newTag, ok := rules.TagRemap[tag]; ok { + op.Tags[i] = newTag + } + } + } +} +``` + +### ResourceAlias + +等效于 `canonicalTag` fallback。在 `normalizeTag()` 被调用前生效——直接修改 tag 值。 + +### TagReassign + +```go +for path, pathItem := range spec.Paths.Map() { + for _, rule := range rules.TagReassign { + if strings.HasPrefix(path, rule.PathPrefix) { + for _, op := range pathItem.Operations() { + op.Tags[0] = rule.Tag // 覆盖第一个 tag + } + } + } +} +``` + +### OpIDVerbExtract + +不修改 spec。在 `verbName()` 中,当 `rules.OpIDVerbExtract==true` 且 `isPathEncodedOpID(opID)` 为 true 时,启用 Priority 3.5。 + +这个逻辑仍然在 `command.go` 中,但由 normalizer 标记控制,而非硬编码检测 GitCode。 + +## 影响分析 + +### 从 command.go 中移除的逻辑 + +| 当前硬编码 | 移除方式 | +|-----------|---------| +| `isShortSynonym()` map | → `ResourceAlias` | +| `canonicalTag` map | → `ResourceAlias` | +| `normalizeTag()` path 交叉验证 | → `ResourceAlias` + `TagReassign` | +| `resourceFromPath()` map | → `TagReassign` | +| `isPathEncodedOpID()` | → 仍保留在 `command.go` 中(通用的 opId 格式检测) | +| Priority 3.5 | → 由 `OpIDVerbExtract` 规则控制,而非自动检测 GitCode | +| `priorNonParam <= 4` | → 如果 spec 中 tag 正确,此阈值可以降低 | + +### 代码量变化 + +- `command.go`:删除 ~40 行硬编码 +- `normalizer.go`:新增 ~80 行 +- `builtin.go`:每个服务 +5 行配置 + +## 实施步骤 + +1. 创建 `internal/spec/normalizer.go`,实现 `NormalizeRule` + `Normalizer.Normalize()` +2. 创建 `internal/spec/normalizer_test.go` +3. 修改 `internal/registry/builtin.go`,`builtinDef` 增加 `normalize` 字段 +4. 修改 `internal/registry/builtin.go`,`addBuiltin()` 中接入 normalizer +5. 从 `command.go` 删除硬编码逻辑 +6. 更新测试 +7. 完整测试通过 + +## 备选方案(未采纳) + +- **x-cora-* 扩展字段**:直接修改 spec JSON。优点是完全消除 Go 代码中的配置;缺点是需要手动维护 spec 文件,容易与上游不同步。 +- **外部 YAML 配置**:类似 views.yaml。优点是 spec 和修正分离;缺点是增加配置复杂度。 + +当前选择的「builtin 注册」方案折中:配置和 spec 在同一个注册点,声明式但无需额外文件。