Просмотр исходного кода

fix(boot): keep startup metadata out of ordinary error output

turtle1999 4 недель назад
Родитель
Сommit
a9b7491cf8

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: f6828cbff8c68d1ea3f6337053af39fa7de59989
-README.zh.md: d458af673538a7d2e2d5939bb017022e4dd4a143
+README.md: 829456a4d11819ebcc77506ca3795fb8f8e996c7
+README.zh.md: adb5c870fb39792a2458358c1328b80a90ff965d

+ 1 - 1
packages/boot/app-boot/README.md

@@ -120,7 +120,7 @@ This section explains how the outcomes above are realized and points at the code
 - **One rejection checkpoint.** `inactiveEntries` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Two-stage failure labels.** Outside startup audit failures, `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack. Plugin diagnostics retain nested causes and aggregate member failures; cyclic causes stop traversal without replacing the original error.
 
-The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles.
+The startup error also retains inactive-entry metadata and raw startup warning/error records without retaining the Loader tree. Its `entries` and `startup` fields are non-enumerable: direct access and full diagnostic reports retain them, while ordinary error inspection omits them. Pending-only failures have no `cause`; failures with recorded errors retain their original values in an `AggregateError`. Import errors are collected through the logger before the Loader mounts because no failed Fiber exists for those imports. The temporary exporter is removed when boot settles.
 
 ### Helper behavior
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -120,7 +120,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
 - **单一 rejection 检查点。** `inactiveEntries` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** 除启动审计失败外,`boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`,并追加最深层插件错误的堆栈。插件诊断保留嵌套原因和聚合错误中的各项失败;原因链出现循环时会停止遍历,但不会替换原始错误。
 
-启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。
+启动错误还保留未激活条目的元数据和原始启动警告、错误记录,不保留 Loader tree。其 `entries` 和 `startup` 字段不可枚举:直接访问和完整诊断报告保留这些字段,常规错误检查输出则省略它们。只有等待条目时没有 `cause`;存在已记录错误时,`AggregateError` 保留其原始值。收集器在 Loader 挂载前通过 logger 收集导入错误,因为这些导入尚无 failed Fiber。启动结算后会移除临时 exporter。
 
 ### Helper 行为
 

+ 6 - 3
packages/boot/app-boot/src/index.ts

@@ -742,7 +742,7 @@ interface StartupLogRecord {
   args: readonly unknown[]
 }
 
-/** Startup audit failure with a concise message and original diagnostic data. */
+/** Startup audit failure with non-enumerable metadata and original failures as its cause. */
 export class StartupError extends Error {
   /** Root configuration and startup logs, attached by boot after disposal. */
   startup?: { configurationPath: string; messages: readonly StartupLogRecord[] }
@@ -752,8 +752,11 @@ export class StartupError extends Error {
    * @param entries - inactive plugin metadata and original failure values.
    */
   constructor(message: string, readonly entries: readonly StartupEntryDiagnostic[]) {
-    super(message, {
-      cause: new AggregateError(entries.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : []), 'Plugin activation failures'),
+    const failures = entries.flatMap(({ outcome }) => outcome.kind === 'failed' ? [outcome.error] : [])
+    super(message, failures.length > 0 ? { cause: new AggregateError(failures, 'Plugin activation failures') } : undefined)
+    Object.defineProperties(this, {
+      entries: { enumerable: false },
+      startup: { enumerable: false },
     })
   }
 }

+ 34 - 0
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -2,6 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod
 import { tmpdir } from 'node:os'
 import { join, resolve, sep } from 'node:path'
 import { pathToFileURL } from 'node:url'
+import { inspect } from 'node:util'
 import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -686,6 +687,39 @@ describe('auditStartupEntries', () => {
     expect(warn).not.toHaveBeenCalled()
   })
 
+  it('omits an error cause when required plugins are only waiting for services', async () => {
+    const error = await auditStartupEntries(ctxWith([
+      { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
+    ]), NAME, vi.fn()).catch((error: unknown) => error)
+    expect(error).toBeInstanceOf(StartupError)
+    expect(Object.hasOwn(error as StartupError, 'cause')).toBe(false)
+    expect(inspect(error)).not.toContain('AggregateError')
+    expect((error as StartupError).message).toContain('Plugins waiting for services (1):')
+  })
+
+  it('keeps diagnostic metadata available without expanding it in ordinary error inspection', () => {
+    const entries = [{
+      id: 'connection', module: './connection.mjs', required: true, fiberState: 0,
+      outcome: { kind: 'pending' as const, missing: ['webRuntime'] },
+    }]
+    const error = new StartupError('waiting for webRuntime', entries)
+    const startup = { configurationPath: '/private/cordis.yml', messages: [
+      { ts: 1, name: 'loader', type: 'warn', args: ['raw diagnostic argument'] },
+    ] }
+    error.startup = startup
+    expect(error.entries).toBe(entries)
+    expect(error.startup).toBe(startup)
+    const output = inspect(error)
+    expect(output).toContain('waiting for webRuntime')
+    expect(output).not.toContain('connection.mjs')
+    expect(output).not.toContain('/private/cordis.yml')
+    expect(output).not.toContain('raw diagnostic argument')
+    const full = inspect(error, { showHidden: true, depth: null })
+    expect(full).toContain('connection.mjs')
+    expect(full).toContain('/private/cordis.yml')
+    expect(full).toContain('raw diagnostic argument')
+  })
+
   it('retains nested and shared errors in a fatal diagnostic without duplicating them', async () => {
     const leaf = new Error('leaf failure')
     leaf.stack = 'Error: leaf failure\n    at plugin.mjs:1:2'