Files
OpenFlare/docs/superpowers/plans/2026-08-29-cordis-config-extension.md
T
ryan efa75558af autoresearch iter 20: give the telegram poller a real long-poll window
telebot types LongPoller.Timeout as time.Duration and sends
int(timeout / time.Second) to getUpdates, so the literal 10 meant ten
nanoseconds: Telegram received timeout=0, long polling never held the
connection, and the adapter polled the Bot API in a tight loop instead.
Use 10 seconds and extract the settings so the conversion is asserted.

The adapter also has no media temp-dir cleanup (downloadMedia creates an
MkdirTemp per attachment and nothing removes it); that is left as a separate
change rather than bundled here.
2026-08-29 09:32:27 +08:00

2586 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cordis 配置扩展点(框架与门禁)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在微内核中落地"插件声明配置字段、内核按声明解析、门禁决定插件激活"的配置扩展点,并证明其解析结果与现有 `backend/pkg/config` 逐 key 等价。
**Architecture:** `core/extpoints` 实现纯 stdlib 的配置引擎(声明注册表 + 优先级解析 + 冲突校验 + 脱敏导出),viper 装载隔离在 `plugins/infra/config` 适配器内;`App.Prepare()` 作为解析屏障,`Fiber` 新增 `FiberSkipped` 态承载门禁结果。
**Tech Stack:** Go 1.25.7、`github.com/spf13/viper v1.21.0`(仅适配器)、`github.com/stretchr/testify`(测试)、`github.com/google/go-cmp`(对拍)、golangci-lint(gofumpt + cyclop/funlen/mnd/revive/dupl/gosec)。
**Spec:** `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md`
---
## 本计划范围(对应 spec §7.3 的 P1 + P2)
本计划交付**内核能力**,不改动任何业务插件与 `cmd`:完成后旧的全局单例 `config.Config` 仍然是生产路径的唯一配置来源,应用行为零变化,新增能力由单测与新旧对拍证明。
**下一个计划(P3 + P4,本计划完成后另行编写)** 才做 27 个消费文件的迁移、`pkg/idgen` 解耦与 `backend/pkg/config` 删除。分期理由:迁移的声明结构体写法依赖本计划定稿的 tag 与 API 形状,先写会在实现过程中失真。
## 文件结构
| 文件 | 职责 | 动作 |
| :--- | :--- | :--- |
| `backend/core/extpoints/config.go` | 配置引擎的抽象与声明注册表:`ConfigSource`、`ConfigBinding`、`ConfigEntry`、`ConfigView`、`ConfigExtension`、`ConfigRegistry.Declare` + tag 遍历 + 冲突校验 | Create |
| `backend/core/extpoints/config_value.go` | 值解码:`convertValue` 及其 bool/int/uint/float/string/duration/slice/struct 分支 | Create |
| `backend/core/extpoints/config_resolve.go` | `Resolve` 优先级链、`Bind` 赋值、只读访问器、`Entries` 脱敏导出 | Create |
| `backend/core/extpoints/config_test.go` | 引擎单测(外部测试包 `extpoints_test`,fake source) | Create |
| `backend/core/config.go` | `ConfigGet[T]` 泛型读取入口 | Create |
| `backend/core/config_test.go` | 泛型读取与 `Context.Config()` 接入测试 | Create |
| `backend/core/types.go` | `ConfigExtension`/`ConfigBinding`/`ConfigEntry`/`ConfigSource` 别名 + `ConfigGatedPlugin` 可选接口 | Modify |
| `backend/core/context.go` | `config` 字段、`NewContext` 初始化、`Fork` 共享、`Config()` 访问器 | Modify |
| `backend/core/fiber.go` | `FiberSkipped` 状态与 `Skip()` | Modify |
| `backend/core/app.go` | `WithConfigSource`/`WithConfigDecl`/`Prepare`/`SetShutdownTimeout`、`Use` 收集声明、`reconcileLocked` 门禁求值 | Modify |
| `backend/plugins/infra/config/source.go` | viper + yaml 适配器,实现 `core.ConfigSource`,保留 `CONFIG_PATH` 与向上查找语义 | Create |
| `backend/plugins/infra/config/source_test.go` | 适配器单测(`t.TempDir()` + `t.Setenv`) | Create |
| `backend/pkg/config/config.go` | 抽出可重入 `load(configPath string, testMode bool)`(仅重构,行为不变) | Modify |
| `backend/pkg/config/parity_test.go` | 新旧解析对拍(临时文件,P4 随旧包删除) | Create then Delete in P4 |
| `scripts/check_cordis_architecture.sh` | 微内核禁 viper 检查项 | Modify |
**约束提示:** 所有新增导出符号必须带符合 `go-documentation` 规范的文档注释(`revive` 会检查);测试统一用 `t.TempDir()`,禁止相对路径创建临时目录。
---
## Task 1: 配置引擎的抽象与声明注册表
**Files:**
- Create: `backend/core/extpoints/config.go`
- Test: `backend/core/extpoints/config_test.go`
- [ ] **Step 1: 写失败的测试**
创建 `backend/core/extpoints/config_test.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
"Wavelet/core/extpoints"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests.
type fakeSource struct {
values map[string]any
env map[string]string
}
func newFakeSource() *fakeSource {
return &fakeSource{values: map[string]any{}, env: map[string]string{}}
}
func (f *fakeSource) Lookup(path string) (any, bool) {
v, ok := f.values[path]
return v, ok
}
func (f *fakeSource) LookupEnv(name string) (string, bool) {
v, ok := f.env[name]
return v, ok
}
func (f *fakeSource) Describe() string { return "fake" }
// redisConfig mirrors how a plugin declares the configuration it reads.
type redisConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
DB int `config:"db" env:"REDIS_DB"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
Ignored string `config:"-"`
private string
}
func TestDeclareRegistersTaggedLeafKeys(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
keys := make([]string, 0)
for _, e := range r.Entries() {
keys = append(keys, e.Key)
}
assert.Equal(t, []string{
"redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix",
}, keys)
}
func TestDeclareRejectsNonStructPointerTarget(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}),
extpoints.ErrConfigTarget)
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}),
extpoints.ErrConfigTarget)
}
func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}
require.NoError(t, r.Declare("cache", binding))
require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed")
type conflictingConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"`
}
err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}})
require.ErrorIs(t, err, extpoints.ErrConfigConflict)
assert.Contains(t, err.Error(), "redis.enabled")
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "driver_http")
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v`
Expected: 编译失败,报 `undefined: extpoints.NewConfigRegistry`、`undefined: extpoints.ConfigBinding`、`undefined: extpoints.ErrConfigTarget`、`undefined: extpoints.ErrConfigConflict`。
- [ ] **Step 3: 写最小实现**
创建 `backend/core/extpoints/config.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"time"
)
// Sentinel errors returned by the configuration extension point.
var (
// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")
// ErrConfigType is returned when a value cannot be converted to the declared type.
ErrConfigType = errors.New("extpoints: configuration value type mismatch")
// ErrConfigInvalid is returned when a resolved value violates a declared value range.
// Reserved for source-level value checks; per-plugin value ranges are validated by the
// declaring plugin after Bind (see spec §4.3 C1).
ErrConfigInvalid = errors.New("extpoints: invalid configuration value")
// ErrConfigUnknownKey is returned when a configuration key was never declared.
ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")
// ErrConfigNotResolved is returned when typed reads happen before resolution.
ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")
// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")
// ErrConfigNoSource is returned when resolution is attempted without a registered source.
ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
)
// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
const (
// OriginEnv marks a value that came from an environment variable.
OriginEnv = "env"
// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
OriginAutoEnable = "auto-enable"
// OriginFile marks a value that came from the configuration file.
OriginFile = "file"
// OriginDefault marks a value that came from a declaration default.
OriginDefault = "default"
)
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
var durationType = reflect.TypeFor[time.Duration]()
// ConfigSource abstracts where raw configuration values come from, keeping the
// micro-kernel free of concrete loaders such as viper.
type ConfigSource interface {
// Lookup returns the raw value stored at a dotted path in the configuration file.
Lookup(path string) (any, bool)
// LookupEnv returns the raw value of an environment variable.
LookupEnv(name string) (string, bool)
// Describe returns a human readable identity for the source, used in diagnostics.
Describe() string
}
// ConfigBinding declares that a plugin reads every `config` tagged field of Target
// under a dotted configuration prefix.
type ConfigBinding struct {
// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
// each field's `config` tag is already a full path.
Prefix string
// Target must be a non-nil pointer to a struct carrying `config` tags.
Target any
}
// configField is a single leaf discovered while walking a binding struct's tags.
// key is the fully qualified dotted path used for resolution; path is the raw `config`
// tag value used to locate the Go field again during Bind.
type configField struct {
key string
path string
env string
autoEnable string
def string
secret bool
typ reflect.Type
}
// configDecl is the registered form of a configField, attributed to its declaring plugin.
type configDecl struct {
key string
pluginID string
env string
autoEnable string
def string
secret bool
typ reflect.Type
}
// ConfigEntry is a redacted, self-describing view of one effective configuration key.
type ConfigEntry struct {
Key string
PluginID string
Env string
Origin string
Value string
}
// ConfigView is the read-only surface over effective configuration values.
// Keys are dotted paths such as "redis.enabled".
type ConfigView interface {
Value(key string) (any, bool)
String(key, fallback string) string
Bool(key string, fallback bool) bool
Int(key string, fallback int) int
Duration(key string, fallback time.Duration) time.Duration
Strings(key string) []string
WasSet(envName string) bool
Origin(key string) string
}
// ConfigExtension is the plugin-facing configuration extension point mounted on the
// root Context and shared by every forked plugin scope.
type ConfigExtension interface {
ConfigView
// SetSource installs the raw value source after construction, letting the composition
// root build the adapter once the kernel Context already exists.
SetSource(src ConfigSource)
// Declare registers plugin-owned configuration bindings before Apply runs.
Declare(pluginID string, bindings ...ConfigBinding) error
// Bind resolves and assigns the configuration values for a tagged struct.
Bind(prefix string, target any) error
// Resolve computes the effective value of every declared key once.
Resolve() error
// Resolved reports whether Resolve has already run.
Resolved() bool
// Entries returns the redacted effective configuration ordered by key.
Entries() []ConfigEntry
}
// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
// computed once by Resolve and reused by every later read.
type ConfigRegistry struct {
mu sync.RWMutex
src ConfigSource
decls map[string]*configDecl
order []string
values map[string]any
origins map[string]string
resolved bool
}
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
// that the kernel can construct the registry before the composition root injects one.
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
return &ConfigRegistry{
src: src,
decls: make(map[string]*configDecl),
values: make(map[string]any),
origins: make(map[string]string),
}
}
// SetSource installs the raw value source. It is intended for the composition root,
// which builds the adapter after the kernel Context already exists.
func (r *ConfigRegistry) SetSource(src ConfigSource) {
r.mu.Lock()
defer r.mu.Unlock()
r.src = src
}
// Declare registers every `config` tagged leaf of each binding's target struct.
// Repeated declarations of the same key are accepted only when their env, default,
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, b := range bindings {
if err := r.declareBinding(pluginID, b); err != nil {
return err
}
}
return nil
}
func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
target, err := bindingStruct(b.Target, b.Prefix)
if err != nil {
return err
}
fields, err := walkConfigFields(target.Type(), b.Prefix)
if err != nil {
return err
}
for _, f := range fields {
if err := r.addDecl(pluginID, f); err != nil {
return err
}
}
return nil
}
// bindingStruct validates that a binding or bind target is a usable struct pointer.
func bindingStruct(target any, prefix string) (reflect.Value, error) {
rv := reflect.ValueOf(target)
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
}
return rv.Elem(), nil
}
// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
// A field without a `config` tag is skipped, except for embedded structs which are
// recursed into so their own tags resolve under the same prefix.
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
var out []configField
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if sf.PkgPath != "" {
continue
}
path := sf.Tag.Get("config")
if path == "-" {
continue
}
if path == "" {
if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
nested, err := walkConfigFields(sf.Type, prefix)
if err != nil {
return nil, err
}
out = append(out, nested...)
}
continue
}
out = append(out, configField{
key: joinKey(prefix, path),
path: path,
env: sf.Tag.Get("env"),
autoEnable: sf.Tag.Get("autoEnable"),
def: sf.Tag.Get("default"),
secret: strings.EqualFold(sf.Tag.Get("secret"), "true"),
typ: sf.Type,
})
}
return out, nil
}
func joinKey(prefix, path string) string {
if prefix == "" {
return path
}
return prefix + "." + path
}
// addDecl records one leaf, enforcing the shared-declaration consistency rule.
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
if existing, ok := r.decls[f.key]; ok {
if existing.env != f.env || existing.def != f.def ||
existing.autoEnable != f.autoEnable || existing.secret != f.secret {
return fmt.Errorf(
"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
ErrConfigConflict, f.key, existing.pluginID, pluginID)
}
return nil
}
r.decls[f.key] = &configDecl{
key: f.key, pluginID: pluginID, env: f.env,
autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
}
r.order = append(r.order, f.key)
return nil
}
```
- [ ] **Step 4: 补齐测试所需的占位实现**
此时 `Entries`、`Resolve`、`Bind`、访问器尚未实现,Step 1 的测试用到 `Entries`。先创建 `backend/core/extpoints/config_resolve.go` 骨架,仅让 `Entries` 返回声明清单(值与来源在 Task 3/4 填充):
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sort"
// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
r.mu.RLock()
defer r.mu.RUnlock()
keys := append([]string(nil), r.order...)
sort.Strings(keys)
out := make([]ConfigEntry, 0, len(keys))
for _, key := range keys {
d := r.decls[key]
out = append(out, ConfigEntry{
Key: d.key, PluginID: d.pluginID, Env: d.env,
Origin: r.origins[key], Value: "pending",
})
}
return out
}
```
- [ ] **Step 5: 运行测试确认通过**
Run: `cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v`
Expected: `--- PASS: TestDeclareRegistersTaggedLeafKeys`、`--- PASS: TestDeclareRejectsNonStructPointerTarget`、`--- PASS: TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata`,`ok Wavelet/core/extpoints`。
- [ ] **Step 6: 格式与静态检查**
Run: `cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/`
Expected: 无告警输出,退出码 0。
- [ ] **Step 7: 提交**
```bash
git add backend/core/extpoints/config.go backend/core/extpoints/config_resolve.go backend/core/extpoints/config_test.go
git commit -m "feat(core): add configuration declaration registry"
```
---
## Task 2: 值解码(标量、时长、切片、结构体)
**Files:**
- Create: `backend/core/extpoints/config_value.go`
- Test: `backend/core/extpoints/config_test.go`(追加)
- [ ] **Step 1: 追加失败的测试**
在 `backend/core/extpoints/config_test.go` 末尾追加(`fakeSource`、`redisConfig` 复用 Task 1 的定义):
```go
// queueConfig is a composite element mirroring worker.queues in config.yaml.
type queueConfig struct {
Name string `config:"name"`
Priority int `config:"priority"`
}
type workerConfig struct {
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
Queues []queueConfig `config:"queues"`
}
type sessionConfig struct {
Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
}
func TestResolveScalarDurationAndSlice(t *testing.T) {
src := newFakeSource()
src.values["redis.db"] = 1
src.values["redis.dial_timeout"] = "5s"
src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
src.env["REDIS_KEY_PREFIX"] = "refresh:"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
var got redisConfig
require.NoError(t, r.Bind("redis", &got))
assert.Equal(t, redisConfig{
Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
}, got)
}
func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
src := newFakeSource()
src.env["REDIS_ADDR"] = "redis:6379"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs"))
}
func TestResolveCompositeSliceOfStructs(t *testing.T) {
src := newFakeSource()
src.values["worker.concurrency"] = 20
src.values["worker.queues"] = []any{
map[string]any{"name": "webhook", "priority": 10},
map[string]any{"name": "default", "priority": 3},
}
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
require.NoError(t, r.Resolve())
var got workerConfig
require.NoError(t, r.Bind("worker", &got))
assert.Equal(t, workerConfig{
Concurrency: 20,
Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
}, got)
}
func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
src := newFakeSource()
src.env["WORKER_CONCURRENCY"] = "many"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
err := r.Resolve()
require.ErrorIs(t, err, extpoints.ErrConfigType)
assert.Contains(t, err.Error(), "worker.concurrency")
assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./core/extpoints/ -run 'TestResolve' -v`
Expected: 编译失败,报 `r.Resolve undefined`、`r.Bind undefined`、`r.Strings undefined`。
- [ ] **Step 3: 写实现**
创建 `backend/core/extpoints/config_value.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// convertValue coerces a raw value coming from the configuration file or an
// environment variable into the declared Go type.
func convertValue(raw any, typ reflect.Type) (any, error) {
if typ == durationType {
return convertDuration(raw)
}
switch typ.Kind() {
case reflect.Bool:
return convertBool(raw)
case reflect.String:
return convertString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return convertInt(raw, typ)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return convertUint(raw, typ)
case reflect.Float32, reflect.Float64:
return convertFloat(raw, typ)
case reflect.Slice:
return convertSlice(raw, typ)
case reflect.Struct:
return convertStruct(raw, typ)
default:
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
}
func convertBool(raw any) (any, error) {
switch v := raw.(type) {
case bool:
return v, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
}
return parsed, nil
default:
return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
}
}
func convertString(raw any) (any, error) {
switch v := raw.(type) {
case string:
return v, nil
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v), nil
default:
return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
}
}
// numericString extracts the textual form of a value so environment overrides,
// which always arrive as strings, share one parsing path with file values.
func numericString(raw any) (string, bool) {
switch v := raw.(type) {
case string:
return strings.TrimSpace(v), true
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v), true
default:
return "", false
}
}
func convertInt(raw any, typ reflect.Type) (any, error) {
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not an integer", ErrConfigType, raw)
}
parsed, err := strconv.ParseInt(text, 10, typ.Bits())
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
}
out := reflect.New(typ).Elem()
out.SetInt(parsed)
return out.Interface(), nil
}
func convertUint(raw any, typ reflect.Type) (any, error) {
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not an unsigned integer", ErrConfigType, raw)
}
parsed, err := strconv.ParseUint(text, 10, typ.Bits())
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
}
out := reflect.New(typ).Elem()
out.SetUint(parsed)
return out.Interface(), nil
}
func convertFloat(raw any, typ reflect.Type) (any, error) {
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a float", ErrConfigType, raw)
}
parsed, err := strconv.ParseFloat(text, typ.Bits())
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
}
out := reflect.New(typ).Elem()
out.SetFloat(parsed)
return out.Interface(), nil
}
// convertDuration accepts both Go duration strings such as "200ms" and integer
// nanoseconds, mirroring what the previous viper based decoding supported.
func convertDuration(raw any) (any, error) {
switch v := raw.(type) {
case time.Duration:
return v, nil
}
text, ok := numericString(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
}
if parsed, err := time.ParseDuration(text); err == nil {
return parsed, nil
}
nanos, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
}
return time.Duration(nanos), nil
}
func convertSlice(raw any, typ reflect.Type) (any, error) {
items, ok := sliceItems(raw)
if !ok {
// Scalar-to-single-element promotion keeps REDIS_ADDR populating redis.addrs.
items = []any{raw}
}
out := reflect.MakeSlice(typ, 0, len(items))
for _, item := range items {
converted, err := convertValue(item, typ.Elem())
if err != nil {
return nil, err
}
out = reflect.Append(out, reflect.ValueOf(converted))
}
return out.Interface(), nil
}
// sliceItems normalises the several slice shapes a loader may produce.
func sliceItems(raw any) ([]any, bool) {
switch v := raw.(type) {
case []any:
return v, true
case []string:
items := make([]any, len(v))
for i, s := range v {
items[i] = s
}
return items, true
}
rv := reflect.ValueOf(raw)
if rv.IsValid() && rv.Kind() == reflect.Slice {
items := make([]any, rv.Len())
for i := 0; i < rv.Len(); i++ {
items[i] = rv.Index(i).Interface()
}
return items, true
}
return nil, false
}
func convertStruct(raw any, typ reflect.Type) (any, error) {
table, ok := asStringMap(raw)
if !ok {
return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
}
fields, err := walkConfigFields(typ, "")
if err != nil {
return nil, err
}
out := reflect.New(typ).Elem()
for _, f := range fields {
item, present := table[f.key]
if !present || item == nil {
continue
}
converted, err := convertValue(item, f.typ)
if err != nil {
return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
}
out.FieldByName(indexFieldName(typ, f.path)).Set(reflect.ValueOf(converted))
}
return out.Interface(), nil
}
// asStringMap normalises the two map shapes produced by YAML decoders.
func asStringMap(raw any) (map[string]any, bool) {
switch v := raw.(type) {
case map[string]any:
return v, true
case map[any]any:
out := make(map[string]any, len(v))
for key, val := range v {
name, ok := key.(string)
if !ok {
return nil, false
}
out[name] = val
}
return out, true
default:
return nil, false
}
}
// indexFieldName maps a declared config path back to the Go struct field carrying it.
func indexFieldName(t reflect.Type, key string) string {
for i := 0; i < t.NumField(); i++ {
if t.Field(i).Tag.Get("config") == key {
return t.Field(i).Name
}
}
return ""
}
```
- [ ] **Step 4: 写解析与绑定实现**
在 `backend/core/extpoints/config_resolve.go` **末尾追加**下列实现(保留 Task 1 写入的文件头、`Entries` 占位与 `sort` import;本步骤新增用到 `errors`、`fmt`、`reflect`,不要引入 `sync`/`time`):
```go
// Resolve computes the effective value of every declared key. Priority is, in order:
// an explicit environment override, an auto-enable trigger, the configuration file,
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
func (r *ConfigRegistry) Resolve() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
return ErrConfigNoSource
}
var errs []error
for _, key := range r.order {
if _, done := r.values[key]; done {
continue
}
if err := r.resolveLocked(key); err != nil {
errs = append(errs, err)
}
}
r.resolved = true
return errors.Join(errs...)
}
// Resolved reports whether Resolve has already run.
func (r *ConfigRegistry) Resolved() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.resolved
}
// resolveLocked computes one key. The caller must hold r.mu.
func (r *ConfigRegistry) resolveLocked(key string) error {
d, ok := r.decls[key]
if !ok {
return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
}
if d.env != "" {
if raw, found := r.src.LookupEnv(d.env); found {
value, err := convertValue(raw, d.typ)
if err != nil {
return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
}
r.values[key], r.origins[key] = value, OriginEnv
return nil
}
}
if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
if _, found := r.src.LookupEnv(d.autoEnable); found {
r.values[key], r.origins[key] = true, OriginAutoEnable
return nil
}
}
if raw, found := r.src.Lookup(key); found {
value, err := convertValue(raw, d.typ)
if err != nil {
return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
}
r.values[key], r.origins[key] = value, OriginFile
return nil
}
if d.def != "" {
value, err := convertValue(d.def, d.typ)
if err != nil {
return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
}
r.values[key], r.origins[key] = value, OriginDefault
return nil
}
r.values[key] = reflect.New(d.typ).Elem().Interface()
r.origins[key] = ""
return nil
}
// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
// were never declared self-register, so only gates need DeclareConfig.
func (r *ConfigRegistry) Bind(prefix string, target any) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
return ErrConfigNoSource
}
if !r.resolved {
return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
}
elem, err := bindingStruct(target, prefix)
if err != nil {
return err
}
fields, err := walkConfigFields(elem.Type(), prefix)
if err != nil {
return err
}
for _, f := range fields {
if _, declared := r.decls[f.key]; !declared {
if err := r.addDecl("bind:"+prefix, f); err != nil {
return err
}
}
if _, done := r.values[f.key]; !done {
if err := r.resolveLocked(f.key); err != nil {
return err
}
}
}
for _, f := range fields {
value := r.values[f.key]
field := elem.FieldByName(indexFieldName(elem.Type(), f.path))
if !field.IsValid() || !field.CanSet() {
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
}
rv := reflect.ValueOf(value)
if !rv.Type().AssignableTo(field.Type()) {
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
ErrConfigType, f.key, rv.Type(), field.Type())
}
field.Set(rv)
}
return nil
}
```
注意:`ErrConfigUnknownKey` 等全部哨兵错误已在 Task 1 的 `config.go` 错误块中定义,本步骤不要重复声明。
- [ ] **Step 5: 运行测试确认通过**
Run: `cd backend && go test ./core/extpoints/ -run 'TestResolve|TestDeclare' -v`
Expected: 全部 `--- PASS`,`ok Wavelet/core/extpoints`。若报 `Entries redeclared`,说明 Step 4 误把整文件替换而非追加。
- [ ] **Step 6: 格式与静态检查**
Run: `cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/`
Expected: 无告警。(`convertValue` 保持 8 个分支,若 `cyclop` 仍报复杂度过高,把 `Slice`/`Struct` 两分支拆成独立函数,不要放宽 lint 配置。)
- [ ] **Step 7: 提交**
```bash
git add backend/core/extpoints/
git commit -m "feat(core): resolve declared configuration with env and file precedence"
```
---
## Task 3: 只读访问器、泛型读取与脱敏导出
**Files:**
- Modify: `backend/core/extpoints/config_resolve.go`(追加访问器)
- Modify: `backend/core/extpoints/config.go`(`Entries` 用到的 secret 判断)
- Create: `backend/core/config.go`
- Test: `backend/core/extpoints/config_test.go`(追加)、`backend/core/config_test.go`
- [ ] **Step 1: 追加失败的测试**
在 `backend/core/extpoints/config_test.go` 末尾追加:
```go
func TestViewAccessorsAndOrigins(t *testing.T) {
src := newFakeSource()
src.values["redis.db"] = 1
src.env["REDIS_ADDR"] = "redis:6379"
src.env["REDIS_ENABLED"] = "false"
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
require.NoError(t, r.Resolve())
assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs"))
assert.Equal(t, "redis:6379", r.Strings("redis.addrs")[0])
assert.False(t, r.Bool("redis.enabled", true))
assert.Equal(t, 1, r.Int("redis.db", 0))
assert.Equal(t, "86400", r.String("redis.missing", "86400"))
assert.True(t, r.WasSet("REDIS_ADDR"))
assert.False(t, r.WasSet("REDIS_NOPE"))
}
func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) {
src := newFakeSource()
src.env["REDIS_ADDR"] = "redis:6379"
src.values["redis.enabled"] = false
r := extpoints.NewConfigRegistry(src)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r.Resolve())
assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled")
assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled"))
explicit := newFakeSource()
explicit.env["REDIS_ADDR"] = "redis:6379"
explicit.env["REDIS_ENABLED"] = "false"
r2 := extpoints.NewConfigRegistry(explicit)
require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
require.NoError(t, r2.Resolve())
assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable")
assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled"))
}
func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
require.NoError(t, r.Resolve())
entries := map[string]extpoints.ConfigEntry{}
for _, e := range r.Entries() {
entries[e.Key] = e
}
assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value)
assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin)
assert.Equal(t, "86400", entries["app.session_age"].Value)
}
func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
r := extpoints.NewConfigRegistry(nil)
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource)
var cfg redisConfig
assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource)
}
```
新增脱敏常量到 `backend/core/extpoints/config.go`(Task 1 未定义它,因为此处才首次使用):
```go
// RedactedValue replaces the printed value of keys declared with secret:"true".
const RedactedValue = "******"
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./core/extpoints/ -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects' -v`
Expected: 编译失败,报 `r.Bool undefined`、`extpoints.RedactedValue undefined` 等;`Entries` 已有但返回 `"pending"` 占位,故 `TestEntriesRedactSecretsAndReportDefaults` 亦失败。
- [ ] **Step 3: 实现访问器**
在 `backend/core/extpoints/config_resolve.go` 末尾追加:
```go
// Value returns the resolved value for key, lazily resolving it when a source is
// available. Unresolvable and missing keys report false rather than an error so
// gates and diagnostics can keep using fallback accessors.
func (r *ConfigRegistry) Value(key string) (any, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if r.src == nil {
value, ok := r.values[key]
return value, ok
}
if _, done := r.values[key]; !done {
if _, declared := r.decls[key]; !declared {
return nil, false
}
if err := r.resolveLocked(key); err != nil {
return nil, false
}
}
value, ok := r.values[key]
return value, ok
}
// String returns the string value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) String(key, fallback string) string {
if value, ok := r.Value(key); ok {
if converted, err := convertString(value); err == nil {
return converted.(string)
}
}
return fallback
}
// Bool returns the boolean value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Bool(key string, fallback bool) bool {
if value, ok := r.Value(key); ok {
if converted, err := convertBool(value); err == nil {
return converted.(bool)
}
}
return fallback
}
// Int returns the int value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Int(key string, fallback int) int {
if value, ok := r.Value(key); ok {
if converted, err := convertInt(value, reflect.TypeFor[int]()); err == nil {
return int(converted.(int))
}
}
return fallback
}
// Duration returns the time.Duration value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration {
if value, ok := r.Value(key); ok {
if converted, err := convertDuration(value); err == nil {
return converted.(time.Duration)
}
}
return fallback
}
// Strings returns the []string value of key, or nil when absent.
func (r *ConfigRegistry) Strings(key string) []string {
value, ok := r.Value(key)
if !ok {
return nil
}
converted, err := convertSlice(value, reflect.TypeFor[[]string]())
if err != nil {
return nil
}
list, _ := converted.([]string)
return list
}
// WasSet reports whether an environment variable is present, regardless of its value.
func (r *ConfigRegistry) WasSet(envName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.src == nil {
return false
}
_, found := r.src.LookupEnv(envName)
return found
}
// Origin reports where a key's effective value came from; "" means the zero value.
func (r *ConfigRegistry) Origin(key string) string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.origins[key]
}
```
把 `Entries` 的占位实现替换为真实值与脱敏:
```go
// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
r.mu.Lock()
defer r.mu.Unlock()
keys := append([]string(nil), r.order...)
sort.Strings(keys)
out := make([]ConfigEntry, 0, len(keys))
for _, key := range keys {
d := r.decls[key]
if _, done := r.values[key]; !done && r.src != nil {
_ = r.resolveLocked(key)
}
out = append(out, ConfigEntry{
Key: d.key,
PluginID: d.pluginID,
Env: d.env,
Origin: r.origins[key],
Value: formatEntryValue(r.values[key], d.secret),
})
}
return out
}
// formatEntryValue renders one effective value for diagnostics, masking secrets.
func formatEntryValue(value any, secret bool) string {
if secret {
return RedactedValue
}
if value == nil {
return ""
}
return fmt.Sprint(value)
}
```
- [ ] **Step 4: 实现泛型读取入口**
创建 `backend/core/config.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"fmt"
"Wavelet/core/extpoints"
)
// ConfigGet reads one resolved configuration value with its declared type. It is the
// generic counterpart of the fallback accessors on ConfigView, and returns
// ErrConfigNotResolved when the key has neither been declared nor resolved.
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
var zero T
if view == nil {
return zero, extpoints.ErrConfigNotResolved
}
raw, ok := view.Value(key)
if !ok {
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
}
value, ok := raw.(T)
if !ok {
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
}
return value, nil
}
```
创建 `backend/core/config_test.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type otelConfig struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
}
func TestConfigGetReturnsDeclaredType(t *testing.T) {
r := extpoints.NewConfigRegistry(nil)
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
rate, err := core.ConfigGet[float64](r, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
assert.Equal(t, 0.0, rate)
}
```
- [ ] **Step 5: 运行测试确认通过**
Run: `cd backend && go test ./core/... -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects|TestConfigGet' -v`
Expected: 全部 `--- PASS`。
- [ ] **Step 6: 格式与静态检查**
Run: `cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/...`
Expected: 无告警。若 `dupl` 因 `convertInt`/`convertUint` 结构相似报警,为其中之一加注释说明类型不同不可合并,或拆出公共 reflect 设置函数;不得关闭 `dupl`。
- [ ] **Step 7: 提交**
```bash
git add backend/core/
git commit -m "feat(core): add read-only config view accessors and generic getter"
```
---
## Task 4: 把配置注册表挂到 Context 并在 types.go 导出别名
**Files:**
- Modify: `backend/core/context.go`(`config` 字段、`NewContext`、`Fork`、`Config()`)
- Modify: `backend/core/types.go`(别名)
- Test: `backend/core/config_test.go`(追加)、`backend/core/context_test.go`(追加断言)
- [ ] **Step 1: 追加失败的测试**
在 `backend/core/config_test.go` 末尾追加:
```go
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
ctx := core.NewContext(nil)
child := ctx.Fork()
require.NoError(t, child.Config().Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &otelConfig{}}))
rate, err := core.ConfigGet[float64](ctx.Config(), "redis.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
assert.Zero(t, rate)
assert.False(t, ctx.Config().Resolved())
}
```
在 `backend/core/context_test.go` 中已有的 Context 构造测试里追加一行断言(沿用该文件现有测试函数与变量名):
```go
require.NotNil(t, ctx.Config(), "every Context must expose the configuration extension")
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./core/ -run 'TestContextConfigIsSharedAcrossForks' -v`
Expected: 编译失败,报 `ctx.Config undefined`、`core.ConfigBinding undefined`(测试里改用 `extpoints.ConfigBinding` 后该项消除)。
- [ ] **Step 3: 写实现**
`backend/core/context.go` 的 `Context` 结构体字段中,在 `settings` 之后加一行:
```go
settings extpoints.SettingExtension
config extpoints.ConfigExtension
```
`NewContext` 的返回字面量中,在 `settings: extpoints.NewSettingRegistry(),` 之后加:
```go
config: extpoints.NewConfigRegistry(nil),
```
`ForkWithContext` 的 child 字面量中,在 `settings: c.settings,` 之后加:
```go
config: c.config,
```
在 `Setting()` 别名方法之后加访问器:
```go
// Config returns the process-level configuration extension point. The registry is
// shared by every fork because configuration declarations are global facts, and it
// intentionally carries no per-scope disposers: values are resolved once before Apply.
func (c *Context) Config() extpoints.ConfigExtension {
return c.config
}
```
`backend/core/types.go` 末尾追加别名与新接口:
```go
// ConfigExtension re-exports extpoints.ConfigExtension.
type ConfigExtension = extpoints.ConfigExtension
// ConfigSource re-exports extpoints.ConfigSource.
type ConfigSource = extpoints.ConfigSource
// ConfigBinding re-exports extpoints.ConfigBinding.
type ConfigBinding = extpoints.ConfigBinding
// ConfigView re-exports extpoints.ConfigView.
type ConfigView = extpoints.ConfigView
// ConfigEntry re-exports extpoints.ConfigEntry.
type ConfigEntry = extpoints.ConfigEntry
// ConfigGatedPlugin is an optional interface for plugins whose activation depends on
// configuration. The kernel evaluates the gate before any Apply runs, so keys read by
// ConfigEnabled must be published through DeclareConfig.
type ConfigGatedPlugin interface {
Plugin
// DeclareConfig publishes the configuration bindings consumed by ConfigEnabled.
DeclareConfig() []extpoints.ConfigBinding
// ConfigEnabled reports whether this plugin should activate for the resolved values.
ConfigEnabled(view extpoints.ConfigView) bool
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `cd backend && go test ./core/... -v -run 'TestContextConfig|TestFiber|TestApp|TestConfigGet'`
Expected: 新增用例 `--- PASS`,既有用例无回归。
- [ ] **Step 5: 提交**
```bash
git add backend/core/context.go backend/core/types.go backend/core/config_test.go backend/core/context_test.go
git commit -m "feat(core): mount the configuration extension point on the kernel Context"
```
---
## Task 5: Fiber 门禁跳过态
**Files:**
- Modify: `backend/core/fiber.go`
- Test: `backend/core/fiber_test.go`(追加)
- [ ] **Step 1: 追加失败的测试**
在 `backend/core/fiber_test.go` 末尾追加:
```go
// gatedPlugin is a minimal plugin used to exercise configuration gating.
type gatedPlugin struct {
name string
enabled bool
applied bool
}
func (g *gatedPlugin) Name() string { return g.name }
func (g *gatedPlugin) Apply(ctx *core.Context) error {
g.applied = true
return nil
}
func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding {
return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}}
}
func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool {
return view.Bool("gate.enabled", false) == g.enabled
}
type gateConfig struct {
Enabled bool `config:"enabled" env:"GATE_ENABLED"`
}
func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) {
root := core.NewContext(nil)
plugin := &gatedPlugin{name: "cache", enabled: true}
f := core.NewFiber(root, plugin)
require.Equal(t, core.FiberPending, f.State())
require.NoError(t, f.Skip())
assert.Equal(t, core.FiberSkipped, f.State())
assert.True(t, f.Skipped())
assert.False(t, plugin.applied, "a skipped plugin must never reach Apply")
assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op")
}
func TestFiberSkipIsIdempotentForActiveFibers(t *testing.T) {
root := core.NewContext(nil)
f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true})
require.NoError(t, f.Load())
require.NoError(t, f.Skip())
assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers")
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./core/ -run 'TestFiberSkip' -v`
Expected: 编译失败,报 `undefined: core.FiberSkipped`、`f.Skip undefined`、`f.Skipped undefined`。
- [ ] **Step 3: 写实现**
`backend/core/fiber.go` 的 `FiberState` 常量块中,在 `FiberDisposed` 之后追加一个状态:
```go
// FiberSkipped indicates the plugin never activated because its configuration gate
// evaluated to false, so an alternative provider took over.
FiberSkipped FiberState = "SKIPPED"
```
`Load` 之后追加 `Skip`:
```go
// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
// Active plugins are left untouched, making the call safe to replay during reconcile.
func (f *Fiber) Skip() error {
f.mu.Lock()
if f.state != FiberPending {
f.mu.Unlock()
return nil
}
f.state = FiberSkipped
f.mu.Unlock()
return f.ctx.Dispose()
}
// Skipped reports whether the plugin was excluded by its configuration gate.
func (f *Fiber) Skipped() bool {
return f.State() == FiberSkipped
}
```
> **不要改 `DependenciesSatisfied`**:依赖能否满足完全由 IoC 容器解析决定。被跳过的插件从未执行 `Apply`,也就没有 `core.Provide`,其消费者自然解析不到服务并在 `Reconcile` 里报"waiting for"。用 Fiber 状态做短路是错误语义。
- [ ] **Step 4: 运行测试确认通过**
Run: `cd backend && go test ./core/ -run 'TestFiber' -v`
Expected: 新增两个用例 `--- PASS`,既有 `TestFiber_ConfluenceAndReactiveActivation`、`TestFiber_UnsatisfiedDependencyReturnsError` 无回归。
- [ ] **Step 5: 提交**
```bash
git add backend/core/fiber.go backend/core/fiber_test.go
git commit -m "feat(core): add skipped fiber state for configuration gates"
```
---
## Task 6: App 装配选项、解析屏障与门禁求值
**Files:**
- Modify: `backend/core/app.go`
- Test: `backend/core/app_test.go`(追加)
- [ ] **Step 1: 追加失败的测试**
在 `backend/core/app_test.go` 末尾追加(复用 Task 5 的 `gatedPlugin`/`gateConfig`;`mapSource` 为本测试自备的内存源):
```go
// mapSource implements core.ConfigSource over static maps.
type mapSource struct {
values map[string]any
env map[string]string
}
func (m *mapSource) Lookup(path string) (any, bool) {
v, ok := m.values[path]
return v, ok
}
func (m *mapSource) LookupEnv(name string) (string, bool) {
v, ok := m.env[name]
return v, ok
}
func (m *mapSource) Describe() string { return "map" }
func newGateSource(enabled bool) *mapSource {
src := &mapSource{values: map[string]any{"gate.enabled": enabled}, env: map[string]string{}}
return src
}
func TestAppPrepareResolvesAndGatesPlugins(t *testing.T) {
redisLike := &gatedPlugin{name: "cache", enabled: true}
redisAlt := &gatedPlugin{name: "cache_memory", enabled: false}
app := core.NewApp(
core.WithProfile(core.ProfileAPI),
core.WithConfigSource(newGateSource(true)),
)
app.Use(redisLike, redisAlt)
require.NoError(t, app.Prepare())
cacheFiber, ok := app.Fiber("cache")
require.True(t, ok)
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
assert.True(t, app.Context().Config().Resolved())
require.NoError(t, app.Reconcile())
require.Equal(t, core.FiberActive, cacheFiber.State())
memoryFiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
assert.False(t, redisAlt.applied)
}
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
require.NoError(t, app.Prepare())
late := &gatedPlugin{name: "cache_memory", enabled: false}
app.Use(late)
require.NoError(t, app.Reconcile())
fiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"plugins added after Prepare must still be gated")
}
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
redisLike := &gatedPlugin{name: "cache", enabled: true}
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
app.Use(redisLike)
require.NoError(t, app.ApplyPlugins())
fiber, ok := app.Fiber("cache")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(), "ApplyPlugins must resolve and gate implicitly")
}
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
src := &mapSource{values: map[string]any{"gate.enabled": "yes"}, env: map[string]string{}}
app := core.NewApp(core.WithConfigSource(src))
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.Prepare()
require.Error(t, err)
assert.Contains(t, err.Error(), "gate.enabled")
}
func TestAppSetShutdownTimeoutOverridesDefault(t *testing.T) {
app := core.NewApp()
app.SetShutdownTimeout(0)
assert.NotZero(t, app.ShutdownTimeout(), "zero durations must not shrink the kernel fallback")
app.SetShutdownTimeout(45 * time.Second)
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./core/ -run 'TestAppPrepare|TestAppStartRunsPrepare|TestAppSetShutdown' -v`
Expected: 编译失败,报 `core.WithConfigSource undefined`、`app.Prepare undefined`、`app.ShutdownTimeout undefined`。
- [ ] **Step 3: 写实现**
`backend/core/app.go` 中,`App` 结构体追加两个字段:
```go
migrationEngine MigrationEngine
shutdownTimeout time.Duration
configSource ConfigSource
prepared bool
```
`AppOption` 区追加选项(放在 `WithShutdownTimeout` 之后):
```go
// WithConfigSource installs the raw configuration source adapter, typically built by
// an infrastructure package outside the kernel, before any plugin is applied.
func WithConfigSource(src ConfigSource) AppOption {
return func(a *App) {
if src == nil {
return
}
a.configSource = src
a.ctx.Config().SetSource(src)
}
}
// WithConfigDecl lets the composition root declare the configuration it reads itself,
// so host-level values participate in conflict validation and redacted reporting.
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
return func(a *App) {
if len(bindings) == 0 {
return
}
if err := a.ctx.Config().Declare(pluginID, bindings...); err != nil {
a.applyErr = err
}
}
}
```
`App` 结构体再加 `applyErr error` 字段,并在 `NewApp` 末尾返回前保持原逻辑(`applyErr` 由 `Prepare` 首次上报)。
追加 `Prepare`、`ShutdownTimeout`、`SetShutdownTimeout`:
```go
// Prepare resolves declared configuration and evaluates plugin gates. It is idempotent
// and runs implicitly from ApplyPlugins, so callers that need resolved values earlier
// (for example to size a shutdown budget) can invoke it explicitly.
func (a *App) Prepare() error {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.applyErr; err != nil {
return err
}
return a.prepareLocked()
}
func (a *App) prepareLocked() error {
if a.prepared {
return nil
}
if err := a.ctx.Config().Resolve(); err != nil {
return err
}
a.prepared = true
return nil
}
// ShutdownTimeout returns the graceful shutdown budget for the application.
func (a *App) ShutdownTimeout() time.Duration {
a.mu.RLock()
defer a.mu.RUnlock()
return a.shutdownTimeout
}
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive values.
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
a.mu.Lock()
defer a.mu.Unlock()
if timeout > 0 {
a.shutdownTimeout = timeout
}
return a
}
```
> **门禁为什么在 `reconcileLocked` 内求值而不是 `Prepare` 里一次性遍历**:`App.Use` 可以在 `Prepare` 之后继续挂载插件(下游定制与动态装配)。只在 `Prepare` 求值会留下一批永不判定的门禁;放在调和循环里则任何时刻新挂载的插件都会被正确判定,且 `Fiber.Skip` 自带"仅 Pending 可跳过"守卫,重复遍历安全。
`Use` 中,为每个成功登记的插件收集声明(放在 `a.pluginMap[name] = p` 之前):
```go
if gated, ok := p.(ConfigGatedPlugin); ok {
if err := a.ctx.Config().Declare(name, gated.DeclareConfig()...); err != nil {
if a.applyErr == nil {
a.applyErr = err
}
}
}
```
`ApplyPlugins` 与 `reconcileLocked` 接入屏障(`ApplyPlugins` 已持锁,改调用 `prepareLocked`):
```go
func (a *App) ApplyPlugins() error {
a.mu.Lock()
if a.applied {
a.mu.Unlock()
return nil
}
a.applied = true
if err := a.applyErr; err != nil {
a.mu.Unlock()
return err
}
if err := a.prepareLocked(); err != nil {
a.mu.Unlock()
return err
}
a.mu.Unlock()
return a.Reconcile()
}
```
把 `reconcileLocked` 的内层循环替换为带门禁判定的版本(其余保持不变):
```go
func (a *App) reconcileLocked() error {
if err := a.prepareLocked(); err != nil {
return err
}
view := a.ctx.Config()
for {
progress := false
for _, f := range a.fibers {
if f.State() != FiberPending {
continue
}
if gated, ok := f.plugin.(ConfigGatedPlugin); ok {
if !view.Resolved() {
continue
}
if !gated.ConfigEnabled(view) {
if err := f.Skip(); err != nil {
return fmt.Errorf("core: skip gated plugin %q: %w", f.Name(), err)
}
continue
}
}
if f.DependenciesSatisfied(a.ctx) {
if err := f.Load(); err != nil {
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
}
progress = true
}
}
if !progress {
break
}
}
// ...existing unsatisfied-dependency reporting unchanged
}
```
`unsatisfied` 收集循环无需改动:它只统计 `FiberPending`,被门禁排除的插件已是 `FiberSkipped`。
- [ ] **Step 4: 运行测试确认通过**
Run: `cd backend && go test ./core/ -v`
Expected: 全部 `--- PASS`,包括既有 `TestApp*`、`TestFiber*`、`TestContext*`。
- [ ] **Step 5: 全量回归(应用行为必须不变)**
Run: `cd backend && go test ./... && go build -o /dev/null ./...`
Expected: 全绿。此时业务插件与 `cmd` 仍走旧的全局单例,因此运行行为与迁移前完全一致——这是本计划的关键安全属性。
- [ ] **Step 6: 格式与静态检查**
Run: `cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/...`
Expected: 无告警。
- [ ] **Step 7: 提交**
```bash
git add backend/core/app.go backend/core/app_test.go
git commit -m "feat(core): add config resolution barrier and plugin gating to App"
```
---
## Task 7: viper 配置源适配器(plugins/infra/config)
**Files:**
- Create: `backend/plugins/infra/config/source.go`
- Create: `backend/plugins/infra/config/source_test.go`
- [ ] **Step 1: 写失败的测试**
创建 `backend/plugins/infra/config/source_test.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/plugins/infra/config"
)
const sampleYAML = "" +
"app:\n addr: \":8000\"\n node_id: 1\n" +
"database:\n enabled: false\n port: 5432\n slow_threshold: 200ms\n" +
"redis:\n addrs:\n - \"127.0.0.1:6379\"\n"
func writeConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(sampleYAML), 0o600))
return path
}
func TestSourceLooksUpNestedPaths(t *testing.T) {
src, err := config.NewSource(config.WithPath(writeConfig(t)))
require.NoError(t, err)
value, ok := src.Lookup("database.port")
require.True(t, ok)
assert.Equal(t, 5432, value)
_, ok = src.Lookup("database.missing")
assert.False(t, ok)
}
func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent.yaml")
src, err := config.NewSource(config.WithPath(missing))
require.NoError(t, err, "a missing configuration file must fall back to environment values")
_, ok := src.Lookup("app.addr")
assert.False(t, ok)
assert.Equal(t, config.EnvOnlyOrigin, src.Describe())
}
func TestSourceRejectsMalformedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("app: [unclosed\n"), 0o600))
_, err := config.NewSource(config.WithPath(path))
require.Error(t, err)
}
func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) {
t.Setenv("WAVELET_SOURCE_PROBE", "present")
src, err := config.NewSource(config.WithPath(writeConfig(t)))
require.NoError(t, err)
value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE")
require.True(t, ok)
assert.Equal(t, "present", value)
_, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT")
assert.False(t, ok)
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd backend && go test ./plugins/infra/config/ -v`
Expected: 编译失败,报 `package Wavelet/plugins/infra/config is not in std` / `undefined: config.NewSource`。
- [ ] **Step 3: 写实现**
创建 `backend/plugins/infra/config/source.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package config adapts viper to the kernel configuration source contract. It is a
// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks, and
// therefore never appears in app.Use. Keeping it out of core preserves the micro-kernel
// rule against importing concrete runtime dependencies.
package config
import (
"errors"
"fmt"
"os"
"github.com/spf13/viper"
)
// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset.
const DefaultFileName = "config.yaml"
// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
const EnvOnlyOrigin = "<env only>"
// maxSearchDepth bounds the upward directory walk so a misconfigured working set
// cannot make the loader scan the whole filesystem.
const maxSearchDepth = 5
// Option configures a Source.
type Option func(*Source)
// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search.
func WithPath(path string) Option {
return func(s *Source) {
s.path = path
}
}
// Source implements core.ConfigSource over a configuration file plus the process environment.
type Source struct {
v *viper.Viper
path string
found bool
}
// NewSource loads the configuration file. A missing file is not an error: the source
// then serves environment values only, matching the previous pkg/config behaviour.
func NewSource(opts ...Option) (*Source, error) {
s := &Source{}
for _, opt := range opts {
opt(s)
}
if s.path == "" {
s.path = os.Getenv("CONFIG_PATH")
}
if s.path == "" {
s.path = findConfigPath(DefaultFileName)
}
v := viper.New()
v.SetConfigFile(s.path)
err := v.ReadInConfig()
switch {
case err == nil:
s.found = true
case isNotFound(err):
// fall through to environment-only lookups
default:
if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search
return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err)
}
}
s.v = v
return s, nil
}
func isNotFound(err error) bool {
var notFound viper.ConfigFileNotFoundError
return errors.As(err, &notFound) || errors.Is(err, os.ErrNotExist)
}
// Lookup returns the raw value stored at a dotted path, or false when the file was not
// loaded or the path is absent.
func (s *Source) Lookup(path string) (any, bool) {
if !s.found || !s.v.IsSet(path) {
return nil, false
}
return s.v.Get(path), true
}
// LookupEnv reads a process environment variable.
func (s *Source) LookupEnv(name string) (string, bool) {
return os.LookupEnv(name)
}
// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values.
func (s *Source) Describe() string {
if !s.found {
return EnvOnlyOrigin
}
return s.path
}
// findConfigPath searches upward from the working directory so tests and binaries run
// from backend/ still find the repository-root configuration file.
func findConfigPath(configPath string) string {
if _, err := os.Stat(configPath); err == nil {
return configPath
}
dir := "."
for range maxSearchDepth {
dir += "/.."
path := dir + "/" + configPath
if _, err := os.Stat(path); err == nil {
return path
}
}
return configPath
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `cd backend && go test ./plugins/infra/config/ -v`
Expected: 四个用例全部 `--- PASS`。
- [ ] **Step 5: 格式与静态检查**
Run: `cd backend && golangci-lint fmt ./plugins/infra/config/ && golangci-lint run ./plugins/infra/config/`
Expected: 无告警。
- [ ] **Step 6: 提交**
```bash
git add backend/plugins/infra/config/
git commit -m "feat(infra): add viper backed configuration source adapter"
```
---
## Task 8: 旧配置包可重入重构 + 新旧对拍
**Files:**
- Modify: `backend/pkg/config/config.go:57-108`
- Create: `backend/pkg/config/parity_test.go`(临时文件,P4 随旧包一并删除)
- [ ] **Step 1: 重构旧加载器为可重入函数**
把 `backend/pkg/config/config.go` 的 `init()` 拆成 `load` + `init`。原实现使用包级 `viper` 全局并在 `init` 里内联全部步骤,对拍需要能反复调用且不受 `isTest()` 干扰:
```go
// load reads configuration from configPath, applies defaults and environment overrides,
// and optionally disables external services for in-test runs.
func load(configPath string, testMode bool) *configModel {
v := viper.New()
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
log.Fatalf("[Config] read config failed: %v\n", err)
}
}
log.Println("[Config] no config file found, using environment variables only")
v.SetConfigType("yaml")
if err := v.ReadConfig(strings.NewReader("")); err != nil {
log.Fatalf("[Config] failed to init empty config: %v\n", err)
}
}
var c configModel
if err := v.Unmarshal(&c); err != nil {
log.Fatalf("[Config] parse config failed: %v\n", err)
}
applyDefaults(&c)
applyEnvOverrides(&c)
applyDefaults(&c)
if testMode {
c.Database.Enabled = false
c.Database.SQLitePath = ":memory:"
c.Redis.Enabled = false
c.ClickHouse.Enabled = false
}
return &c
}
func init() {
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = findConfigPath("config.yaml")
}
Config = load(configPath, isTest())
printConfig(Config)
}
```
同步调整:`import` 增加 `"errors"`;删除原 `init` 里的 `viper.SetConfigFile`/`viper.AutomaticEnv`/`viper.ReadInConfig` 等包级调用与 `if _, ok := err.(viper.ConfigFileNotFoundError); !ok` 断言(改用上面的 `errors.As`)。
> **行为等价说明(评审时核对)**:`viper.AutomaticEnv()` 只影响按键读取,`Unmarshal` 走的是 `AllKeys`,因此去掉 `AutomaticEnv` 不改变解析结果;环境变量覆盖仍由 `applyEnvOverrides` 负责。
- [ ] **Step 2: 运行旧测试确认无回归**
Run: `cd backend && go test ./pkg/config/ ./cmd/ -v -run 'TestApplyEnvOverrides|Test' 2>&1 | tail -30`
Expected: `pkg/config` 的 `TestApplyEnvOverridesRedisMaintNotifications` 通过;`cmd` 包既有用例结果与改动前一致(先运行一次改动前的 `go test ./cmd/` 记录基线)。
- [ ] **Step 3: 写对拍测试**
创建 `backend/pkg/config/parity_test.go`:
```go
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Temporary migration harness: proves the new kernel configuration engine resolves
// every key identically to pkg/config before the legacy singleton is deleted in P4.
// Delete this file together with backend/pkg/config.
package config
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/spf13/viper"
"Wavelet/core/extpoints"
)
// yamlSource is a test-local core.ConfigSource over the repository config file.
// It deliberately does not import plugins/infra/config: backend/pkg must not depend on
// upper layers even in tests, and the adapter has its own coverage in its package tests.
type yamlSource struct {
v *viper.Viper
}
func newYAMLSource(t *testing.T, path string) *yamlSource {
t.Helper()
v := viper.New()
v.SetConfigFile(path)
require.NoError(t, v.ReadInConfig())
return &yamlSource{v: v}
}
func (s *yamlSource) Lookup(path string) (any, bool) {
if !s.v.IsSet(path) {
return nil, false
}
return s.v.Get(path), true
}
func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() }
// engineAppConfig mirrors appConfig with engine tags.
type engineAppConfig struct {
AppName string `config:"app_name" env:"APP_NAME"`
Env string `config:"env" env:"APP_ENV"`
Addr string `config:"addr" env:"APP_ADDR"`
NodeID int64 `config:"node_id" env:"APP_NODE_ID"`
APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX"`
GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"`
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"`
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"`
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
}
type engineDatabaseConfig struct {
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"`
Host string `config:"host" env:"DB_HOST"`
Port int `config:"port" env:"DB_PORT"`
Username string `config:"username" env:"DB_USERNAME"`
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
Database string `config:"database" env:"DB_NAME"`
MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"`
MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"`
ConnMaxLifetime int `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME"`
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME"`
LogLevel string `config:"log_level" env:"DB_LOG_LEVEL"`
SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE"`
TimeZone string `config:"time_zone" env:"DB_TIMEZONE"`
ApplicationName string `config:"application_name" env:"DB_APPLICATION_NAME"`
SearchPath string `config:"search_path" env:"DB_SEARCH_PATH"`
PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"`
StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"`
DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"`
Replicas []engineReplicaConfig `config:"replicas"`
SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD"`
}
type engineRedisConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
Username string `config:"username" env:"REDIS_USERNAME"`
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
DB int `config:"db" env:"REDIS_DB"`
ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
DialTimeout int `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
ReadTimeout int `config:"read_timeout" env:"REDIS_READ_TIMEOUT"`
WriteTimeout int `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"`
MaxRetries int `config:"max_retries" env:"REDIS_MAX_RETRIES"`
PoolTimeout int `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"`
ConnMaxIdleTime int `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"`
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"`
}
type engineClickHouseConfig struct {
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"`
Username string `config:"username" env:"CLICKHOUSE_USERNAME"`
Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"`
Database string `config:"database" env:"CLICKHOUSE_NAME"`
MaxIdleConn int `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN"`
MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN"`
ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME"`
DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT"`
BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE"`
}
type engineLogConfig struct {
Level string `config:"level" env:"LOG_LEVEL"`
Format string `config:"format" env:"LOG_FORMAT"`
Output string `config:"output" env:"LOG_OUTPUT"`
FilePath string `config:"file_path" env:"LOG_FILE_PATH"`
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE"`
MaxAge int `config:"max_age" env:"LOG_MAX_AGE"`
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS"`
Compress bool `config:"compress" env:"LOG_COMPRESS"`
}
type engineOtelConfig struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
}
// engineReplicaConfig mirrors databaseReplicaConfig, a composite element of database.replicas.
type engineReplicaConfig struct {
Host string `config:"host"`
Port int `config:"port"`
Username string `config:"username"`
Password string `config:"password"`
}
// engineQueueConfig and engineWorkerConfig mirror the worker section, whose defaults
// legitimately move to driver_asynq_worker in P3; the repository file declares them
// explicitly, so parity is unaffected.
type engineQueueConfig struct {
Name string `config:"name"`
Priority int `config:"priority"`
}
type engineWorkerConfig struct {
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"`
Queues []engineQueueConfig `config:"queues"`
}
func repositoryConfig(t *testing.T) string {
t.Helper()
path := filepath.Join("..", "..", "config.yaml")
if _, statErr := os.Stat(path); statErr != nil {
t.Skip("repository root config.yaml is unavailable")
}
return path
}
// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the
// parity assertion use distinct Go types for the same shape, so values are compared
// textually instead of handing cmp a cross-type diff.
func flatten(prefix string, v reflect.Value, out map[string]string) {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
fv := v.Field(i)
path := prefix + "." + field.Name
if fv.Kind() == reflect.Struct && fv.Type() != durationType {
flatten(path, fv, out)
continue
}
out[path] = fmt.Sprint(fv.Interface())
}
}
// durationType mirrors the engine's own notion of a scalar duration field.
var durationType = reflect.TypeFor[time.Duration]()
func TestEngineParityWithLegacyLoader(t *testing.T) {
path := repositoryConfig(t)
scenarios := []struct {
name string
env map[string]string
}{
{name: "file only", env: nil},
{
name: "implicit enable from hosts",
env: map[string]string{
"DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000",
},
},
{
name: "explicit flags win over implicit enable",
env: map[string]string{
"DB_HOST": "postgres", "DB_ENABLED": "false",
"REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false",
"CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false",
},
},
{
name: "scalar overrides and duration parsing",
env: map[string]string{
"LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_SLOW_THRESHOLD": "1s",
"REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for name, value := range scenario.env {
t.Setenv(name, value)
}
legacy := load(path, false)
src := newYAMLSource(t, path)
engine := extpoints.NewConfigRegistry(src)
require.NoError(t, engine.Declare("parity",
extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}},
extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}},
extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}},
extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}},
extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}},
extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}},
extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}},
))
require.NoError(t, engine.Resolve())
var app engineAppConfig
var database engineDatabaseConfig
var redis engineRedisConfig
var clickhouse engineClickHouseConfig
var log engineLogConfig
var otel engineOtelConfig
var worker engineWorkerConfig
for _, binding := range []struct {
prefix string
target any
}{
{"app", &app}, {"database", &database}, {"redis", &redis},
{"clickhouse", &clickhouse}, {"log", &log}, {"otel", &otel}, {"worker", &worker},
} {
require.NoError(t, engine.Bind(binding.prefix, binding.target))
}
// The legacy loader keeps two code-level defaults outside its tags; the engine
// expresses them as declared defaults, so normalise before diffing (spec C1).
if legacy.App.SessionAge <= 0 {
legacy.App.SessionAge = 86400
}
if legacy.Otel.TracerName == "" {
legacy.Otel.TracerName = "github.com/Rain-kl/Wavelet"
}
legacyFlat := map[string]string{}
flatten("app", reflect.ValueOf(legacy.App), legacyFlat)
flatten("database", reflect.ValueOf(legacy.Database), legacyFlat)
flatten("redis", reflect.ValueOf(legacy.Redis), legacyFlat)
flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), legacyFlat)
flatten("log", reflect.ValueOf(legacy.Log), legacyFlat)
flatten("otel", reflect.ValueOf(legacy.Otel), legacyFlat)
flatten("worker", reflect.ValueOf(legacy.Worker), legacyFlat)
engineFlat := map[string]string{}
flatten("app", reflect.ValueOf(app), engineFlat)
flatten("database", reflect.ValueOf(database), engineFlat)
flatten("redis", reflect.ValueOf(redis), engineFlat)
flatten("clickhouse", reflect.ValueOf(clickhouse), engineFlat)
flatten("log", reflect.ValueOf(log), engineFlat)
flatten("otel", reflect.ValueOf(otel), engineFlat)
flatten("worker", reflect.ValueOf(worker), engineFlat)
assert.Empty(t, cmp.Diff(legacyFlat, engineFlat), "engine resolution drifted from legacy loader")
})
}
}
```
测试文件的 import 块必须包含:`fmt`、`os`、`path/filepath`、`reflect`、`testing`、`time`、`github.com/google/go-cmp/cmp`、`github.com/spf13/viper`、`github.com/stretchr/testify/assert`、`github.com/stretchr/testify/require`、`Wavelet/core/extpoints`。
- [ ] **Step 4: 运行对拍确认等价**
Run: `cd backend && go test ./pkg/config/ -run TestEngineParityWithLegacyLoader -v`
Expected: 四个场景全部 `--- PASS`,无任何 `drifted` 断言输出。若出现 drift,逐项核对是否为 spec §4.3 登记的 C1–C5 有意差异:是则在该场景补注释说明差异来源,否则按 drift 修复引擎。
- [ ] **Step 5: 确认既有测试与构建仍全绿**
Run: `cd backend && go test ./... && go build -o /dev/null ./...`
Expected: 全绿。
- [ ] **Step 6: 提交**
```bash
git add backend/pkg/config/
git commit -m "refactor(config): make legacy loader reentrant and add engine parity test"
```
---
## Task 9: 架构门禁脚本禁止 core 引入 viper
**Files:**
- Modify: `scripts/check_cordis_architecture.sh:57-65`
- [ ] **Step 1: 扩展检查项**
把 `CORE_FRAMEWORK_IMPORTS` 的匹配式加入 viper、mapstructure 与 gin 的兄弟框架,使配置装载依赖无法渗进内核:
```bash
CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron|"github.com/spf13/viper"|"github.com/mitchellh/mapstructure"' \
"${BACKEND_DIR}/core/" --glob '*.go' -g '!*contracts*' -g '!*_test.go' || true)
```
同步更新失败提示文案,列出新增的两个包:
```bash
log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):"
```
- [ ] **Step 2: 运行脚本确认通过**
Run: `./scripts/check_cordis_architecture.sh`
Expected: `✓ backend/core/ 无重型框架依赖`,末行 `✓ 所有 Cordis 架构合规性检查全部通过 (0 Violations)!`,退出码 0。
- [ ] **Step 3: 反向验证检查有效性**
Run: `printf 'package core\n\nimport _ "github.com/spf13/viper"\n' > backend/core/zz_probe_tmp.go && ./scripts/check_cordis_architecture.sh; STATUS=$?; rm backend/core/zz_probe_tmp.go; exit $STATUS`
Expected: 报 `✗ [FAIL] backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架`,退出码非 0。(临时文件必须删除,用后确认 `git status --short` 干净。)
- [ ] **Step 4: 提交**
```bash
git add scripts/check_cordis_architecture.sh
git commit -m "chore(arch): forbid viper and mapstructure inside the micro-kernel"
```
---
## Task 10: 收尾验证与文档同步
**Files:**
- Modify: `AGENTS.md`(Cordis 分层清单补配置扩展点条目)
- Modify: `docs/WAVELET_WHITE_PAPER.md`(§5.1 顶级分包说明)
- [ ] **Step 1: 写内核侧用法文档**
在 `AGENTS.md` 的“严格遵循事项 (Guardrails)”中,`扩展点自包含注册` 列表内 `动态配置` 一条之后补充:
```markdown
- **静态配置声明**:插件在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取自己声明的配置(字段用 `config` / `env` / `default` / `autoEnable` / `secret` tag 声明);需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明。**严禁**新增全局配置单例或在 `backend/pkg/` 读取配置。
```
- [ ] **Step 2: 修正白皮书漂移**
在 `docs/WAVELET_WHITE_PAPER.md` §5.1 的顶级分包说明后追加一条:
```markdown
- **配置所有权下沉**:`backend/pkg/config` 全局单例已废除。`core/extpoints` 只提供配置声明与解析引擎(不 import viper),`plugins/infra/config` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。
```
- [ ] **Step 3: 全量门禁**
Run: `make code-check`
Expected: 架构脚本 0 Violations;`golangci-lint run` 无输出;前端 tsc 与 eslint 无新增错误(本计划未触碰前端,若报既有错误需确认为基线问题)。
- [ ] **Step 4: 格式化**
Run: `make format`
Expected: gofumpt 无 diff 或自动格式化;`git status --short` 中出现的文件需一并纳入下一步。
- [ ] **Step 5: 实跑确认应用行为未变**
Run: `cd backend && go run main.go all 2>&1 | head -40`
Expected: banner 正常输出、迁移日志正常、`[Config] loaded configuration` 出现,进程正常启动后 Ctrl-C 优雅退出。**这是 P1/P2 的验收线:新能力已就位,生产路径仍走旧单例,行为必须与改动前一致。**
- [ ] **Step 6: 提交**
```bash
git add AGENTS.md docs/WAVELET_WHITE_PAPER.md
git commit -m "docs(config): record the configuration extension point ownership rules"
```
---
## 完成标准(本计划)
1. `cd backend && go test ./...` 与 `go build ./...` 全绿;`make code-check` 与 `./scripts/check_cordis_architecture.sh` 零违规。
2. 引擎单测覆盖:优先级四档、`autoEnable` 与显式 env 的相对优先、标量 env→切片、`time.Duration`、结构体切片、冲突校验、脱敏导出、无 source 时的错误路径。
3. `TestEngineParityWithLegacyLoader` 四个场景零 drift,证明除 spec §4.3 的 C1–C5 外解析结果与旧实现逐 key 等价。
4. 同时挂载门禁谓词相反的两个插件时,恰好一个 `FiberActive`、一个 `FiberSkipped`,且被跳过者 `Apply` 从未执行。
5. `core/` 不出现 viper/mapstructure import,且架构脚本能主动拦截该违规。
6. 生产启动路径行为未变(仍由 `config.Config` 供值),业务插件与 `cmd` 零改动。
---
## 与 spec 的偏差(实施时须回写 spec)
计划编写阶段的自审发现三处与本 spec 已批准版本不一致的实现,均属实现期发现的正确性问题。在 Task 10 落库时一并回写 `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md`:
| # | spec 原述 | 本计划实现 | 理由 |
| :--- | :--- | :--- | :--- |
| S1 | §4.3 C1 与 §6 把 `app.session_age<=0` 的判定列为内核解析错误(`ErrConfigInvalid`) | 引擎不做值域校验;`ErrConfigInvalid` 保留为源级校验占位,值域由声明者在 `Bind` 之后自行校验(auth 校验 `SessionAge > 0` 并使 `Apply` 失败) | 引擎被设计成不认识任何业务 key 的语义,让它知道"session_age 必须为正"会破坏该不变式并把业务规则塞进内核 |
| S2 | §3.2 `ConfigView` 含 `Source(key) string` | 更名 `Origin(key)`,并新增 `Value(key) (any, bool)`;`ConfigExtension` 新增 `SetSource(src)` 与 `Resolved()` | `Source` 与类型名 `ConfigSource` 在同文件内易混淆;`Value` 是 `core.ConfigGet[T]` 的支撑(Go 方法不能带类型参数);`SetSource` 进接口以避免 `WithConfigSource` 里的运行时类型断言 |
| S3 | §3.5 只给出 `WithShutdownTimeout` 与 `app.Prepare()` | 额外新增 `App.ShutdownTimeout()` 读取器与 `SetShutdownTimeout(d) *App`(链式,与既有 `WithProfile` 风格一致) | 组合根需要在 `Prepare()` 之后把已解析的预算写回内核,原先只有构造期选项,无法表达该顺序 |
回写时同步修正 §7.3 的分期编号:本计划覆盖 P1 + P2,P3 + P4 由后续计划承接(`pkg/idgen` 解耦、27 个文件迁移、删除 `backend/pkg/config`)。