refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口

## 用户裁定(本次唯一方向)

1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」

## 实测起点

    537 个测试文件 / 377 个源码文件 / 2853 条 Test
    523 / 537(97%)直接调用私有函数
    514 个是 in-package 白盒(package xxx,与源码同目录)
     88 个读 .go/.md 源码做断言
     94 个断言私有字段(f.Step / r.buf / a.sched …)
     38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
    internal/agent/core: 140 test / 52 src

## 做了什么

### ① 删除 514 个 in-package 白盒测试

不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。

用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:

    internal/agent/core/child/test/     Registry 的 Add/Get/Stop/Remove 输出
    internal/agent/core/test/           StageHost 的 Register/Run 输出
    internal/plugins/files/test/        工具 handler 的 {path} → {content/error}
    internal/plugins/webui/test/        HTTP 请求 → 响应(NewHandler/RegisterRoutes)
    internal/tfidf/test/                文本 → 向量/检索结果

现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。

### ② 回退「为测试而导出」的接口

这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:

    webui.FrontendAsset / DashboardPage        删除
    webui.GraphDataForVisual → graphDataForVisual
    webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
    webui.NewSSEEventRing                       删除
    kstate.MountForTest                         删除
    security.ClassifyPrincipalForTest           删除

生产代码对它们的内部调用同步改回私有名,不改任何行为。

### ③ CI 一鼓作气改到位(用户指定顺序)

**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。

**加两道构建期静态门**(原先这些约束错放在 go test 里):

- `测试归拢(源码目录不得有 _test.go)`
  `find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
  非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
  用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
  这是**工具链**的职责,不该由 `go test` 兼职。

`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。

### ④ 顺带(上一批未提交的)

`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。

## 验证

    go build ./...                     PASS
    go vet ./...                       PASS
    gofmt -l                           干净
    python3 .github/scripts/check-ci-gates.py   PASS
    go test ./... -count=1             5 个测试包全过 / 100 个包无测试 / 0 FAIL
    源码目录 _test.go 数              0
    测试文件数                         9(全部在 <module>/test/)

## 后续

按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
This commit is contained in:
JianFeeeee
2026-11-09 11:13:56 +08:00
parent eac8e808f1
commit 53cdc7f2a6
539 changed files with 158 additions and 116960 deletions

View File

@ -1,924 +0,0 @@
package all
// 「真实登记表 → 整机启动图」的判据(2026-10-24)。
//
// ## 迁移记录(ID:13,2026-10-24)
//
// 本文件原先挂在 `internal/kernelboot/modules/registry_test.go`,那个包
// **零生产调用**(死包:Register/BuildModules 是 kernelmod 那套的**第二份
// 实现**,外部调用点 = 0),只为了安放这组判据才留着。
//
// 现迁移到**真实聚合入口** `internal/kernelmod/all`:本包本来就是
// 「把全部模块 import 进来」的那一层,判据放在这里,用的就是生产路径
// 同一条 import 链(all → 各模块 init → kernelmod.Register)。
// 死包与其第二份注册表实现已删除。
//
// ★ 迁移没有削弱任何判据的能力:下面判据 5 的真相源(扫 `..` 源码树的
// 类型大小)与宿主包无关,换宿主前后都照样咬得住「登记了但漏 import」。
// 变异亲测见本文件「## 失败面」一节里标题后注「逐条实测过」的那一处
// (不是 TestEveryRegisteredModuleIsCompiledIn 上方注「已实测咬红」的那一处)
// 第 1–3 条。★ 按**该节的区分词**找,不按行号/位置找:本文件有**两处**失败面
// 小节,且章节位置会随重构漂移。
//
// ## 这个文件补的是什么(此前完全没有的覆盖)
//
// `internal/kernelboot` 自身的判据很密(图编解码、分层、调度、迁移回滚、
// 并发压测),但**全部用假模块**(`fakeModule`)在图上验算法。
// 也就是说:分层算法被验过,而「**真实的** 17 个模块按真实登记表装配成
// 一台整机、逐层起来、再严格逆序关掉」这件事**一条用例都没有**。
//
// 这中间藏着一整类失败:算法对、单模块对,但**装配起来**不对 ——
// 漏挂一个模块(依赖图缺设施)、某层的关闭函数没登记(漏关)、
// 关闭顺序反了(先关被依赖者,后关依赖者)。
//
// ## 为什么判据必须用真实登记表,而不能造一份"期望清单"
//
// 造假模块:任何"漏挂"都测不出来(假模块之间自洽)。
// 抄一份期望模块名清单:清单在「新增模块但忘了 import」时**也跟着漏**
// ⇒ 判据照绿(本仓已有先例:TestEveryModuleDirIsImported 的初版就栽在
// 手写清单上)。
//
// ⇒ 判据 1–4 用 `kernelmod.Registered()` 作为装配输入,断言的对象是
// **装配结果**(图 + 就绪状态 + 关闭顺序 + 资源回收),而不是某份清单。
//
// ## ★★ 真相源不能是要检测对象的入口(本文件最重要的一条设计约束)
//
// 判据 5(`TestEveryRegisteredModuleIsCompiledIn`)**不能**扫
// `kernelmod.Register(...)` 调用点作为真相源,因为"模块静默消失"有两种形态:
//
// a. 四方法写全、init() 里调了 Register,只差 all/all.go 的聚合 import
// ⇒ Register 调用点仍在源码里 ⇒ 扫调用点**抓得住**
// b. 四方法写全,但 init() 根本没写(或写了又被删)
// ⇒ Register 调用点不存在 ⇒ **要检测的入口自己消失了**
// ⇒ 源码侧与登记表侧同时少一个 ⇒ 集合比对照常相等 ⇒ **假绿**
//
// b 才是"静默消失"最危险的形态:编译通过、四方法齐全、只是没人注册。
// 把「被测对象」同时当「检测入口」,入口一被破坏,判据就**失去视野**——
// 这与本仓此前几次判据假绿同病(真相源与被测对象同源)。
//
// ⇒ 判据 5 的真相源换成与 Register 无关的**接口形状**:
// 「目录 + 类型名」复合键 + 「Name/Requires/Provides/Init 四方法齐全」。
// 模块哪怕不 Register、不被 import,四方法仍写在源码里 ⇒ 一定扫得到。
//
// · 为什么必须带**目录**:本仓大量类型都叫 `Module`,只按类型名做键会撞成
// 一堆(实测撞完只剩 2 个)
// · 为什么必须**四方法集**而不是"有个 Name()":只有 Name 的类型到处都是,
// 四个方法齐才是 `kernelboot.Module` 的形状
// · 为什么取 Name() 的**字符串字面量**:非字面量会让该模块从视野消失,
// 故显式报错**而不静默跳过**(跳过 = 又一次假绿)
//
// ★ 下次谁要改这条判据,先回答:**把它的真相源破坏掉,它还会绿吗?**
// 若会绿,说明真相源选在了被测对象身上,必须换(见上面 b)。
//
// ## 失败面(**逐条实测过**,2026-10-24;不是注释声称)
//
// 记法:⇒ 后面写「谁红 + 红在哪一句」。凡实测与预期不符的,照实写实际形状。
//
// 1. 删掉**有下游依赖**的模块的 `func init() { kernelmod.Register(...) }`
// (实测对象:tracker)
// ⇒ 判据 1/2/3/4 红,红在「真实登记表算不出启动图:依赖了不存在的设施
// [tracker(agent 需要)…]、等待环 [agent plugin-host]」。
// ★ 但**判据 5 也红** —— 见第 2 条,两条互为冗余。
//
// 2. 从 `internal/kernelmod/all/all.go` 去掉任一 `_ ".../kernelmod/xxx"` 行
// ⇒ 实测同上(模块根本没被编译进来)。
//
// 3. ★ 删掉**无下游依赖的叶子模块**的 init()(实测对象:multimodal)
// ⇒ **只有判据 5 红**,判据 1–4 全绿。
// 为什么 1–4 咬不住:没人依赖它 ⇒ 图能解、层顺序对、哨兵数对、无残留。
// 这正是判据 5 存在的唯一理由(初版判据在此**假绿**,见其函数注释)。
//
// 4. 把 Run 的层执行顺序改成逆序
// ⇒ 判据 1/2/3/4 红,但**红在装配失败**(第 4 层模块拿到 nil 依赖:
// 「agent 依赖的设施 “memory” 对应的数据为 nil」),
// 不是「层间顺序断言」咬的 —— 装配先于断言就炸了。
// ⇒ 把某一层整个跳过:**内核 panic**(llm.buildProviderManager 拿到
// 0x0 的 LuaVM,SIGSEGV)。仍是红的,但形态是崩溃不是可读断言。
// ★ 以上是 **ID:14 之前**的形态(2026-10-24 实测记录,保留下来
// 作为“为什么需要执行期校验”的原始证据)。
//
// ✅ **已完成(ID:14,提交 e70c2b5)**:kernelboot.Run 现在在**每层 Init
// 之前**先校“本层依赖 ⊆ 此前真跑完的层实际产出的设施”,缺就返回
// *DependencyNotReadyError(指名模块名 + 缺哪些设施),**本层一个
// Init 都不调**。用真实登记表实测,同一个变异变成可读报错:
// · 逆序 ⇒ 第 **0** 层就拒(produced 为空):
// 「模块 agent 缺设施 [docmem knowledge llm …];
// 模块 plugin-host 缺设施 [config docmem …]」
// · 跳过第 2 层 ⇒ 第 **2** 层拒:
// 「模块 stage-host 缺设施 [knowledge llm textmem]」
// **两种都不再是 panic / SIGSEGV**。
//
// ⇒ 下次看到本条,请不要据此认为“Run 不拦 nil 依赖”;它现在拦。
// 另:该行为的专用回归判据在 internal/kernelmod/all/runtime_dep_test.go
// (A 正序不误报 / B 交换两层 / C 删提供者整层)与
// internal/kernelboot/runtimedep_test.go(纯逻辑四形状 + 防预填自检)——
// 那两组比在本节做整机变异快得多,改代码时优先跑它们。
//
// 5. 某层不再登记关闭函数(模块忘调 AddCloser / 层边界钩子丢了)
// ⇒ 判据 3 红(哨兵数量不符)。与第 7 条同支路,实测由第 7 条覆盖。
//
// 6. 关闭改成与启动同序(正序)
// ⇒ 判据 3 红:实测「有 5 层,但只观测到 0 个…」/「关闭顺序不是严格逆序」。
//
// 7. 关闭函数不真正执行(CloseAll 变 no-op,等价于「漏 Stop/Close」)
// ⇒ 判据 3 红(哨兵 0 个)+ **判据 4 红**(「第 1 轮关闭后仍多 3 个
// goroutine(基线 11,现 14,允许抖动 2)」)。两条各自独立咬住。
//
// ## 为什么这些断言不需要"扫描源码"或"反射读模块内部"
//
// 全部断言都落在**装配产物**上:分层计划(Layers)、syncbus 的就绪顺序、
// 关闭函数的实际执行顺序、进程级 goroutine 数与文件系统状态。
// 源码怎么组织都无所谓 —— 只认"装配起来之后发生了什么"。
import (
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"testing"
"time"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
// ★ 判据与被测对象现在同属一个包(internal/kernelmod/all):
// 本文件就在聚合 import 的**那一层**里。因此不需要(也不能)再
// 空白 import 自己 —— 只要本包被编译,all.go 的聚合 import 就已生效。
// 这正是迁移的收益之一:判据用的是生产同一条 import 链,而非旁路。
)
// ─────────────────────────── 整机夹具 ───────────────────────────
// machine 是一台**真实装配起来**的内核(全部模块都是 kernelmod 里的真货)。
type machine struct {
t *testing.T
ks *kstate.State
bus *kstate.SyncBus
// plan 是真实登记表算出的分层计划(启动图)。
plan *kernelboot.Plan
// res 是执行结果(层数 / 峰值并发 / 完成顺序)。
res *kernelboot.RunResult
// dataDir 是本机的数据目录(真实文件系统,不是内存桩)。
dataDir string
// layerClosers 记录"每层边界登记的关闭函数"被调用的层号顺序。
// 这是"严格逆序关闭"的**观测点**:层边界处 AddCloser 一个哨兵,
// 它必然排在该层所有模块的关闭函数**之后**(OnLayerDone 在层内 Init
// 全部完成后才跑),所以哨兵的调用顺序 = 层的关闭顺序。
layerClosers []int
closed bool
}
// layerSentinelFacilityPrefix 用于在断言消息里区分"哨兵"与"真模块"。
const shutdownProbeName = "boot-graph-test/layer-probe"
// bootMachine 用真实登记表装配一台整机。
//
// 参数 httpAddr 走 127.0.0.1:0(内核会把它传给 webui 插件):
// 不占固定端口 ⇒ 并发跑 CI/本地多次不会 "bind: address already in use"
// (实测:写死默认 :8080 时本机起两次就红)。
func bootMachine(t *testing.T) *machine {
t.Helper()
dir := t.TempDir()
// 只建**真实启动会建**的目录(与 cmd/homed 的 ensureDataDirs 对齐)。
for _, d := range []string{
"memory", "memory/text", "memory/documents", "knowledge",
"adapters", "agentfs", "plugins",
} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatalf("建数据目录: %v", err)
}
}
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
// ★ 注册表不在这里 t.Cleanup 关闭:本判据会在**同一个测试**里反复启停
// 多轮,t.Cleanup 要到测试结束才跑 ⇒ 每轮都留一个 *sql.DB 的
// connectionOpener goroutine 活着 ⇒ 泄漏判据会被自家夹具污染
// (实测:3 轮后 goroutine 数 2→5,逐轮 +1,全部来自 database/sql)。
// 关闭点放到 shutdown()(与生产的停机序列同处),并由 machine 保证只关一次。
ks := kstate.New()
ks.CfgReg = reg
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.Cfg = reg.ToConfig()
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.HTTPAddr = "127.0.0.1:0"
mods := kernelmod.Registered()
if len(mods) == 0 {
t.Fatal("★ 真实登记表是空的 —— `internal/kernelmod/all` 的聚合 import 断了")
}
if d := kernelmod.Duplicates(); len(d) > 0 {
t.Fatalf("★ 模块名重复注册: %v", d)
}
plan, err := kernelboot.Build(mods)
if err != nil {
t.Fatalf("★ 真实登记表算不出启动图(漏挂模块 / 缺依赖 / 环): %v", err)
}
bus := kstate.NewSyncBus()
// 与生产 RunKernelModules 同序:先 MarkDeclared(让拼错校验有真相源),
// 再 Run(Run 内部还会 MarkDeclared,幂等)。
var declared []string
for _, layer := range plan.Layers {
for _, m := range layer {
declared = append(declared, m.Provides()...)
}
}
bus.MarkDeclared(declared)
m := &machine{t: t, ks: ks, bus: bus, plan: plan, dataDir: dir}
res, err := kernelboot.Run(plan, kernelboot.RunOptions{
State: ks,
Bus: bus,
MaxParallel: 4, // 与生产一致(cmd/homed 的 RunKernelModules)
OnLayerDone: func(idx int, _ []kernelboot.Module) {
// ★ 每层一个关闭哨兵:它登记在**该层所有模块之后**,
// 因此 CloseAll 的 LIFO 语义要求哨兵按层号**严格降序**被调用。
layer := idx
ks.AddCloser(func() { m.layerClosers = append(m.layerClosers, layer) })
},
})
if err != nil {
t.Fatalf("★ 整机装配失败: %v", err)
}
m.res = res
// ★ 停机走**生产路径**(cmd/homed 的 waitForShutdown 顺序),
// 而不是只调 CloseAll:漏关某层往往正是"生产路径少了一步"。
t.Cleanup(func() {
if !m.closed {
m.shutdown()
}
})
return m
}
// shutdown 执行与生产 `waitForShutdown` 一致的停机序列。
func (m *machine) shutdown() {
m.closed = true
if reg := m.ks.PluginRegistry; reg != nil {
reg.StopAll()
}
if trk := m.ks.Tracker; trk != nil {
if err := trk.Stop(); err != nil {
m.t.Logf("tracker.Stop: %v", err)
}
}
if m.ks.CfgReg != nil {
_ = m.ks.CfgReg.Flush()
// 生产里进程随即退出、句柄由内核回收;测试里必须显式关,
// 否则 *sql.DB 的 connectionOpener 会一直活着(见 bootMachine 注释)。
_ = m.ks.CfgReg.Close()
}
if sup := m.ks.Supervisor; sup != nil {
sup.Shutdown()
}
if err := m.ks.CloseAll(); err != nil {
m.t.Logf("CloseAll: %v", err)
}
}
// moduleNames 返回计划里全部模块名(跨层)。
func (m *machine) moduleNames() []string {
var out []string
for _, layer := range m.plan.Layers {
for _, mod := range layer {
out = append(out, mod.Name())
}
}
sort.Strings(out)
return out
}
// ─────────────────────── 判据 1:整机全部模块就位 ───────────────────────
// ★★★ 判据:**真实登记表里的每个模块都进了图,且其设施全部就绪**。
//
// 防的失败方式:「模块建了但没挂上」—— 新建 `internal/kernelmod/foo/mod.go`
// 写了类型和 `init()`,但忘了在 `all/all.go` 加聚合 import,或忘了写
// `init()` 里的 Register。后果不是编译错,而是该模块**静默不参与装配**:
// 它提供的设施永远不就绪,依赖它的插件**永不启动**(且无任何报错)。
//
// ★ 本判据的**能力边界(实测)**:只有「漏挂的模块被别人依赖」时才红。
//
// 漏掉的是**叶子模块**(没人 Requires 它,如 multimodal)时,
// 本判据连同判据 2/3/4 **全部照绿** —— 图照样能解、层照样对、
// 哨兵照样齐、也没东西可残留。
// 那种形态由判据 5(TestEveryRegisteredModuleIsCompiledIn)单独咬住,
// 它的真相源是源码里的类型与方法集,不依赖 Register 调用点。
// ⇒ 别把本判据当成「漏挂」的通用防线,两判据是互补关系,不是冗余。
func TestRealRegistryAssemblesWholeMachine(t *testing.T) {
m := bootMachine(t)
registered := map[string]bool{}
for _, name := range kernelmod.Names() {
registered[name] = true
}
inPlan := map[string]bool{}
for _, name := range m.moduleNames() {
if inPlan[name] {
t.Fatalf("★ 模块 %s 在启动图里出现多次(分层去重失败)", name)
}
inPlan[name] = true
}
// ① 双向集合相等:登记了却没进图 / 进了图却不在登记表,都是缺陷。
var missingInPlan, missingInRegistry []string
for n := range registered {
if !inPlan[n] {
missingInPlan = append(missingInPlan, n)
}
}
for n := range inPlan {
if !registered[n] {
missingInRegistry = append(missingInRegistry, n)
}
}
sort.Strings(missingInPlan)
sort.Strings(missingInRegistry)
if len(missingInPlan) > 0 {
t.Fatalf("★ 已登记但没进启动图的模块: %v —— 模块静默消失,"+
"它提供的设施永不就绪(依赖它的插件会永不启动且无报错)", missingInPlan)
}
if len(missingInRegistry) > 0 {
t.Fatalf("★ 在启动图里但不在登记表的模块: %v", missingInRegistry)
}
// ② 每个模块声明的设施都必须真的「已就绪」(不是"登记过",是"就绪")。
// ★ 这两个状态必须分开断言:MarkDeclared 会把全部 Provides 记成
// known("登记过"),所以只查 Known 的话,一个模块哪怕从没跑过
// 也"存在"——恰恰漏掉本判据要抓的失败。
snap := m.bus.Snapshot()
var neverReady []string
for _, layer := range m.plan.Layers {
for _, mod := range layer {
for _, f := range mod.Provides() {
if !snap[f] {
neverReady = append(neverReady, mod.Name()+"→"+f)
}
}
}
}
sort.Strings(neverReady)
if len(neverReady) > 0 {
t.Fatalf("★ 这些设施的提供者跑过了却没被标成就绪: %v\n"+
" 后果:插件声明依赖它会**永远等不到**(静默不启动)", neverReady)
}
// ③ 全部层都执行完,且内核完成信号已发(未声明依赖的插件的启动信号)。
if m.res.LayerCount != len(m.plan.Layers) {
t.Fatalf("★ 只执行了 %d/%d 层", m.res.LayerCount, len(m.plan.Layers))
}
if !m.bus.KernelComplete() {
t.Fatal("★ 全部层跑完却没有 MarkKernelComplete —— 未声明依赖的插件永不拉起")
}
// ④ 每个模块的 Init 都被调过:就绪顺序里应包含全部 Provides,
// 且不得出现"从未被任何模块声明"的野设施。
readySet := map[string]bool{}
for _, f := range m.bus.ReadyInOrder() {
readySet[f] = true
}
for _, layer := range m.plan.Layers {
for _, mod := range layer {
for _, f := range mod.Provides() {
if !readySet[f] {
t.Errorf("★ 设施 %s(%s)不在 ReadyInOrder 里", f, mod.Name())
}
}
}
}
t.Logf("整机就绪:%d 层 / %d 模块,峰值层内并发 %d,就绪设施 %d 个",
m.res.LayerCount, len(m.moduleNames()), m.res.MaxConcurrent, len(readySet))
}
// ─────────────────── 判据 2:层间顺序严格符合依赖拓扑 ───────────────────
// ★★★ 判据:**层间必须严格有序,层内两两无依赖**。
//
// 防的失败方式:
// - 执行顺序被改(正序→逆序、或某层被提前/推迟)⇒ 消费者先于提供者
// - 分层退化成"一层装完"(并行度归零,等价于回到串行 17 步)
// - 有人把模块塞进错误的层(图被手改 / 层号漂移)
func TestLayerTopologyIsRespected(t *testing.T) {
m := bootMachine(t)
// ① 逐层检查:本层每个模块的 Requires 只能由**更早的层**提供。
// 这就是 BFS 分层的定义(第 k 层的前驱全在 <k 层)。
providedByLayer := map[string]int{} // 设施 -> 首次被提供的层号
var violations []string
for li, layer := range m.plan.Layers {
// 先检查(用"更早层"的累积结果),再登记本层提供的设施。
for _, mod := range layer {
for _, req := range mod.Requires() {
got, ok := providedByLayer[req]
if !ok {
violations = append(violations,
mod.Name()+" 依赖 "+req+",但它的提供者在**更晚**的层(或不存在)")
continue
}
if got >= li {
violations = append(violations,
mod.Name()+"(第"+itoa(li)+"层)依赖 "+req+
"(第"+itoa(got)+"层)—— 层间顺序必须严格")
}
}
}
for _, mod := range layer {
for _, f := range mod.Provides() {
if _, seen := providedByLayer[f]; !seen {
providedByLayer[f] = li
}
}
}
}
if len(violations) > 0 {
sort.Strings(violations)
t.Fatalf("★ 层间顺序错了:\n %s", strings.Join(violations, "\n "))
}
// ② 层内两两无依赖(否则它们不该同层:其中一个必然在更晚的层)。
// 这是"同层可并行的前提",算法若坏了这条会先红。
for li, layer := range m.plan.Layers {
own := map[string]bool{}
for _, mod := range layer {
for _, f := range mod.Provides() {
own[f] = true
}
}
for _, mod := range layer {
for _, req := range mod.Requires() {
if own[req] {
t.Fatalf("★ 第 %d 层内部存在依赖:%s 依赖同层的 %s —— "+
"同层并行会读到未初始化的依赖", li, mod.Name(), req)
}
}
}
}
// ③ 观测到的就绪顺序必须与分层一致:某设施被标记就绪时,
// 它所在层的**全部前驱层**都已就绪。用 ReadyInOrder(真实写入顺序)
// 而不是 Layers(计划),这样才能咬住"执行时乱序"。
readyAt := map[string]int{}
for i, f := range m.bus.ReadyInOrder() {
readyAt[f] = i
}
for li, layer := range m.plan.Layers {
for _, mod := range layer {
for _, req := range mod.Requires() {
reqLayer := providedByLayer[req]
if reqLayer >= li {
continue // 已在 ① 报过
}
// 前驱设施必须先于本模块任一设施进入就绪序列。
for _, f := range mod.Provides() {
if readyAt[req] > readyAt[f] {
t.Fatalf("★ 就绪顺序反了:%s(%s) 先于其依赖 %s 就绪",
f, mod.Name(), req)
}
}
}
}
}
// ④ 分层必须真的带来并行(否则"图"退化成一条串行链,等于没做)。
// ★ 不写死层数/模块数:真实登记表增删模块时这条仍然成立。
if len(m.plan.Layers) >= len(m.moduleNames()) {
t.Fatalf("★ 无并行度:%d 层 / %d 模块(等价于全串行)",
len(m.plan.Layers), len(m.moduleNames()))
}
if m.res.MaxConcurrent < 2 {
t.Fatalf("★ 峰值层内并发只有 %d —— 同层并行没生效", m.res.MaxConcurrent)
}
}
// ─────────────────── 判据 3:关闭严格逆序 ───────────────────
// ★★★ 判据:**关闭顺序 = 启动顺序的严格逆序(按层)**。
//
// 防的失败方式:
// - 某层漏关(模块忘 AddCloser / 层边界钩子被删)⇒ 哨兵少一个
// - 关闭顺序改成与启动同序(正序)⇒ 先关被依赖者,依赖者随后用到已关资源
// - CloseAll 的 LIFO 语义被改(例如排序后执行)
func TestShutdownIsStrictlyReverseLayerOrder(t *testing.T) {
m := bootMachine(t)
layers := len(m.plan.Layers)
// 先停机再清点:哨兵是在 CloseAll 执行时才被调用的,装配刚完成时它
// 只是"登记了"。
m.shutdown()
got := m.layerClosers
if len(got) != layers {
t.Fatalf("★ 有 %d 层,但只观测到 %d 个层边界关闭哨兵被调用(顺序 %v)—— "+
"说明有层在关闭时被漏掉了(该层的模块资源不会释放)",
layers, len(got), got)
}
want := make([]int, layers)
for i := 0; i < layers; i++ {
want[i] = layers - 1 - i // 严格降序 = 启动顺序的逆序
}
if !equalIntSlice(got, want) {
t.Fatalf("★ 关闭顺序不是严格逆序:\n 实际(关闭顺序)= %v\n"+
" 期望(启动的逆序)= %v\n"+
" 正序关闭 ⇒ 先关被依赖者,依赖它的模块随后用到已关的资源", got, want)
}
// 关闭后 Closers 必须被消费干净(否则下次 CloseAll 会重跑已关资源)。
if n := len(m.ks.Closers); n != 0 {
t.Fatalf("★ CloseAll 之后仍残留 %d 个关闭函数(重复执行会二次释放)", n)
}
t.Logf("关闭顺序(层号)= %v —— 严格逆序 ✓", got)
}
// ─────────────────── 判据 4:关闭后无残留 ───────────────────
// goroutineSlack 是允许的 goroutine 抖动上限。
//
// ★ 为什么是 2 而不是 0:
// `testing` 自身在用例执行期间会有短暂 goroutine(如 -test.timeout 的
// 定时器、t.Cleanup 的调度),且 Go 运行时的 GC worker 会在
// runtime.GC() 后短暂存活。实测(本机 + CI 同构)关闭后
// 差值恒为 0,留 2 的余量只为吸收这种框架级抖动 —— 不是用来掩盖泄漏的
// (泄漏的量级是"每层一个轮询循环",即 ≥5,远超 2)。
const goroutineSlack = 2
// ★★★ 判据:**关闭后进程回到基线:无 goroutine 泄漏、无资源残留**。
//
// 防的失败方式:
// - 某模块的停止函数没跑(轮询/回调循环继续跑)⇒ goroutine 不回落
// - 句柄没关(DB/文件/HTTP server)⇒ 临时目录删不掉
// - 停机路径**越关越多**(关闭时又注册订阅/起协程)⇒ 反复启停会累积
//
// 做法:连续启停整机 3 轮,每轮都要求回落到基线,且差值不随轮次增长。
// 单轮回落可能是"碰巧",多轮不增长才能排除"每轮漏一点"。
func TestShutdownLeavesNoResidue(t *testing.T) {
baseline := settleGoroutines(t, 1<<30) // 先让运行时安静下来
t.Logf("基线 goroutine 数 = %d", baseline)
const rounds = 3
prevAfter := baseline
for round := 0; round < rounds; round++ {
m := bootMachine(t)
peak := runtime.NumGoroutine()
subscribersAfterBoot := countBusSubscribers(t, m.bus)
dir := m.dataDir
m.shutdown()
// ① goroutine 回落到基线(有界重试,不写死 sleep)。
after := settleGoroutines(t, baseline+goroutineSlack)
delta := after - baseline
if delta > goroutineSlack {
t.Fatalf("★ 第 %d 轮关闭后仍多 %d 个 goroutine(基线 %d,现 %d,允许抖动 %d)\n"+
" 这通常意味着某层模块的停止函数没跑(轮询/回调循环还活着)\n%s",
round+1, delta, baseline, after, goroutineSlack, goroutineStacks())
}
// ② 绝对水位不得逐轮抬升(排除"每轮漏一点"的慢泄漏)。
//
// ★ 比的是**绝对 goroutine 数**而不是相对第一轮基线的差值:
// 测试框架自身(-test.timeout 定时器、t.Cleanup 调度、GC worker)
// 会漂 1 个,用差值会在第 2 轮把框架抖动误报成泄漏。
// 真泄漏的量级是"每层一个轮询循环"(≥5/轮),逐轮抬升必然可见。
if round > 0 && after > prevAfter {
t.Fatalf("★ 关闭后的 goroutine 水位在抬升:第 %d 轮 %d > 上一轮 %d —— "+
"反复启停会累积泄漏\n%s", round+1, after, prevAfter, goroutineStacks())
}
prevAfter = after
// ③ 临时目录必须删得掉:有未关闭的句柄/未卸载的 overlay 时会 EBUSY。
// 这是"漏释放资源"最直接的外部证据(比看代码可靠)。
if err := removeAllWithRetry(dir, 20); err != nil {
var left []string
_ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err == nil && fi != nil {
rel, _ := filepath.Rel(dir, p)
left = append(left, rel)
}
return nil
})
sort.Strings(left)
t.Fatalf("★ 关闭后临时目录删不掉(有句柄/挂载未释放): %v\n 残留项: %v",
err, left)
}
// ④ syncbus 订阅者不得在关闭路径上**增长**。
//
// ★ 为什么断言"不增长"而不是"归零":`SyncBus` 刻意**没有**
// Unsubscribe(它是启动期一次性状态快照,随本次装配的 bus 一起
// 被丢弃)。所以绝对归零需要给内核加 API —— 那是业务改动,
// 不在本判据的权限内。真正的泄漏形态是"关闭时又挂上了订阅"
// (每轮+1),这条能咬住;绝对归零作为已知缺口单独报出。
subsAfterShutdown := countBusSubscribers(t, m.bus)
if subsAfterShutdown > subscribersAfterBoot {
t.Fatalf("★ 关闭路径**新增**了 syncbus 订阅者:装配后 %d → 关闭后 %d "+
"—— 关闭时不该再挂回调(会持有已停止的对象)",
subscribersAfterBoot, subsAfterShutdown)
}
t.Logf("第 %d 轮:基线 %d,装配峰值 %d,关闭后 %d(差 %d),"+
"订阅者 %d→%d,临时目录已清理",
round+1, baseline, peak, after, delta,
subscribersAfterBoot, subsAfterShutdown)
}
}
// ──────────── 判据 5:登记表必须覆盖源码树(独立真相源) ────────────
// ★★★ 判据:**源码树里每个「实现了 kernelboot.Module 四方法」的类型,
// 其 Name() 必须出现在登记表里**。
//
// ## 真相源为什么必须是「接口形状」而不是 Register 调用点(初版的教训)
//
// 「漏挂一个模块」有两种形态,初版只抓得住第二种:
//
// a. 写好了 mod.go(类型 + init()),但 all/all.go 少一行聚合 import
// ⇒ Register 调用点**仍在源码里** ⇒ 扫得到 ⇒ 抓得住
// b. 连 init() 都没写 / 写了又删了(其余三方法齐全)
// ⇒ Register 调用点不存在 ⇒ 初版扫描的入口本身消失
// ⇒ 源码侧与登记表侧**同时少一个** ⇒ 集合比对照常相等 ⇒ 假绿
//
// b 正是「模块静默消失」最危险的形态:四方法写全了、编译也过、
// 只是没人调 Register。把「要检测的对象」同时当「检测入口」,
// 入口一被破坏判据就失去视野 —— 与 ID:16/20/23 记的三次判据假绿同病:
// **真相源与被测对象同源**。
//
// ⇒ 换成与 Register 无关的真相源:**类型 + 方法集**。
// 模块哪怕不 Register、不被 import,Name/Requires/Provides/Init 四个方法
// 仍写在源码里(AST 按「目录+类型名」复合键收集,四方法齐全即视为模块,
// 取其 Name() 的字符串字面量)。
//
// ## 已排除的误报面(设计时逐个实测)
//
// · 类型重名:本仓大量类型都叫 `Module`,**只按类型名做键会撞成一堆**
// (实测撞成只剩 2 个)⇒ 必须用 目录+类型名 复合键
// · 指针接收者:agent 的方法是 `func (*Module) Name()`,
// AST 要同时处理 *ast.Ident 与 *ast.StarExpr
// · 非字面量 Name():**显式报错**,不静默跳过(否则该模块从视野消失)
// · `_test.go` / `all/`(聚合 import 包)/ `testdata/`:不扫
//
// 实测基线:本仓该法恰扫出 17 个类型,与登记表 17 个名字一一对应(零误报)。
//
// 失败面(**已实测咬红**,不是注释声称):
//
// · 删任一模块的 init()(含无下游依赖的叶子模块 multimodal)⇒ 红
// · 删 all/all.go 任一聚合 import ⇒ 红(该类型四方法仍在 ⇒ 源码侧多一个)
// · 删掉整个模块目录 ⇒ 反向断言红(登记表里有、源码树里没有)
func TestEveryRegisteredModuleIsCompiledIn(t *testing.T) {
const modRoot = ".."
fromSource := scanModuleTypes(t, modRoot)
if len(fromSource) == 0 {
t.Fatal("★ 源码扫描一个模块都没找到 —— 扫描逻辑失效(判据会永远绿)")
}
registered := map[string]bool{}
for _, n := range kernelmod.Names() {
registered[n] = true
}
var missing []string
for name, file := range fromSource {
if !registered[name] {
rel, _ := filepath.Rel(modRoot, file)
missing = append(missing, name+"("+rel+")")
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Fatalf("★ 源码树里有 %d 个「四方法齐全的模块类型」**没被登记**(模块静默消失):\n %s\n"+
" 修复:给该类型补 `func init() { kernelmod.Register(X{}) }`,"+
"并在 internal/kernelmod/all/all.go 加聚合 import\n"+
" 后果:它提供的设施永不就绪 ⇒ 依赖它的插件永不启动,且无任何报错",
len(missing), strings.Join(missing, "\n "))
}
// 反向:登记表里的每个模块名都应能在源码树里找到(防夹具污染生产表)。
var phantom []string
for name := range registered {
if _, ok := fromSource[name]; !ok {
phantom = append(phantom, name)
}
}
sort.Strings(phantom)
if len(phantom) > 0 {
t.Fatalf("★ 登记表里有 %v,但源码树里找不到四方法齐全的类型 —— "+
"登记项可能是测试夹具或残留", phantom)
}
t.Logf("源码树扫描到 %d 个模块类型,登记表 %d 个,双向覆盖 ✓",
len(fromSource), len(registered))
}
// scanModuleTypes 按「目录+类型名」复合键收集模块类型(独立真相源)。
//
// 判定:Name/Requires/Provides/Init 四方法齐全 ⇒ 是模块类型;
// 取 Name() 的字符串字面量作为模块名。返回 模块名 → 定义文件路径。
func scanModuleTypes(t *testing.T, root string) map[string]string {
t.Helper()
fset := token.NewFileSet()
type key struct{ dir, typ string }
methods := map[key]map[string]bool{}
nameLit := map[key]string{}
nameFile := map[key]string{}
err := filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi == nil {
return nil
}
if fi.IsDir() {
switch fi.Name() {
case "all", "testdata":
// all 是聚合 import 包(自身不定义模块类型);testdata 不被编译。
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil // 语法错误交给编译器报,这里不当判据失败
}
dir := filepath.Dir(path)
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 {
continue
}
var typ string
switch rt := fn.Recv.List[0].Type.(type) {
case *ast.Ident:
typ = rt.Name
case *ast.StarExpr:
if id, ok := rt.X.(*ast.Ident); ok {
typ = id.Name // 指针接收者(如 agent 的 *Module)
}
}
if typ == "" {
continue
}
k := key{dir, typ}
if methods[k] == nil {
methods[k] = map[string]bool{}
}
methods[k][fn.Name.Name] = true
if fn.Name.Name == "Name" && fn.Body != nil {
for _, st := range fn.Body.List {
ret, ok := st.(*ast.ReturnStmt)
if !ok || len(ret.Results) != 1 {
continue
}
lit, ok := ret.Results[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
v, uerr := strconv.Unquote(lit.Value)
if uerr != nil {
continue
}
nameLit[k] = v
nameFile[k] = path
}
}
}
return nil
})
if err != nil {
t.Fatalf("扫描源码树: %v", err)
}
out := map[string]string{}
for k, ms := range methods {
complete := true
for _, want := range []string{"Name", "Requires", "Provides", "Init"} {
if !ms[want] {
complete = false
break
}
}
if !complete {
continue
}
name, ok := nameLit[k]
if !ok {
// ★ 显式失败而不是跳过:非字面量 Name() 会让该模块从判据视野消失,
// 那就又是一次静默漏测(初版判据就是这么假绿的)。
t.Fatalf("★ %s 里类型 %s 四方法齐全但 Name() 不是字符串字面量 —— "+
"本判据无法静态解析它。请把 Name() 改成 `return \"...\"`,"+
"或在 scanModuleTypes 里补上解析(**不要**静默跳过)",
nameFile[k], k.typ)
}
out[name] = nameFile[k]
}
return out
}
// ─────────────────────────── 辅助 ───────────────────────────
// settleGoroutines 让 goroutine 数回落到 <= target,返回最终观测值。
//
// 有界重试(不是写死 sleep):每轮 GC + 短暂让出,最多 100 轮(约 2s)。
// 轮次上限的存在让"真泄漏"以超时后的真实数字报出来,而不是永远等下去。
func settleGoroutines(t *testing.T, target int) int {
t.Helper()
var n int
for i := 0; i < 100; i++ {
runtime.GC()
n = runtime.NumGoroutine()
if n <= target {
return n
}
time.Sleep(20 * time.Millisecond)
}
return n
}
// removeAllWithRetry 反复尝试删目录(有界)。
//
// 为何需要重试:关闭函数的先后顺序里,DB 的 Close 与文件句柄的释放
// 可能比内核的 dentry 回收稍晚;Linux 上偶发 EBUSY。重试上限保证
// "真删不掉"不会变成无限等待。
func removeAllWithRetry(dir string, tries int) error {
var err error
for i := 0; i < tries; i++ {
if err = os.RemoveAll(dir); err == nil {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return err
}
// countBusSubscribers 读 syncbus 上的订阅者数量。
//
// 只读反射(不 Set、不 Interface),用于把"关闭时挂订阅"这种泄漏
// 变成可断言的数字。字段名/类型变化时本函数会 fatalf —— 那是判据
// 失去观测点,必须显式修,不能静默变成 0。
func countBusSubscribers(t *testing.T, bus *kstate.SyncBus) int {
t.Helper()
if bus == nil {
return 0
}
f := reflect.ValueOf(bus).Elem().FieldByName("onUpdate")
if !f.IsValid() || f.Kind() != reflect.Slice {
t.Fatalf("syncbus 的订阅者字段不再是 slice(判据失去观测点)")
}
return f.Len()
}
func equalIntSlice(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// goroutineStacks 在泄漏时抓全量栈(带进失败消息,便于定位是哪一层没关)。
func goroutineStacks() string {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
return "--- goroutine 栈 ---\n" + string(buf[:n])
}

View File

@ -1,115 +0,0 @@
package all
// 设施名「内核 ↔ 公开 SDK」一致性判据(2026-10-16)。
//
// ## 为什么需要它
//
// 设施名同时存在于**两处**,且必须逐字一致:
//
// 内核:internal/kernelmod/facilities.go (模块 Provides 用)
// SDK:third_party/hologramagentsdk/sdk/facilities.go (插件声明用)
//
// 它们**不能合并**:SDK 是独立发布的模块(外部插件依赖它),
// 而内核的 internal 规则又禁止内部包被外部 import。
//
// ⇒ 两处并存 ⇒ 有漂移风险。**改一处忘另一处**的后果是:
// 插件按 SDK 声明的名字,内核从未注册 ⇒ 插件**永不启动**,且(在本判据
// 与运行期校验出现前)无任何报错。
//
// 本判据把"两边一致"变成可执行的检查。
//
// ## 为什么必须从**源码**提取而不是手写清单
//
// 手写清单在"新增了一个设施名但只改了内核"时**也不会包含新名字**
// ⇒ 判据照样绿(守卫形同虚设)。本仓已有先例
// (TestCapability_AllMethodsClassified 初版就是这个毛病)。
import (
"os"
"path/filepath"
"regexp"
"sort"
"testing"
)
// internalOnlyFacilities 是**内核内部**顺序标记(不是给插件的契约)。
//
// ★ 判据要排除它们:它们的消费者是内核模块,不该进公开 SDK。
// 若把它们也拿去比对,会得到"内核有但 SDK 没有"的**假红**(实测撞到:
// knowledge-migrated)。反向也要防:有人误把内部标记放进 SDK 当对外契约。
var internalOnlyFacilities = map[string]bool{
"knowledge-migrated": true,
// kernel.version 同理:它是 update 模块的启动时序事实("版本自检与
// 升级回滚判定已完成"),消费者是内核模块,不是插件契约。
"kernel.version": true,
}
// extractFacilities 从 Go 源码里提取 `FacilityXxx = "值"` 形式的常量。
func extractFacilities(t *testing.T, path string) map[string]string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s: %v", path, err)
}
re := regexp.MustCompile(`(?m)^\s*(Fac[A-Za-z0-9_]*|Facility[A-Za-z0-9_]*)\s*=\s*"([^"]+)"`)
out := map[string]string{}
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
out[m[1]] = m[2]
}
if len(out) < 10 {
t.Fatalf("只从 %s 提取到 %d 个设施常量 —— 正则或文件结构变了(判据会假绿)",
path, len(out))
}
return out
}
// ★★★ 判据:**内核与 SDK 的设施名值集合逐字一致**。
func TestFacilityNamesMatchPublicSDK(t *testing.T) {
kernel := extractFacilities(t, filepath.Join("..", "facilities.go"))
sdk := extractFacilities(t, filepath.Join("..", "..", "..",
"third_party", "hologramagentsdk", "sdk", "facilities.go"))
kernelVals := map[string]string{} // 值 → 内核常量名
for name, v := range kernel {
if internalOnlyFacilities[v] {
continue // 内核内部顺序标记,不是插件契约
}
kernelVals[v] = name
}
sdkVals := map[string]string{}
for name, v := range sdk {
sdkVals[v] = name
}
var missingInSDK, missingInKernel []string
for v, name := range kernelVals {
if _, ok := sdkVals[v]; !ok {
missingInSDK = append(missingInSDK, name+"="+v)
}
}
for v, name := range sdkVals {
if _, ok := kernelVals[v]; !ok {
missingInKernel = append(missingInKernel, name+"="+v)
}
}
sort.Strings(missingInSDK)
sort.Strings(missingInKernel)
if len(missingInSDK) > 0 {
t.Errorf("★ 内核有但**公开 SDK 没有**的设施名: %v\n"+
" 后果:插件作者无法 import 该常量(只能照文档抄 ⇒ 易拼错),"+
"且打包期校验会把它当'未知设施名'而拒绝", missingInSDK)
}
for v := range sdkVals {
if internalOnlyFacilities[v] {
t.Errorf("★ 公开 SDK 里出现了内核**内部顺序标记** %q —— "+
"它的消费者是内核模块,不该成为插件契约(插件声明它没有意义,"+
"且会误导作者以为可用)", v)
}
}
if len(missingInKernel) > 0 {
t.Errorf("★ 公开 SDK 有但**内核没有**的设施名: %v\n"+
" 后果:插件按此声明 ⇒ 内核从未注册 ⇒ 插件**永不启动**(静默)",
missingInKernel)
}
}

View File

@ -1,212 +0,0 @@
package all
// 架构防腐判据(2026-11-04)。
//
// ## 这组判据守的是"分层不要退化成莲藕"
//
// 本次重构(见 kernelplan.md)解决的核心问题是:**共享数据靠手工接线**。
// 它退化的形态有四条已知通道,每条都真实发生过:
//
// core → Agent 字段(每种 agent 构造点都要记得传)
// plugin → 12 个 SetXxx(合法性见下面 TestPluginSettersAreInjection)
// sdk → SDKConfig 传参
// security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
//
// 前三条里,`security` 那条是**真缺陷**(已删除);`core` 那条也是
// (skill 索引没进子 agent 提示词);`plugin` 那条经实测是**合法的
// 构造期注入**(每个 Setter 只有一个调用点、调用方持有 ks、少了立刻报错)。
//
// 判据要能区分这两类,否则要么放过真缺陷,要么逼人做无意义的改造。
//
// ## 每条判据都做过变异验证
//
// 见各条注释里的"变红条件"。没有变异验证的判据等于不存在
// (engineering-lessons §1.1:结构判据 ≠ 质量判据)。
import (
"os"
"os/exec"
"strings"
"testing"
)
// ─────────────────── ① 环方向:底层包不得反向依赖装配器 ───────────────────
// TestCoreDoesNotImportKernelboot 钉住 core 不依赖装配引擎。
//
// 为什么重要:core 是环的一极。它一旦依赖 kernelboot,就无法读任何
// 统一挂载点(环),只能自建通道 —— 那正是本次重构要消灭的形态。
//
// 变红条件:在 internal/agent/core 任一文件里 import kernelboot ⇒ 红。
func TestCoreDoesNotImportKernelboot(t *testing.T) {
assertNoDep(t, "github.com/JianFeeeee/HologramAgent/internal/agent/core",
"github.com/JianFeeeee/HologramAgent/internal/kernelboot")
}
// TestPluginDoesNotImportKernelboot 钉住 plugin 不依赖装配引擎。
func TestPluginDoesNotImportKernelboot(t *testing.T) {
assertNoDep(t, "github.com/JianFeeeee/HologramAgent/internal/plugin",
"github.com/JianFeeeee/HologramAgent/internal/kernelboot")
}
// TestSdkDoesNotImportKernelboot 钉住 sdk 不依赖装配引擎。
func TestSdkDoesNotImportKernelboot(t *testing.T) {
assertNoDep(t, "github.com/JianFeeeee/HologramAgent/internal/sdk",
"github.com/JianFeeeee/HologramAgent/internal/kernelboot")
}
// TestSecurityDoesNotImportKernelboot 钉住 security 不依赖装配引擎。
func TestSecurityDoesNotImportKernelboot(t *testing.T) {
assertNoDep(t, "github.com/JianFeeeee/HologramAgent/internal/security",
"github.com/JianFeeeee/HologramAgent/internal/kernelboot")
}
// TestKstateDoesNotDependOnCore 是"core 能 import kstate"的另一面。
//
// 只有这一半成立(kstate 不依赖 core),"core 读统一挂载点"这条路才通。
//
// 变红条件:kstate.State 里加 *agentCore.Xxx 类型的字段 ⇒ 红。
func TestKstateDoesNotDependOnCore(t *testing.T) {
assertNoDep(t, "github.com/JianFeeeee/HologramAgent/internal/kstate",
"github.com/JianFeeeee/HologramAgent/internal/agent/core")
}
// ─────────────────── ② 共享数据只有一个来源 ───────────────────
// TestNoSecurityPackageLevelSingletons 已在 internal/security 内实现
// (singleton_test.go)。这里只做跨包的**存在性**检查:确认那组判据没被删。
//
// 为什么需要这条:判据文件被删是很常见的"静默废掉判据"方式
// —— 删掉之后所有测试仍然绿,只是再也不检查了。
func TestSecuritySingletonGuardStillExists(t *testing.T) {
src, err := os.ReadFile("../../security/singleton_test.go")
if err != nil {
t.Fatalf("★ security 的单例判据文件不见了(%v)——\n"+
"删除判据是最隐蔽的「修复」方式:测试全绿,但再也不检查了。", err)
}
if !strings.Contains(string(src), "TestNoPackageLevelSingletons") {
t.Fatal("★ singleton_test.go 里找不到 TestNoPackageLevelSingletons")
}
for _, want := range []string{`"global"`, `"runtime"`, `"globalCodec"`} {
if !strings.Contains(string(src), want) {
t.Fatalf("★ 禁表里缺 %s —— 那个单例可以原样加回来", want)
}
}
}
// ─────────────────── ③ Agent 不得持有共享数据字段 ───────────────────
// TestAgentHasNoPromptSectionsField 钉住 Agent 不再持插件声明的提示词段。
//
// 这是本次故障的直接来源:skillIndex 曾是 Agent 的字段,导致 rtchild /
// 驻留子 / 工作式子各自构造时都要记得传,漏传就是"那个 agent 看不到任何
// skill",而模型只能凭工具名猜用法。
//
// 变红条件:给 Agent 加回 promptSections 或 skillIndex 字段 ⇒ 红。
func TestAgentHasNoPromptSectionsField(t *testing.T) {
src, err := os.ReadFile("../../agent/core/agent.go")
if err != nil {
t.Fatalf("读 agent.go: %v", err)
}
text := string(src)
// 只看结构体字段声明行(允许注释里提到这些名字)。
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue
}
if strings.Contains(trimmed, "promptSections ") && strings.Contains(trimmed, "*") {
t.Fatalf("★ Agent 又持有了提示词段字段: %q\n\n"+
"它必须挂在 kstate.State.PromptSections 上,任何 agent\n"+
"(含未来新类型)读同一份 —— 零接线。", trimmed)
}
if strings.Contains(trimmed, "skillIndex ") {
t.Fatalf("★ Agent 又持有了 skillIndex 字段: %q\n"+
"详情见上述 PromptSections 的说明。", trimmed)
}
}
}
// ─────────────────── ④ plugin 的 Setter 是合法注入(防误改) ───────────────────
// TestPluginSettersAreInjection 钉住"这些 Setter 不是要收敛的东西"。
//
// 背景:kernelplan v1 曾主张把 12 个 SetXxx 改成"读 kstate"。
// 实测做不到 —— kstate 持有 *plugin.Registry,plugin 若 import kstate
// 就是 import cycle(已用探针实证)。
//
// 更重要的是:它们**本来就不是缺陷**。每个 Setter 只有一个调用点,
// 调用方持有 ks,显式传参;少了任何一个插件拿 nil 立刻报错,
// 不存在"静默降级"(那是包级单例的病症)。
//
// 本条判据防的是**过度收敛**:后来者看到一堆 Setter 就想合并成全局注册表,
// 那会把"显式注入"退化成"隐式全局"。
//
// 变红条件:plugin.Registry 出现读 kstate 的代码 ⇒ 红(说明有人硬改)。
func TestPluginSettersAreInjection(t *testing.T) {
out, err := exec.Command("go", "list", "-deps",
"github.com/JianFeeeee/HologramAgent/internal/plugin").Output()
if err != nil {
t.Fatalf("go list -deps: %v", err)
}
deps := strings.Fields(string(out))
for _, d := range deps {
if d == "github.com/JianFeeeee/HologramAgent/internal/kstate" {
t.Fatal("★ internal/plugin 依赖了 kstate —— 应该是 import cycle。\n\n" +
"若这真的编译过了,说明 kstate 不再持有 *plugin.Registry,\n" +
"那要先重新评估:共享数据的分发方式是否还成立。\n" +
"(kstate 持有 plugin.Registry 是为了让模块间共享插件注册表;\n" +
" 改了这个前提,整套注入路径都要重新设计。)")
}
}
}
// ─────────────────── ⑤ 插件声明式提示词段的唯一来源 ───────────────────
// TestPromptSectionsOnlyFromKstate 钉住内核只从挂载点读插件段。
//
// 变红条件:agent/core 里出现 a.promptSections(字段读取)⇒ 红。
func TestPromptSectionsOnlyFromKstate(t *testing.T) {
src, err := os.ReadFile("../../agent/core/promptsections.go")
if err != nil {
t.Fatalf("读 promptsections.go: %v", err)
}
text := string(src)
if !strings.Contains(text, "kstate.Get()") {
t.Fatal("★ renderSystemPrompt 不再从 kstate 读插件段 ——\n" +
"那意味着它退回了 Agent 字段(子 agent 拿不到)。")
}
if strings.Contains(text, "a.promptSections") {
t.Fatal("★ 仍在读 a.promptSections 字段 —— 应改为 kstate.Get().PromptSections")
}
}
// ─────────────────── 工具 ───────────────────
// assertNoDep 断言 pkg 的**传递依赖**里没有 forbidden。
//
// 用 `go list -deps` 而不是源码扫描:传递依赖能抓到间接环
// (源码扫描只能看到直接 import)。
func assertNoDep(t *testing.T, pkg, forbidden string) {
t.Helper()
out, err := exec.Command("go", "list", "-deps", pkg).Output()
if err != nil {
var stderr string
if ee, ok := err.(*exec.ExitError); ok {
stderr = string(ee.Stderr)
}
t.Fatalf("go list -deps %s 失败: %v\n%s", pkg, err, stderr)
}
for _, d := range strings.Fields(string(out)) {
if d == forbidden {
t.Fatalf("★ %s 依赖了 %s\n\n"+
"这条依赖会形成环,让被依赖方无法反向读统一挂载点 ——\n"+
"那正是「共享数据靠手工接线」的来源。\n\n"+
"正确做法:共享数据挂在 kstate.State 上,需要它的包 import kstate。\n"+
"(若被依赖方**就是**挂载点持有的类型,则它不能反过来读挂载点 ——\n"+
" 那种情况用构造期显式注入,见 TestPluginSettersAreInjection。)",
pkg, forbidden)
}
}
}

View File

@ -1,351 +0,0 @@
package all
// 内核模块注册表 + 分层的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.3/§2.6/§2.8。
//
// ## 用户的验收标准(必须被判据钉住)
//
// > 模块应该在 /modelname/ 独立文件夹下,根目录只有 core.go,
// > core 内有一个注册表注册各个模块 … 注意注册表中的模块列表是乱序的
//
// ⇒ 四条底线:
// ① 每个模块**独立文件夹**
// ② 依赖写在**模块自己**的 Requires() 里(无中央依赖清单)
// ③ 注册表列表**乱序**(顺序无意义,不能被依赖)
// ④ 第 0 层模块**不依赖任何模块**("core 不依赖任何模块"字面成立)
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
)
// ★★★ 判据:**依赖图可建 + 模块全登记 + 无重复**。
func TestRegistryBuildsValidGraph(t *testing.T) {
if d := kernelmod.Duplicates(); len(d) > 0 {
t.Fatalf("★ 模块名重复注册: %v(两个模块同名,或一个模块被导入两次)", d)
}
mods := kernelmod.Registered()
if len(mods) < 10 {
t.Fatalf("★ 只登记 %d 个模块 —— 可能有模块漏了 init() 注册", len(mods))
}
plan, err := kernelboot.Build(mods)
if err != nil {
t.Fatalf("★ 依赖图无解(结构错误): %v", err)
}
total := 0
for _, l := range plan.Layers {
total += len(l)
}
if total != len(mods) {
t.Fatalf("★ 分层丢失模块: %d != %d", total, len(mods))
}
}
// ★★★ 判据(用户验收标准 ④):**第 0 层不依赖任何模块**。
func TestFirstLayerHasNoDependencies(t *testing.T) {
plan, err := kernelboot.Build(kernelmod.Registered())
if err != nil {
t.Fatal(err)
}
for _, m := range plan.Layers[0] {
if len(m.Requires()) != 0 {
t.Errorf("★ 第 0 层模块 %s 有依赖 %v —— 分层正确的话不该有",
m.Name(), m.Requires())
}
}
names := map[string]bool{}
for _, m := range plan.Layers[0] {
names[m.Name()] = true
}
for _, want := range []string{"config", "kernel-api"} {
if !names[want] {
t.Errorf("★ %s 应在第 0 层(它不依赖任何模块)", want)
}
}
}
// ★★★ 判据(用户验收标准 ①②):**每模块独立文件夹;依赖写在自己文件里**。
func TestEachModuleInOwnFolder(t *testing.T) {
root := ".."
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
modDirs := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
modDirs++
files, _ := filepath.Glob(filepath.Join(root, e.Name(), "*.go"))
if len(files) == 0 {
t.Errorf("★ 模块目录 %s 下没有 .go 文件", e.Name())
}
}
if modDirs < 10 {
t.Fatalf("★ 只找到 %d 个模块目录(期望 ≥10)", modDirs)
}
// ② 依赖不能出现在"中央清单"里:根目录只有 core.go/facilities.go/all 子包
for _, f := range []string{"core.go", "facilities.go"} {
b, err := os.ReadFile(filepath.Join(root, f))
if err != nil {
continue
}
src := string(b)
if strings.Contains(src, "Requires() []string { return []string{") {
t.Errorf("★ %s 里出现了具体的 Requires —— 依赖应写在各模块自己的文件里", f)
}
}
}
// ★★★ 判据(用户验收标准 ③):**注册表列表乱序**(顺序无意义,不能被依赖)。
//
// ★ 怎么测"乱序":注册顺序由 init() 决定。这里断言 Names() 的顺序
// **不等于**按名字排序的顺序 —— 若相等,说明有人给它排过序,
// 那会给使用者"这个顺序有意义"的错觉(用户明确要求乱序)。
//
// ⚠ 注意:这条是**性质检查**,不是绝对的(init 顺序恰好等于字典序时也会通过
// 排序检查)。所以真正守"顺序无意义"的是**文档 + 分层器内部排序**,
// 而本判据是"如果有人显式排序就报红"的报警器。
func TestRegistryOrderIsNotMeaningful(t *testing.T) {
names := kernelmod.Names()
if len(names) < 2 {
t.Fatal("模块太少")
}
sorted := append([]string(nil), names...)
sort.Strings(sorted)
if strings.Join(names, ",") == strings.Join(sorted, ",") {
t.Logf("提示:注册顺序恰好等于字典序(不必然是错误,但请确认没有人"+
"给注册表排序——顺序无意义是设计的一部分): %v", names)
}
// 核心断言:Registered() 返回的列表**不得**按依赖序排列
// (即:不能有人把"启动顺序"写进注册表)
plan, err := kernelboot.Build(kernelmod.Registered())
if err != nil {
t.Fatal(err)
}
var layerOrder []string
for _, l := range plan.Layers {
for _, m := range l {
layerOrder = append(layerOrder, m.Name())
}
}
if strings.Join(names, ",") == strings.Join(layerOrder, ",") {
t.Fatal("★ 注册表顺序恰好等于**依赖分层序** —— 说明注册表被用来表达启动顺序了," +
"那正是要消灭的隐式顺序依赖(用户要求注册表乱序)")
}
}
// ★★★ 判据:**关键顺序被依赖图保证**(每条都来自真实约束)。
func TestLayeringPreservesCriticalOrder(t *testing.T) {
plan, err := kernelboot.Build(kernelmod.Registered())
if err != nil {
t.Fatal(err)
}
layerOf := map[string]int{}
for i, l := range plan.Layers {
for _, m := range l {
layerOf[m.Name()] = i
}
}
cases := []struct{ a, b, why string }{
{"config", "lua", "initLuaVM(cfg) 收 cfg"},
{"lua", "llm", "initLLMProviders 收 luaVM"},
{"config", "memory", "initMemoryStack 收 cfgReg"},
{"knowledge-migration", "knowledge-store", "迁移必须早于 store 扫盘"},
{"llm", "stage-host", "newStageAndRegistry 收 providerMgr"},
{"memory", "stage-host", "newStageAndRegistry 收 mem.db"},
{"stage-host", "agent", "newMainAgent 收 stageHost"},
{"llm", "agent", "newMainAgent 收 provider"},
{"memory", "agent", "newMainAgent 收 mem.db"},
{"tracker", "agent", "newMainAgent 收 trk"},
}
for _, c := range cases {
la, ok1 := layerOf[c.a]
lb, ok2 := layerOf[c.b]
if !ok1 || !ok2 {
t.Errorf("模块缺失: %s=%v %s=%v", c.a, ok1, c.b, ok2)
continue
}
if la >= lb {
t.Errorf("★ %s 应在 %s 之前(%s),实际层号 %d >= %d", c.a, c.b, c.why, la, lb)
}
}
}
// ★★★ 判据:**text-memory 显式依赖 onnx-parser**(隐藏依赖,见 textmem 包注释)。
//
// ★ 不能用层号断言:text-memory 已因 memory 被推到第 2 层,
// 而 onnx-parser 在第 1 层 ⇒ 删掉依赖后层号依然满足。必须断言**声明本身**。
func TestHiddenDependencyOnnxDeclared(t *testing.T) {
src := readMod(t, filepath.Join("..", "textmem", "mod.go"))
if !strings.Contains(src, "FacONNX") {
t.Fatal("★ text-memory 未声明对 onnx-parser 的依赖 —— " +
"它在运行期读 nlp.defaultParser(onnx 模块写的包级单例)。" +
"当前靠 distillLoop 的 Interval=10min 碰巧安全;" +
"Interval 变小或解析器装配更后 ⇒ 静默降级(图记忆实体退化成整句复合值)")
}
}
// ★★★ 判据:**分层带来并行**(对照原 17 步串行)。
func TestLayeringYieldsParallelism(t *testing.T) {
mods := kernelmod.Registered()
plan, err := kernelboot.Build(mods)
if err != nil {
t.Fatal(err)
}
maxLayer := 0
for _, l := range plan.Layers {
if len(l) > maxLayer {
maxLayer = len(l)
}
}
if len(plan.Layers) >= len(mods) {
t.Fatalf("★ 无并行度(%d 层 / %d 模块)", len(plan.Layers), len(mods))
}
t.Logf("并行度: %d 层 / %d 模块,最宽层 %d 个", len(plan.Layers), len(mods), maxLayer)
}
func readMod(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s: %v", path, err)
}
return string(b)
}
// ★★★ 判据:**每个模块目录都被 import 引入**(漏 import ⇒ 模块静默消失)。
//
// ## ★ 这条是变异测试发现的**真实覆盖漏洞**
//
// 我删掉 `all.go` 里 `multimodal` 的 import 后,**全部判据仍然通过** ——
// 因为:
//
// · 依赖图仍然解得开(multimodal **没有下游依赖**,没人需要它)
// · 注册表数量检查只有 `>= 10` 这种下限(少一个不报)
//
// 后果:**多模态能力凭空消失**,而启动照常成功、无任何报错。
// 这与本仓反复吃亏的"静默降级"是同一形态:
//
// · 漏 import 插件包 ⇒ 插件静默消失(已有同类教训)
// · 漏 import 模块包 ⇒ 模块静默消失(本条)
//
// ## 判据做法:**统计目录 vs 统计注册**
//
// 扫描 `internal/kernelmod/` 下的模块目录(含 `mod.go` 或实现 Module 的 .go
// 文件),与注册表里的模块名比对 —— 目录有而注册表没有 ⇒ 漏 import。
//
// ★ 为什么不用"手写清单":手写清单在"新增目录忘了加 import"时
// **也不会包含新目录** ⇒ 判据照样绿(本仓已有先例)。
func TestEveryModuleDirIsImported(t *testing.T) {
root := ".."
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
registered := map[string]bool{}
for _, n := range kernelmod.Names() {
registered[n] = true
}
// 收集"看起来是模块目录"的:含 .go 文件、且文件里有 kernelmod.Register 调用
var moduleDirs []string
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "all" {
continue // 这个子包是 import 清单本身
}
files, _ := filepath.Glob(filepath.Join(root, name, "*.go"))
hasRegister := false
for _, f := range files {
if strings.Contains(f, "_test.go") {
continue
}
b, err := os.ReadFile(f)
if err != nil {
continue
}
if strings.Contains(string(b), "kernelmod.Register(") {
hasRegister = true
break
}
}
if hasRegister {
moduleDirs = append(moduleDirs, name)
}
}
if len(moduleDirs) < 5 {
t.Fatalf("只找到 %d 个模块目录 —— 扫描逻辑失效(判据会假绿)", len(moduleDirs))
}
// 每个模块目录里的 Register(...) 参数名 → 期望的模块名
// (用"目录名"与"注册名"的映射,因为目录名与 Name() 不必相同)
dirToNames := map[string][]string{}
for _, d := range moduleDirs {
files, _ := filepath.Glob(filepath.Join(root, d, "*.go"))
for _, f := range files {
if strings.Contains(f, "_test.go") {
continue
}
b, _ := os.ReadFile(f)
// ★ 正则要容忍 gofmt 的对齐空格(列对齐会让 `{` 前有多个空格)。
// 初版写成 `\{ return` (单空格)⇒ 只匹配到 agent 一个,
// 其余全部"找不到目录"(判据假红)。实测撞到。
re := regexp.MustCompile(`(?m)^func \([A-Za-z0-9_]+\) Name\(\) string\s+\{ return "([^"]+)" \}`)
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
dirToNames[d] = append(dirToNames[d], m[1])
}
}
}
var missing []string
for dir, names := range dirToNames {
if len(names) == 0 {
continue
}
// 该目录的**每个**模块名都必须在注册表里
//(一个目录可含多个模块,如 knowledge/{migration,store})
anyFound := false
for _, n := range names {
if registered[n] {
anyFound = true
}
}
if !anyFound {
missing = append(missing, fmt.Sprintf("%s(%v)", dir, names))
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Fatalf("★ 这些模块目录**没有被 import 引入**(模块静默消失,启动不报错): %v\n"+
" 修复:在 internal/kernelmod/all/all.go 里加空白 import", missing)
}
// 反向:注册表里的每个模块都应能对应到某个目录
known := map[string]bool{}
for _, ns := range dirToNames {
for _, n := range ns {
known[n] = true
}
}
for n := range registered {
if !known[n] {
t.Errorf("★ 注册表里的模块 %s 找不到对应目录 —— 可能是测试夹具注册的,或扫描漏了", n)
}
}
}

View File

@ -1,257 +0,0 @@
package all
// ID:14 · Run 的**执行期**依赖校验判据(2026-10-24)。
//
// ## 与构建期校验的分工(别把这条当成 Build 的重复实现)
//
// - 构建期(kernelboot.Build → diagnoseUnresolvable):看**声明**。
// Requires 有没有人 Provides、有没有环。声明写对了就绿。
// - 执行期(kernelboot.Run → verifyLayerDependencies,本文件测的):看**事实**。
// 按这份 plan 真的跑完的层里产出了什么。
//
// 两者不可互相替代。**声明全对、图也自洽**时,plan 的层顺序仍可能被改坏
// (手改 Layers / 图与代码漂移 / 有人把某层提前),此时 Build 依然绿,
// 而模块 Init 拿到的是 nil 依赖 ⇒ 崩在业务代码里。
//
// 本判据的复现形状就是实测撞到的那个:让 **llm 排到 lua 之前**,
// llm.buildProviderManager 收到 0x0 的 LuaVM,在
// `luaVM.ConfigureConcurrency(...)`(internal/lua/vm.go:212 取 v.mu)里 SIGSEGV。
//
// ## 为什么判据必须用真实登记表
//
// 用假模块自造一份错误的层顺序,只能证明"这段校验逻辑会报错",
// 不能证明"真实 17 个模块的依赖形状被它覆盖"。这里直接拿
// kernelmod.Registered() 算出的真实 plan 做变异(删除首层 / 交换两层),
// 报错里的模块名与设施名都是真名字。
import (
"errors"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// realPlan 用真实登记表算分层计划(与生产 dev 路径同一个入口)。
func realPlan(t *testing.T) *kernelboot.Plan {
t.Helper()
plan, err := kernelboot.Build(kernelmod.Registered())
if err != nil {
t.Fatalf("真实登记表算不出启动图: %v", err)
}
if len(plan.Layers) < 4 {
t.Fatalf("★ 真实计划只有 %d 层 —— 这条判据的变异前提不成立(层数太少无法制造顺序破坏)",
len(plan.Layers))
}
return plan
}
// layerNames 返回某层的模块名(升序,便于断言消息)。
func layerNames(layer []kernelboot.Module) []string {
out := make([]string, 0, len(layer))
for _, m := range layer {
out = append(out, m.Name())
}
return out
}
// emptyState 造一个最小可用的 KernelState:只填 Group 0 的产物。
//
// ★ 为什么必须填 Cfg:`config` 模块的 Init 会显式拒绝 nil 的 ks.Cfg
// (它自己就有前置检查)。不填的话"正确的 L0 也跑不起来",
// 那样就分不清红是因为顺序变了还是因为夹具没准备好。
//
// ★ 为什么连 Daemon.DataDir 一起填:万一有人在本判据上做"临时摸掉守卫"的
// 变异,模块会真的跑到写盘那一行。不填的话 text-memory 会往**仓内**
// 当前目录下的 memory/text 写(实测产生过 internal/kernelmod/all/memory/)。
// 夹具不能往仓库里漏文件。
func emptyState(t *testing.T) *kstate.State {
t.Helper()
dir := t.TempDir()
ks := kstate.New()
ks.Cfg = &types.Config{}
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.HTTPAddr = "127.0.0.1:0"
return ks
}
// ★★★ 判据 A(正向基线):**正确的层顺序下,执行期校验不误报**。
//
// 取真实 plan 的**第一层**单独跑:模块(config / kernel-api)本来就绪条件
// 为空,若能通过 ⇒ 校验没把"合法的第 0 层"当成缺依赖。
//
// (整机正序不误报由 internal/kernelmod/all 的整机判据覆盖(bootgraph_test.go):
// 它用真实 plan 跑完全部 5 层,装配成功后资产/关闭/泄漏全部断言。
// 这里只做最小正向基线,避免重复一份整机夹具。)
func TestRunRuntimeGuardDoesNotFireOnCorrectPrefix(t *testing.T) {
plan := realPlan(t)
prefix := &kernelboot.Plan{Layers: [][]kernelboot.Module{plan.Layers[0]}}
if _, err := kernelboot.Run(prefix, kernelboot.RunOptions{State: emptyState(t)}); err != nil {
t.Fatalf("★ 合法前缀(第 0 层 %v)被判成缺依赖 —— 校验误报: %v",
layerNames(plan.Layers[0]), err)
}
}
// ★★★ 判据 B(**复现原始崩溃条件**):把提供者排到消费者之后 ⇒ 必须
// 返回可读的 DependencyNotReadyError,**不是** SIGSEGV。
//
// 做法:交换真实 plan 的第 1 层与第 2 层。
// 交换后 lua/memory/onnx-parser/knowledge-migration 都排到了 llm/text-memory/
// knowledge-store 之后 —— 这正是"改层顺序"的最小形态,也是实测崩溃的场景
// (llm 拿 0x0 LuaVM)。
//
// 断言三件事:
// 1. 错误类型是 *DependencyNotReadyError(不是 panic、不是 Init 透传的错)
// 2. 报错**指名**哪个模块缺哪个设施(可读、可定位)
// 3. 报错发生在**装配之前**:此前各层实际产出的设施只有 L0 那两个,
// 且 llm 依赖的 LuaVM 从未被创建(ks.LuaVM == nil)
func TestRunRejectsLayerOrderViolation(t *testing.T) {
plan := realPlan(t)
// 前置:真实 plan 的第 1 层确实提供 lua/memory/onnx,第 2 层确实消费它们。
// 若前提变了(有人调整了模块依赖),下面交换就不再"制造出违规" ⇒ 判据变空转。
providesIn := func(layer []kernelboot.Module, fac string) bool {
for _, m := range layer {
for _, p := range m.Provides() {
if p == fac {
return true
}
}
}
return false
}
for _, fac := range []string{kernelmod.FacLua, kernelmod.FacMemory, kernelmod.FacONNX} {
if !providesIn(plan.Layers[1], fac) {
t.Fatalf("★ 前提不成立:第 1 层不再提供 %s —— 本判据的变异会变成空转(不会制造违规)", fac)
}
}
swapped := &kernelboot.Plan{Layers: [][]kernelboot.Module{
plan.Layers[0],
plan.Layers[2], // ← 原本在第 3 位:knowledge-store / llm / text-memory
plan.Layers[1], // ← 原本在第 2 位:lua / memory / onnx-parser / …
}}
// 只保留到违规发生处(第 1 层)就够:守卫在 Init 之前就失败。
swapped.Layers = swapped.Layers[:2]
ks := emptyState(t)
_, err := kernelboot.Run(swapped, kernelboot.RunOptions{State: ks})
if err == nil {
t.Fatalf("★ 层顺序被改坏(%v 排到了 %v 之前)却装配成功 —— 校验没生效",
layerNames(plan.Layers[2]), layerNames(plan.Layers[1]))
}
var dnr *kernelboot.DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError(可读报错),实际是 %T: %v",
err, err)
}
// ② 指名道姓:llm 缺 lua、text-memory 缺 memory/onnx、knowledge-store 缺迁移标记。
want := map[string][]string{
// ★ 2026-11-04:llm 新增了 FacSecurity 依赖。
// 理由:ProviderManager.Register 在注册每个 provider 时套出向装饰器
// (明文→别名),而装饰器需要变量库。单例删除后变量库必须**在注册前**
// 由 SetSecretStore 注入 ⇒ 依赖图必须保证 security 先完成,
// 不能靠"碰巧先装了单例"。
"llm": {kernelmod.FacLua, kernelmod.FacSecurity},
"text-memory": {kernelmod.FacMemory, kernelmod.FacONNX},
"knowledge-store": {kernelmod.FacMigrated},
}
for mod, facs := range want {
got, ok := dnr.Missing[mod]
if !ok {
t.Fatalf("★ 报错没指名模块 %s 缺依赖。实际缺失表: %v", mod, dnr.Missing)
}
if strings.Join(got, ",") != strings.Join(facs, ",") {
t.Errorf("★ 模块 %s 缺失设施报错不准:期望 %v,实际 %v", mod, facs, got)
}
}
// 报错文本必须能直接照着修(模块名 + 设施名 + 层号都在里面)。
for _, wantSub := range []string{"第 1 层", "llm", kernelmod.FacLua, "缺设施"} {
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("★ 报错文本缺少 %q,无法据此定位(原文: %s)", wantSub, err.Error())
}
}
// ③ 违规发生在装配之前:只跑完了 L0。
//
// ★ L0 现在是三个:config / kernel-api / update。update 模块 Requires
// 为空(它肩负"启动失败回滚上一次升级",必须早于所有模块),所以
// 天然落在 L0。这里断言**集合**而非顺序 —— 注册表本就是乱序的。
got := dnr.Produced
wantL0 := map[string]bool{
kernelmod.FacConfig: true,
kernelmod.FacKernelIO: true,
kernelmod.FacVersion: true,
}
if len(got) != len(wantL0) {
t.Errorf("★ 守卫失败时 L0 应产出 %d 个设施,实际 %v", len(wantL0), dnr.Produced)
}
for _, f := range got {
if !wantL0[f] {
t.Errorf("★ L0 出现了意外的设施 %q(实际产出 %v)", f, dnr.Produced)
}
}
if ks.LuaVM != nil {
t.Error("★ 守卫失败后 ks.LuaVM 非 nil —— 说明 lua 模块被跑过了,与\"装配前校验\"矛盾")
}
if dnr.Layer != 1 {
t.Errorf("★ 报错层号应为 1,实际 %d", dnr.Layer)
}
}
// ★★★ 判据 C(变异证明:删掉首层 = 所有消费者都缺依赖):
//
// 与判据 B 不同的问题形态 —— 不是"顺序颠倒",而是"提供者整层消失"
// (旧图/新代码混装、或有人误删一层)。此时第 0 层全部模块缺 config/kernel-io,
// 且**一个 Init 都不该跑**。
func TestRunRejectsMissingProvidingLayer(t *testing.T) {
plan := realPlan(t)
// 删掉第 0 层,其余层原样前移。
cut := &kernelboot.Plan{Layers: append([][]kernelboot.Module{}, plan.Layers[1:]...)}
ks := emptyState(t)
_, err := kernelboot.Run(cut, kernelboot.RunOptions{State: ks})
if err == nil {
t.Fatal("★ 提供者整层消失却装配成功")
}
var dnr *kernelboot.DependencyNotReadyError
if !errors.As(err, &dnr) {
t.Fatalf("★ 期望 *DependencyNotReadyError,实际 %T: %v", err, err)
}
if dnr.Layer != 0 {
t.Errorf("★ 应在第 0 层就拒绝(该层没有任何前置产出),实际报第 %d 层", dnr.Layer)
}
if len(dnr.Produced) != 0 {
t.Errorf("★ 第 0 层之前不该有任何产出,实际 %v", dnr.Produced)
}
// 原第 1 层的模块全部缺 config 或 kernel-io(每个模块至少要一个)。
if len(dnr.Missing) < 5 {
t.Fatalf("★ 只报出 %d 个模块缺依赖(原第 1 层有 %d 个)—— 校验漏报",
len(dnr.Missing), len(plan.Layers[1]))
}
for mod, facs := range dnr.Missing {
ok := false
for _, f := range facs {
if f == kernelmod.FacConfig || f == kernelmod.FacKernelIO {
ok = true
}
}
if !ok {
t.Errorf("★ 模块 %s 被报缺 %v,但它缺的应是 config/kernel-io(整层消失)", mod, facs)
}
}
// 一个模块都不该跑过:IO / EventBus 是 kernel-api 的产物,应仍为 nil。
if ks.IO != nil || ks.EventBus != nil {
t.Error("★ 第 0 层被判缺依赖后仍有模块被执行(IO/EventBus 非 nil)")
}
}

View File

@ -1,543 +0,0 @@
package all
// 装配级安全判据(2026-10-30)。
//
// ## 为什么必须新开一个文件、而不是加在 security_toolgate_test.go 里
//
// 那边 4 条判据全绿了**整整一轮发布**,而生产里的闸门根本没接上。原因很简单:
//
// 它们自己调了 a.SetSecurity(...)。
//
// 判据把缺失的那根线补上了,所以永远绿 —— 测的是「接线之后行为对」,
// 而不是「生产有没有接线」。这是「机制 ≠ 接线」最纯粹的样本。
//
// 本文件钉的是**装配**:
//
// kernelmod.Registered() → kernelboot.Build → kernelboot.Run
// → security.Init 产出 ks.Security
// → agent.Init 把 ks.Security 装进 Agent
// → 真实工具执行链读到它(securityDecisionFor → CheckTool)
//
// ★ 变异要求(每条都在实现上验过,见文件末尾的变异表):
// ① 从 agent.Requires() 删掉 FacSecurity ⇒ 判据 1 红
// ② 从 buildAgent 的 AgentConfig 删 Security ⇒ 判据 2 红
// ③ 从 resident/rtchild 的 AgentConfig 删 Security ⇒ 判据 3 红
// ④ 从 New() 删掉 `security: cfg.Security` ⇒ 判据 2 红
// ⑤ 从 agent.Init 删掉 nil 检查 ⇒ 判据 4 红
import (
"strings"
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"testing"
agentcore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
"github.com/JianFeeeee/HologramAgent/internal/security"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
// ───────────────── 判据 1:agent 模块**声明**了 security 依赖 ─────────────────
// ★★★ 判据:`agent` 模块必须在 Requires() 里声明 FacSecurity。
//
// ## 为什么不能靠"层号碰巧对"来代替这条判据
//
// 即使不声明,security 也在第 1 层、agent 在第 4 层 ⇒ `ks.Security` 恰好
// 已被填好,运行期照样能读到。**这正是这个缺陷藏了整轮的原因。**
//
// 但那个"恰好"是偶然的:security 只依赖 config,所以天然在前面。一旦
// security 将来加了自己的依赖(比如判某些策略要先读 llm 配置),它就会被推到
// agent 之后 —— 那时 agent 读到一个 nil 的 ks.Security,而**图不会拦**,
// 因为图根本不知道 agent 需要它。
//
// 所以这条判据断言的是**声明**,不是顺序。与 TestHiddenDependencyOnnxDeclared
// 同型(那条也明确写了"不能用层号断言")。
func TestAgentDeclaresSecurityDependency(t *testing.T) {
var matched *kernelboot.Module
var requires []string
for _, m := range kernelmod.Registered() {
if m.Name() != "agent" {
continue
}
requires = m.Requires()
matched = &m
}
if matched == nil {
t.Fatal("★ agent 模块没注册")
}
for _, r := range requires {
if r == kernelmod.FacSecurity {
return
}
}
t.Fatalf("★ agent 模块未声明对 %q 的依赖(当前 Requires=%v)。\n"+
" 后果:buildAgent 读 ks.Security 就成了**隐式顺序依赖** ——\n"+
" 今天恰好成立(security 在第 1 层),但那是运气不是设计,\n"+
" 而隐式顺序依赖正是本仓模块化设计要消灭的形态。\n"+
" 修法:kernelmod/agent 的 Requires() 加 kernelmod.FacSecurity。",
kernelmod.FacSecurity, requires)
}
// ───────────────── 判据 2:装配后 Agent 的执行链真的读到它 ─────────────────
// ★★★ 判据:走真实登记表装配整机后,给 `ks.Security` 配的黑名单
// **必须**能拦住 Agent 的工具执行链。
//
// ## 与前一代判据的区别(本文件存在的全部理由)
//
// 旧判据:`a := newAgent(); a.SetSecurity(rt); assert(a.securityDecisionFor(...))`
//
// ⇒ 自己补线,永远绿。
//
// 本判据:不碰 SetSecurity,只用**模块装配**产出的 ks.Agent 与 ks.Security,
//
// 并且走**真实执行入口** `ExecuteTool`(即 api.ToolHost,生产里
// device 与插件工具面都通过它进来)。
//
// ## 为什么用"黑名单里的**不存在**的工具"
//
// 闸门在 `executeToolCallInner` 里位于**分派之前**(参数校验之后)。
// 所以拿一个未注册的工具名去试:
//
// · 闸门生效 ⇒ 得到 "not permitted"
// · 闸门失效 ⇒ 落到分派,得到 "does not exist"
//
// 两者文本完全不同,无从混淆。这样判据不需要注册探针工具、不需要造插件,
// 也不会因为工具不存在而"碰巧"通过。
func TestAgentAssemblyWiresSecurity(t *testing.T) {
m := bootMachine(t)
ks := m.ks
if ks.Security == nil {
t.Fatal("★ security 模块没产出 ks.Security(装配链断了)")
}
if ks.Agent == nil {
t.Fatal("★ agent 模块没产出 ks.Agent")
}
const deniedTool = "boot_probe_denied_tool"
// ① 先证明这个工具名**没被注册**(否则下面"not permitted"可能来自别处)。
// ks.Agent 在挂载点里存 any(kstate 不能依赖 agent/core,否则成环)。
// 本判据要真实执行工具,断言回具体类型。
agentImpl, _ := ks.Agent.(*agentCore.Agent)
if agentImpl == nil {
t.Fatal("ks.Agent 不是 *agentCore.Agent")
}
out, _ := agentImpl.ExecuteTool(deniedTool, nil)
if strings.Contains(out, "not permitted") {
t.Fatalf("前置不成立:未配黑名单时就已被拒(%q)—— 检查环境里的实际策略", out)
}
// ② 关键一步:给**模块化产出**的 ks.Security 装黑名单。
// 如果 Agent 里拿的是另一个实例(或 nil),它看不到这条 ⇒ 判据红。
ks.Security.SetPolicy(security.Policy{ToolDeny: []string{deniedTool}})
out, _ = agentImpl.ExecuteTool(deniedTool, nil)
if !strings.Contains(out, "not permitted") {
t.Fatalf("★ 已给 ks.Security 配 %q 黑名单,Agent 的执行链却没拦住(实际返回 %q)。\n"+
" 可能原因(按概率):\n"+
" ① buildAgent 没把 ks.Security 传进 AgentConfig.Security\n"+
" ② New() 没把 cfg.Security 写进 a.security\n"+
" ③ 执行链上少了 securityDecisionFor 这道闸\n"+
" 三者任一都会让策略表正确而完全无效,且日志里一条拒绝都没有。",
deniedTool, out)
}
// ③ 反向:未命中的工具必须放行。
// 否则是"全拒"型假绿(闸门写死了),而不是接线正确。
if out, _ := agentImpl.ExecuteTool("boot_probe_unlisted_tool", nil); strings.Contains(out, "not permitted") {
t.Fatalf("未命中黑名单的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", out)
}
}
// ───────────────── 判据 4:装配失败时响亮,而不是静默放行 ─────────────────
// ★★★ 判据:`ks.Security == nil` 时 agent 模块必须**拒绝装配**。
//
// 防的失败方式:把 nil 一路传下去 —— Agent 装好了、日志写着 "kernel ready"、
// 工具照常执行,只是策略永远不生效。本项目已经踩过一次这个坑
// ("策略表存在、判定函数正确、日志里一条拒绝都没有")。
//
// 注意区分:配置显式关闭安全模块时装的是 **Disabled Runtime(非 nil)**,
// 所以本判据不会误伤"用户选择不用安全模块"这个合法场景。
//
// 实现要点:用**真实模块的 Init**,不是一个复制品。bootMachine 已把整机跑过
// 一遍(所有依赖真实就绪),这里借它的 KernelState 复制一份、只破坏一个前提。
// agent.Init 的 nil 检查在所有副作用之前(第一个 if),所以本判据不产生资源。
func TestAgentAssemblyRefusesNilSecurity(t *testing.T) {
m := bootMachine(t)
base := m.ks
if base.Security == nil {
t.Fatal("前置失败:整机装配没产出 Security")
}
var agentMod kernelboot.Module
for _, mod := range kernelmod.Registered() {
if mod.Name() == "agent" {
agentMod = mod
}
}
if agentMod == nil {
t.Fatal("★ agent 模块没注册")
}
// ★ 不能复制 KernelState(它含 sync.Mutex,复制即 vet 报错,且真的不安全)。
// 改为新建一个:把**真实状态里已就绪的全部依赖**逐字段搬过去,
// 唯独 Security 留 nil —— 这样 Init 只会因为这一个前提失败。
//
// 为什么这样做而不是直接用 base:Init 成功时会覆盖 base.Agent,
// 破坏后续判据的上下文。新建一份是干净的。
var probe kstate.State
probe.Cfg = base.Cfg
probe.CfgReg = base.CfgReg
probe.DataDir = base.DataDir
probe.AgentWorkDir = base.AgentWorkDir
probe.CLISocket = base.CLISocket
probe.HTTPAddr = base.HTTPAddr
probe.Ctx = base.Ctx
probe.Memory = base.Memory
probe.LLMProvider = base.LLMProvider
probe.LLMManager = base.LLMManager
probe.Tracker = base.Tracker
probe.DocStore = base.DocStore
probe.Knowledge = base.Knowledge
probe.TextMemory = base.TextMemory
probe.StageHost = base.StageHost
probe.PluginRegistry = base.PluginRegistry
probe.EventBus = base.EventBus
probe.Embedder = base.Embedder
probe.MultimodalSpace = base.MultimodalSpace
probe.MultimodalProviderName = base.MultimodalProviderName
probe.MultimodalErr = base.MultimodalErr
probe.BaseAPIKey = base.BaseAPIKey
probe.Security = nil // ← 唯一被破坏的前提
err := agentMod.Init(&probe)
if err == nil {
t.Fatal("★ ks.Security 为 nil 时 agent 模块居然装配成功了 —— " +
"nil 会被一路传到 securityDecisionFor,那里恒放行且不留任何日志。\n" +
" 必须在 Init 里响亮失败(与 device 模块对 ks.Security 的检查同型)。")
}
if !strings.Contains(err.Error(), "security") {
t.Fatalf("失败信息必须点名缺失的设施(便于运维一眼定位),实际: %v", err)
}
}
// ───────────────── 判据 4:父为子**声明**策略,而不是子继承父的权限 ─────────────────
// ★★★ 判据:父创建子 agent 时,父声明的工具策略必须**真的限制住那个子**,
// 且**只影响那个子**(不是全局)。
//
// ## 为什么这版取代了上一版(用户裁定 2026-10-30)
//
// 上一版的判据断言「子继承父的 `a.security`」—— 那只保证了**机制**到位,
// 却把权限模型写错成了「子 = 父的完整权限」。
//
// 正确模型是 **父为子声明策略**:
//
// 父:能调全部工具(受全局策略约束)
// 子:父在创建时决定它能调什么 —— 内核持有并执行,子不能自己扩大
//
// 这不是新机制:`security.CheckToolFor(Principal, tool)` 与
// `PrincipalPolicy{AllowTools, DenyTools}` 早就在树里,只是**零生产调用方**
// (与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
//
// ## 本判据钉三件事
//
// ① 父声明的 deny 真的拦住**子**
// ② 同一条策略**不影响父**(不是全局策略)
// ③ 子销毁后策略被撤销(同名重建不能继承上一代父的约束)
func TestParentDeclaresChildPolicy(t *testing.T) {
rt := security.NewRuntime(nil)
// 父:parentID 为空 ⇒ 走 CheckTool(只受全局策略约束)。
const forbidden = "resident_denied_tool"
parent := agentcore.New(agentcore.AgentConfig{
ID: "main",
Provider: nil,
Security: rt,
})
if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed {
t.Fatalf("父不应受限(它是内核自身,此工具不受任何全局策略限制): %s", d.Reason)
}
// 父为子声明策略:允许 probe_ok_*,禁止 forbidden。
childID := "policy-probe-child"
if err := parent.ProbeDeclareChildPolicy(childID, []string{"probe_ok_*"}, []string{forbidden}); err != nil {
t.Fatalf("父为子声明策略失败: %v", err)
}
// ① 子被判住:子 parentID 非空 ⇒ 走 CheckToolFor(Principal{self})
child := agentcore.New(agentcore.AgentConfig{
ID: types.AgentID(childID),
ParentID: "main",
Provider: nil,
Security: rt, // 同一个 Runtime 实例(它只是机制,权限靠主体声明)
})
if d := child.ProbeSecurityDecision(forbidden); d.Allowed {
t.Fatalf("★ 父已为子 %q 声明禁止 %q,子的判定却仍是 Allowed —— "+
"主体策略没有被工具执行链读到(CheckToolFor 未接线)", childID, forbidden)
}
if d := child.ProbeSecurityDecision("other_tool"); d.Allowed {
t.Fatal("★ 父给了 probe_ok_* 白名单,other_tool 却仍被放行 —— 白名单未生效")
}
if d := child.ProbeSecurityDecision("probe_ok_1"); !d.Allowed {
t.Fatalf("白名单内的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", d.Reason)
}
// ② 同一条策略不影响父(主体隔离)。
if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed {
t.Fatal("★ 子策略泄漏成了全局策略 —— 父也被禁了。主体隔离失效")
}
// ③ 撤销后子恢复(同名重建不能继承上一代父的约束)。
parent.ProbeRevokeChildPolicy(childID)
if d := child.ProbeSecurityDecision(forbidden); !d.Allowed {
t.Fatal("★ 策略已撤销,子却仍被禁 —— 主体表以调用方给的名字为键," +
"不清理就会让同名重建的新一代继承上一代父的约束(与 rtchild 留档串线同型)")
}
}
// ★★★ 判据:**销毁路径必须撤销策略**(两处对称:单数与复数)。
//
// 这条单列,因为本仓反复吃亏在"单数路径改了、复数路径漏了":
// `StopRTChild` 注销入站登记而 `StopRTChildren` 漏了,就是这么来的。
//
// ## 2026-11-04 适配:撤销动作已移入骨架
//
// R2/R3 把"撤销策略"从两个停止函数搬进了 `child.Registry.Cleanup`,
// 由各类型注入的 `StopDeps.RevokePolicy` 触发。于是判据需要钉**两个环节**:
//
// ① 骨架真的会调用注入的 RevokePolicy(否则注入等于没接)
// ② 各类型真的注入了它(否则骨架有地方调、但没人填)
//
// 只钉 ①:注入漏填时永远绿。
// 只钉 ②:骨架不调时永远绿。
//
// 两个环节都缺失才是真实缺口,所以必须**成对**断言。
//
// 行为版判据放在 `internal/agent/core`(那里有真实脚手架),
// 本处保留源码级断言,因为它能跨包检查"装配是否被人改掉"。
func TestChildTeardownRevokesPolicy(t *testing.T) {
// ① 骨架:Cleanup 必须调用 deps.RevokePolicy。
//
// 变红条件:删掉 registry.go 里那两行 ⇒ 红。
reg := readMod(t, "../../agent/core/child/registry.go")
ci := strings.Index(reg, "func (r *Registry) Cleanup(")
if ci < 0 {
t.Fatal("找不到 Registry.Cleanup —— 符号改名了?判据需要跟着改")
}
cleanupBody := reg[ci:]
if end := strings.Index(cleanupBody, "\n}\n"); end > 0 {
cleanupBody = cleanupBody[:end]
}
if !strings.Contains(cleanupBody, "deps.RevokePolicy") {
t.Error("★ Cleanup 没有调用 deps.RevokePolicy —— 子策略永远不会被撤销。\n" +
" 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n" +
" 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。")
}
// ② 各类型:必须往 StopDeps 里填 RevokePolicy。
//
// rt 走 children.StopOne/StopAll ⇒ 由 rtStopDeps 提供。
// 驻留子走自己的 teardownResidentWith ⇒ 那里直接调。
cases := []struct{ file, anchor, needle, why string }{
{"../../agent/core/rtchild.go", "func (a *Agent) rtStopDeps(",
"RevokePolicy: a.revokeChildPolicy",
"rtchild 的 StopDeps 必须注入撤销动作"},
{"../../agent/core/resident.go", "func (a *Agent) teardownResidentWith(",
"revokeChildPolicy",
"驻留子销毁必须撤销父为它声明的策略"},
}
for _, c := range cases {
src := readMod(t, c.file)
i := strings.Index(src, c.anchor)
if i < 0 {
t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.anchor)
}
body := src[i:]
if end := strings.Index(body, "\n}\n"); end > 0 {
body = body[:end]
}
if !strings.Contains(body, c.needle) {
t.Errorf("★ %s 里没有 %q —— %s。\n"+
" 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n"+
" 上一代父留下的约束,且表单调增长。", c.anchor, c.needle, c.why)
}
}
// ③ 单数与复数必须**共用同一条骨架路径**(这是"对称"的结构保证)。
//
// 变红条件:StopRTChildren 改回自己写一遍循环 + 撤销 ⇒ 红。
rt := readMod(t, "../../agent/core/rtchild.go")
for _, fn := range []string{
"func (a *Agent) StopRTChild(", "func (a *Agent) StopRTChildren(",
} {
i := strings.Index(rt, fn)
if i < 0 {
t.Fatalf("找不到 %s", fn)
}
body := rt[i:]
if end := strings.Index(body, "\n}\n"); end > 0 {
body = body[:end]
}
if !strings.Contains(body, "a.children.Stop") {
t.Errorf("★ %s 没走骨架的 StopOne/StopAll —— 单数与复数各写一遍时,"+
"必然出现\"改一处漏一处\"(engineering-lessons §2.3)", fn)
}
}
}
// ★★★ 判据:创建路径必须在**子开始执行前**声明策略。
//
// 顺序错(比如在 go carrier.rtLoop 之后声明)会留下一个"无约束执行"窗口:
// 子一接到输入就会跑工具,那时策略可能还没进内核。
func TestPolicyDeclaredBeforeChildStarts(t *testing.T) {
cases := []struct{ file, declAnchor, startAnchor, name string }{
{"../../agent/core/resident.go", "declareChildPolicy(opts.ID", "child.Start()", "驻留子"},
{"../../agent/core/rtchild.go", "declareChildPolicy(opts.ID", "go carrier.rtLoop(rc)", "rtchild"},
}
for _, c := range cases {
src := readMod(t, c.file)
di := strings.Index(src, c.declAnchor)
si := strings.Index(src, c.startAnchor)
if di < 0 || si < 0 {
t.Fatalf("%s: 找不到锚点(decl=%v start=%v)", c.file, di >= 0, si >= 0)
}
if di > si {
t.Errorf("★ %s:策略声明在 `%s` **之后** —— 存在一个「子已开始执行、策略尚未生效」的窗口",
c.name, c.startAnchor)
}
}
}
// ───────────────── 判据 5:策略语义只能在 security 内部 ─────────────────
// ★★★ 判据:`agent/core` **不得**再做策略决策。
//
// ## 收敛前它做了什么(2026-10-31)
//
// security_profile.go 里 214 行,其中绝大部分是策略逻辑:
// · 自己从 payload 抠 `security_profile` 字段
// · 自己判断 "parentID != "" → 走 CheckToolFor,否则 CheckTool"
// · 自己拼 `PrincipalPolicy{AllowTools:…, DenyTools:…}` 字面量
// · 自己写 `Kind: "resident"` **声明自己的主体类别**
//
// 最后一条是提权口子:调用方说自己是内部主体,就享受内部豁免
// (不过 HardPrivateTools)。类别必须由 security 从**事实**推出。
//
// ## 本判据用什么钉
//
// 源码断言(跨包行为断言做不到"禁止某种写法")。三类越界各一条正则:
//
// ① `payload["security_profile"]` 出现在 agent/core ⇒ 解析回落调用方
// ② `Kind:` 字面量出现在 agent/core 的安全文件 ⇒ 调用方声明类别
// ③ `CheckTool(` / `CheckToolFor(` 直接出现在 agent/core ⇒ 越级选链
//
// ★ 例外:`security_profile.go` 的**注释**里会提到这些名字(解释历史)。
// 所以逐行判断,跳过纯注释行。
func TestAgentCoreHoldsNoPolicySemantics(t *testing.T) {
src := readMod(t, "../../agent/core/security_profile.go")
// 会被禁止的代码形态(不含注释)。
banned := []struct{ pattern, why string }{
{`payload["security_profile"]`,
"档位字段名是 security 的协议,解析必须留在 security.BeginRound"},
{`Kind:`,
"主体类别必须由 security 从事实(有无父)推出,调用方不得声明"},
{`.CheckToolFor(`,
"选哪条判定链是策略决策,调用方只该调 Round.CheckTool"},
{`PrincipalPolicy{`,
"策略结构由 security 组装,调用方只该传 allow/deny 列表"},
}
for _, b := range banned {
for i, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue // 注释里解释历史是允许的
}
if strings.Contains(line, b.pattern) {
t.Errorf("★ agent/core/security_profile.go:%d 出现 %q —— %s\n\t%s",
i+1, b.pattern, b.why, trimmed)
}
}
}
}
// ★★★ 判据:主体类别由 security 从**事实**推出,且**不信**调用方声明。
//
// 收敛前 `Kind: "resident"` 由 agent 写死 —— 它同时意味着"我是内部"
// 与"我不受收窄",把两件不同的事绑在一个字符串上。现在分类规则只有一处
// (`classifyPrincipal`),本判据钉住三条映射。
func TestPrincipalKindIsDerivedBySecurity(t *testing.T) {
rt := security.NewRuntime(nil)
// ① 有父 ⇒ 子执行体(内部豁免,但可被父收窄)
if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "kid", IsChild: true}); p.Kind != security.KindSubAgent {
t.Fatalf("有父的执行体应归为 KindSubAgent,实际 %q", p.Kind)
} else if !p.IsInternal() || !p.IsSubAgent() {
t.Fatal("KindSubAgent 必须同时满足 IsInternal(豁免 HardPrivate)与 IsSubAgent(可被收窄)")
}
// ② 无 id ⇒ 内核自身
if p := rt.ClassifyPrincipalForTest(security.RoundInput{}); p.Kind != security.KindInternal {
t.Fatalf("无 id 应归为 KindInternal,实际 %q", p.Kind)
}
// ③ 有 id 无父 ⇒ 用户
if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "qq-user-1"}); p.Kind != security.KindUser {
t.Fatalf("经插件通道进来的应归为 KindUser,实际 %q", p.Kind)
}
// ④ 枚举不接受自由字符串:拼错必须能被识别(而不是静默变成用户主体)
if _, ok := security.ParsePrincipalKind("residnet"); ok {
t.Fatal("★ 拼错的主体类别被当成合法值 —— 拼写错误会静默漂移权限语义")
}
if k, ok := security.ParsePrincipalKind("resident"); !ok || k != security.KindSubAgent {
t.Fatal("旧值 resident 应兼容映射为 KindSubAgent(不破坏既有调用方)")
}
}
// ★★★ 判据:轮次边界在**全部输入路径**上都被声明。
//
// 收敛把判定逻辑收到了一处,但"一轮何时开始/结束"只有各路径自己知道。
// 本仓已有三条独立输入路径,漏一条就是"那类 agent 永远没有档位":
//
// 主 agent + 驻留子 → runInputTask(驻留子有自己的 Agent 实例)
// rtchild → rtRunOnce(不走调度器,单独一条)
//
// 用源码断言:真跑三条路径需要完整记忆栈/rt 通道,
// 而"前置不满足就 skip"的判据等于没有(与 TestChildTeardownRevokesPolicy 同一理由)。
func TestEveryInputPathDeclaresRound(t *testing.T) {
cases := []struct{ file, fn, why string }{
{"../../agent/core/task.go", "func (a *Agent) runInputTask(",
"主 agent 与驻留子共用的输入入口"},
{"../../agent/core/rtchild.go", "func (a *Agent) rtRunOnce(",
"rtchild 不走调度器、不经过 runInputTask,是独立的一条输入路径"},
}
for _, c := range cases {
src := readMod(t, c.file)
i := strings.Index(src, c.fn)
if i < 0 {
t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.fn)
}
body := src[i:]
if end := strings.Index(body, "\n}\n"); end > 0 {
body = body[:end]
}
if !strings.Contains(body, "beginSecurityRound(") {
t.Errorf("★ %s 里没有 beginSecurityRound —— %s 缺少安全轮次边界。\n"+
" 后果:这条路径上的输入永远不会激活插件声明的档位,\n"+
" 而它在**其他**路径上是生效的 ⇒ 表现为「时灵时不灵」,最难查。",
c.fn, c.why)
}
if !strings.Contains(body, "End()") {
t.Errorf("★ %s 里没有 End() —— 档位会在轮次结束后泄漏到下一轮,"+
"guest 的收窄误伤后续输入(方向相反就是提权)", c.fn)
}
}
}

View File

@ -1,260 +0,0 @@
package all
// 判据:**生产装配路径必须真的接通**(2026-11-04)。
//
// ## 这组判据补的是一个真实的"测试绿、生产坏"
//
// 我第一版实现里,`agent/core` 的 renderSystemPrompt 已经从
// `kstate.Get().PromptSections` 读段,测试也全绿 —— 但**生产上是坏的**:
//
// · `kstate.Mount(ks)` 在生产路径上**没有任何调用者** ⇒ Get() 永远 nil
// · `sdk.SetSystemPromptAPI` 生产上没人调 ⇒ 插件声明拿 nil API
//
// 测试为什么绿?因为测试用 `MountForTest` 自己挂了挂载点。
// 这就是本仓反复吃过的亏(engineering-lessons §1.1):
//
// 判据把缺失的那根线补上了,所以永远绿。
//
// 所以本组判据不看"行不行",只看**装配代码里有没有那根线**——
// 它们失败时指向的是一行代码,而不是一个行为。
import (
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
)
// TestKstateIsMountedByRun 钉住 kernelboot.Run 会装入统一挂载点。
//
// ## 为什么必须是这一条
//
// `kstate.Get()` 是底层包(agent/core 等)读共享数据的**唯一**入口。
// 它返回 nil 时所有读取都走 `if ks != nil` 的分支 ⇒ **静默跳过**。
//
// 静默跳过比报错危险得多:插件声明的技能索引不渲染,模型看不到任何
// skill 说明,于是凭工具名猜用法 —— 那正是"拿 terminal_create 当
// cmd_run"的根因。而日志里一个字都没有。
//
// 变红条件:删掉 kernelboot.Run 里的 kstate.Mount(ks) ⇒ 红。
func TestKstateIsMountedByRun(t *testing.T) {
src, err := os.ReadFile("../../kernelboot/kernelboot.go")
if err != nil {
t.Fatalf("读 kernelboot.go: %v", err)
}
if !strings.Contains(string(src), "kstate.Mount(ks)") {
t.Fatal("★ kernelboot.Run 没有调 kstate.Mount(ks)\n\n" +
"后果:kstate.Get() 永远返回 nil ⇒ 所有读共享数据的代码\n" +
"都走 `if ks != nil` 的跳过分支 ⇒ **静默不生效**(不是报错)。\n\n" +
"具体症状:插件声明的系统提示词段一条都不渲染,模型看不到\n" +
"任何 skill 索引,只能凭工具名猜用法。")
}
}
// TestPromptSectionsWiredToPlugins 钉住插件声明面被真正注入。
//
// 三处缺一不可:
//
// kernelmod/stagehost 产出 ks.PromptSections(注册表本身)
// kernelmod/pluginhost 从 ks 取出来 → reg.SetPromptSections
// internal/sdk reg 把它传进 SDKConfig → SetSystemPromptAPI
//
// 缺任何一处,插件的 `s.SystemPrompt()` 都返回 nil,而 skillmgr 的
// publishIndex 对 nil 是**静默返回**(它不能 panic,因为插件可能跑在
// 没有该能力的宿主里)—— 于是技能索引永远进不了提示词。
//
// 这三条分开写而不是合成一条:失败时要一眼看出断在哪一节。
func TestPromptSectionsWiredToPlugins(t *testing.T) {
// ① 注册表被产出
stagehost, err := os.ReadFile("../../kernelmod/stagehost/mod.go")
if err != nil {
t.Fatalf("读 stagehost/mod.go: %v", err)
}
if !strings.Contains(string(stagehost), "ks.PromptSections = ") {
t.Fatal("★ stage-host 模块没有产出 ks.PromptSections —— 注册表不存在")
}
// ② 被注入给插件注册表
pluginhost, err := os.ReadFile("../../kernelmod/pluginhost/mod.go")
if err != nil {
t.Fatalf("读 pluginhost/mod.go: %v", err)
}
if !strings.Contains(string(pluginhost), "reg.SetPromptSections(") {
t.Fatal("★ plugin-host 模块没有把 ks.PromptSections 注入给插件注册表\n\n" +
"后果:sdk.SystemPrompt() 返回 nil ⇒ skillmgr.publishIndex 静默返回\n" +
"⇒ 技能索引永远进不了系统提示词。")
}
// ③ 被传进 SDK 并被接上
sdkplug, err := os.ReadFile("../../sdk/plugin.go")
if err != nil {
t.Fatalf("读 sdk/plugin.go: %v", err)
}
sdkText := string(sdkplug)
if !strings.Contains(sdkText, "SetSystemPromptAPI(") {
t.Fatal("★ internal/sdk 没有调用 SetSystemPromptAPI\n\n" +
"后果:插件的 s.SystemPrompt() 返回 nil,DeclarePromptSection 无处落地。")
}
// 注意匹配的是**字段声明**(`PromptSections *promptsec.Registry`),
// 不是赋值写法(`PromptSections:` 只出现在 registry.go 的 SDKConfig
// 字面量里)。第一版判据写成了冒号形式,于是把已经接好的代码判成红。
if !strings.Contains(sdkText, "PromptSections *promptsec.Registry") {
t.Fatal("★ SDKConfig 没有 PromptSections 字段 —— 插件声明的段无处传递")
}
}
// TestSharedDataInjectionsHaveProducers 是**通用**的一条。
//
// 本轮重构引入了若干"注入点"(Setter),每一个都要求有人调它。
// 没人调的注入点 = 功能静默缺失。
//
// 这条判据列出的每一项,都必须在生产代码(非 _test)里存在调用者。
// 它是从上面那两次真实事故里抽象出来的:
//
// · kstate.Mount 漏了 ⇒ Get() 永远 nil
// · SetPromptSections 漏了 ⇒ 插件声明无处落地
//
// 变红条件:新增一个注入点却忘了接 ⇒ 在这里补一行就会红,直到真接上。
func TestSharedDataInjectionsHaveProducers(t *testing.T) {
// 注入点 → 期望在哪个包里找到生产者(子串匹配,跨文件搜索)。
wants := []struct {
method string
where string // 生产代码里的调用形态
}{
{"kstate.Mount", "kstate.Mount(ks)"},
{"plugin.Registry.SetPromptSections", "reg.SetPromptSections("},
{"sdk.SetSystemPromptAPI", "SetSystemPromptAPI("},
{"plugin.Registry.SetSecurityRuntime", "reg.SetSecurityRuntime("},
{"plugin.Registry.SetSecretStore", "reg.SetSecretStore("},
{"config.SetCodec", "cfgReg.SetCodec("},
}
for _, w := range wants {
found := false
// 扫所有非 _test 的 Go 文件。
roots := []string{"../../kernelboot", "../../kernelmod", "../../sdk", "../../plugin", "../../config", "../../agent"}
for _, root := range roots {
if filepathContains(t, root, w.where) {
found = true
break
}
}
if !found {
t.Fatalf("★ 注入点 %s 在生产代码里没有调用者(找 %q)\n\n"+
"没人调的注入点 = 功能静默缺失:拿不到注入的那一方会走\n"+
"\"未装配就跳过\"的分支,日志里一个字都没有。", w.method, w.where)
}
}
}
// filepathContains 递归搜索目录下所有非测试 Go 文件是否包含 substr。
func filepathContains(t *testing.T, dir, substr string) bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
p := dir + "/" + e.Name()
if e.IsDir() {
if filepathContains(t, p, substr) {
return true
}
continue
}
if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
continue
}
b, err := os.ReadFile(p)
if err != nil {
continue
}
if strings.Contains(string(b), substr) {
return true
}
}
return false
}
// TestRunActuallyMountsState 是**端到端**的那一条(其余是源码判据)。
//
// ## 为什么源码判据不够
//
// TestKstateIsMountedByRun 只确认"那行代码存在"。它证明不了:
//
// · 那行在**真实装配流程**里被执行到(可能被 early return 跳过)
// · Mount 的是**同一个** State(而不是另一份)
// · 装配完成后 ks.PromptSections 真的被 stage-host 填上了
//
// 所以这条跑真实 `kernelboot.Run` + 真实模块登记表,然后从**底层包的
// 视角**(kstate.Get())检查能不能看到东西。
//
// ## 为什么这条能防住我犯的那个错
//
// 第一版实现里 agent/core 读 kstate.Get(),而生产上没人 Mount ⇒ 永远 nil。
// 测试全绿是因为测试自己 MountForTest。本判据**不自己 Mount**,
// 只读 Run 之后的状态 —— 所以它能抓到"没人 Mount"。
func TestRunActuallyMountsState(t *testing.T) {
// 先清空挂载点,确保"绿"只能来自 Run 自己。
restore := kstate.MountForTest(nil)
defer restore()
if kstate.Get() != nil {
t.Fatal("前置不成立:清空挂载点失败")
}
// 用**完整** fixture:emptyState 缺 CfgReg,onnx 模块会直接解引用 nil
// (实测 panic)。这里按 bootgraph_test.go 的整机夹具建 state。
// ★ 不用 t.TempDir():整机装配会挂 agentfs/merged(真实 overlay 挂载点),
// t.TempDir 的严格 RemoveAll 会报 EBUSY。改用与 bootgraph_test.go 相同的
// "重试删除"策略(Linux 上 dentry 回收可能稍晚)。
dir, err := os.MkdirTemp("", "wiring-mounts-")
if err != nil {
t.Fatalf("建临时目录失败: %v", err)
}
t.Cleanup(func() {
_ = removeAllWithRetry(dir, 50)
})
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
t.Cleanup(func() { reg.Close() })
ks := kstate.New()
ks.CfgReg = reg
ks.DataDir = dir
ks.AgentWorkDir = filepath.Join(dir, "agentfs")
ks.Cfg = reg.ToConfig()
ks.Cfg.Daemon.DataDir = dir
ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins")
ks.HTTPAddr = "127.0.0.1:0"
plan := realPlan(t)
if _, err := kernelboot.Run(plan, kernelboot.RunOptions{
State: ks, Bus: kstate.NewSyncBus(), MaxParallel: 2,
}); err != nil {
t.Skipf("整机装配未通过(%v)—— 本判据依赖完整装配,跳过而不是假绿", err)
}
// ① 挂载点必须是 Run 装入的那一份。
got := kstate.Get()
if got == nil {
t.Fatal("★ kernelboot.Run 跑完了,但 kstate.Get() 仍是 nil\n\n" +
"后果:所有读共享数据的底层包(agent/core 等)都走\n" +
"`if ks != nil` 的跳过分支 ⇒ 静默不生效。\n" +
"测试之所以没发现,是因为测试自己调了 MountForTest ——\n" +
"判据把缺失的那根线补上了,所以永远绿。")
}
if got != ks {
t.Fatal("★ Run 装入的挂载点不是传入的那个 State —— 底层包读到的会是另一份")
}
// ② stage-host 必须已产出插件声明式提示词段的注册表。
if got.PromptSections == nil {
t.Fatal("★ 装配完成后 ks.PromptSections 仍是 nil\n\n" +
"stage-host 模块应产出它。缺了它 ⇒ 插件声明无处落地 ⇒\n" +
"skillmgr 的技能索引永远进不了系统提示词。")
}
}