feat: add unified command result framework core

This commit is contained in:
玉澜 committed 2026-08-10 12:22:46 +08:00
1 parent cf3bcb380f
commit 2dd067562e
40 files changed
+9463 -74

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
# DWS Unified Command Framework 2.0 设计概要
> 状态:Framework core 提案。本文只定义框架能力与集成边界;产品命令迁移、Skill 更新和真实服务复验由后续 PR 独立完成。
## 1. 产品裁决
1. 不公开 `--output-contract`,也不增加任何等价别名。
2. Agent 继续只使用既有 `--format json`。
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
5. 回滚是命令声明与发布行为,不改变消费者 argv。
6. 本 PR 不迁移任何产品命令;已有命令路径、参数和输出保持不变。
## 2. 渐进迁移
内部状态机:
```text
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
```
- `legacy_only`:只构造、输出 legacy。
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
- `unified_only`:清理仅服务 legacy 的产品 renderer。
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
## 3. 统一结果
Framework 2.0 统一表达四类结果:
```text
success 请求完成且命令认为操作已完成
pending 请求被受理,但异步操作尚未终结
partial_failure 批量操作有成功项,也有失败或未知项
failure 请求或操作失败
```
JSON 基本形态:
```json
{
"ok": true,
"outcome": "success",
"data": {}
}
```
硬不变量:
```text
ok == (outcome in {success, pending})
process rc == 0 <=> ok == true
top-level error present <=> outcome == failure
one invocation emits exactly one primary result
```
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
## 4. 输出与错误纪律
- 统一 JSON/NDJSON 的 primary result 写 stdout;stderr 只写诊断。
- 日志不得污染 stdout。
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
## 5. 重试与超时边界
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
## 6. 后续集成范围
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
- shortcut、长连接、批量写和异步任务各自需要独立集成 PR;框架 core 不替产品推断 success、pending、partial 或分页事实。
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
## 7. 对齐原则
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
## 8. 发布门禁
命令晋级 `unified_active` 前至少满足:
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
4. Help、Schema 和全仓示例不存在协议选择参数。
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
+398 -22
View File
@@ -15,24 +15,24 @@ package app
import ( import (
"context" "context"
"encoding/json"
stderrors "errors" stderrors "errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/url" "net/url"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
@@ -40,6 +40,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -58,8 +59,11 @@ var (
rootStopAllStdioClients = StopAllStdioClients rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll rootMkdirAll = os.MkdirAll
rootCreateFile = os.Create rootCreateTemp = os.CreateTemp
rootSyncFile = (*os.File).Sync
rootCloseFile = (*os.File).Close rootCloseFile = (*os.File).Close
rootRenameFile = os.Rename
rootRemoveFile = os.Remove
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
rootPluginLoadUser = (*plugin.Loader).LoadUser rootPluginLoadUser = (*plugin.Loader).LoadUser
rootPluginLoadDev = (*plugin.Loader).LoadDev rootPluginLoadDev = (*plugin.Loader).LoadDev
@@ -76,10 +80,49 @@ var (
// Execute runs the root command and returns the process exit code. // Execute runs the root command and returns the process exit code.
func Execute() (exitCode int) { func Execute() (exitCode int) {
var (
root *cobra.Command
executed *cobra.Command
resultStore *output.ResultStore
)
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r) target := executed
exitCode = 5 if target == nil && root != nil {
if found, _, err := root.Find(os.Args[1:]); err == nil {
target = found
}
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
exitCode = code
if target != nil {
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
}
} else if target != nil && output.UsesUnifiedResult(target) {
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
exitCode = code
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
if executed == nil {
executed = target
}
}
CloseFileLogger()
if executed != nil {
// The result envelope has already established the command outcome.
// A late sink-close failure is diagnostic only: emitting a second
// envelope or changing the exit code would violate the one-result
// contract and make stdout impossible for Agents to branch on.
if err := closeOutputSink(executed); err != nil {
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
}
} }
}() }()
@@ -95,15 +138,17 @@ func Execute() (exitCode int) {
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args)) timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
}() }()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Attach timing collector to context for use by child components // Attach timing collector to context for use by child components
ctx = WithTimingCollector(ctx, timing) ctx := WithTimingCollector(context.Background(), timing)
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
defer stopSignals()
initStart := time.Now() initStart := time.Now()
engine := newPipelineEngine() engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine) root = rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart)) timing.Record("cmd_init", time.Since(initStart))
// Run PreParse handlers on raw argv before Cobra parses flags. // Run PreParse handlers on raw argv before Cobra parses flags.
@@ -111,16 +156,55 @@ func Execute() (exitCode int) {
// and --limit100 → --limit 100. // and --limit100 → --limit 100.
if err := rootRunPreParse(root, engine); err != nil { if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err) err = newPreParseValidationError(err)
if interrupted, _ := signalState.outcome(); interrupted != nil {
err = interrupted
}
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(target, result)
if emitErr == nil {
return code
}
}
_ = printExecutionError(root, os.Stdout, os.Stderr, err) _ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err) return apperrors.ExitCode(err)
} }
executed, err := rootExecuteCommand(root) var err error
executed, err = rootExecuteCommand(root)
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
if interrupted != nil && !primaryCompletedBeforeSignal {
if _, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
if executed == nil {
executed = root
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
return interrupted.ExitCode()
}
err = interrupted
}
if err != nil { if err != nil {
if executed == nil { if executed == nil {
executed = root executed = root
} }
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
var publicationErr *outputPublicationError
if stderrors.As(err, &publicationErr) {
return apperrors.ExitCode(publicationErr)
}
return code
}
err = rewordRequiredFlagError(err) err = rewordRequiredFlagError(err)
var raw apperrors.RawStderrError
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(executed, result)
if emitErr == nil {
return code
}
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if isUnknownCommandError(err) { if isUnknownCommandError(err) {
executed.SetOut(os.Stderr) executed.SetOut(os.Stderr)
_ = executed.Help() _ = executed.Help()
@@ -129,9 +213,119 @@ func Execute() (exitCode int) {
_ = printExecutionError(executed, os.Stdout, os.Stderr, err) _ = printExecutionError(executed, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err) return apperrors.ExitCode(err)
} }
if code, emitted := output.StoredExitCode(resultStore); emitted {
return code
}
return 0 return 0
} }
// errorInfoFromExecutionError projects the repository error model into the unified
// failure body. Exit code and category are derived from the same error value,
// preventing the wire and process status from drifting apart.
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
exitCode := apperrors.ExitCode(err)
info := &output.ErrorInfo{
Type: errorTypeForExitCode(exitCode),
ExitCode: exitCode,
Message: err.Error(),
}
var interrupted *processInterruption
if stderrors.As(err, &interrupted) && interrupted != nil {
info.Type = "internal"
info.Subtype = interrupted.Subtype()
return info
}
if stderrors.Is(err, context.DeadlineExceeded) {
info.Subtype = "deadline_exceeded"
}
var cliErr *helpers.CLIError
if stderrors.As(err, &cliErr) && cliErr != nil {
info.UpstreamCode = cliErr.Code
info.Hint = cliErr.Suggestion
info.Operation = cliErr.Operation
}
var callErr *transport.CallError
if stderrors.As(err, &callErr) && callErr != nil {
info.HTTPStatus = callErr.HTTPStatus
info.RPCCode = callErr.RPCCode
info.Stage = string(callErr.Stage)
if callErr.RequestID != "" {
info.RequestID = callErr.RequestID
} else if callErr.TraceID != "" {
info.RequestID = callErr.TraceID
}
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed == nil {
return info
}
if typed.Category == apperrors.CategoryPartial {
// An error lacks the item-level data required by partial_failure.
// Callers must use output.Partial; fail closed consistently otherwise.
info.Type = string(apperrors.CategoryInternal)
} else {
info.Type = string(typed.Category)
}
info.Subtype = typed.Reason
if typed.Hint != "" {
info.Hint = typed.Hint
}
info.Actions = append([]string(nil), typed.Actions...)
info.Retryable = typed.RetryableSet && typed.Retryable
info.RetryAfterSeconds = typed.RetryAfterSeconds
if typed.RPCCode != 0 {
info.RPCCode = typed.RPCCode
}
if typed.ServerDiag.TraceID != "" {
info.TraceID = typed.ServerDiag.TraceID
}
info.Operation = typed.Operation
info.ServerKey = typed.ServerKey
info.Origin = typed.Origin
if typed.FailureStage != "" {
info.Stage = typed.FailureStage
}
info.ExecutionStarted = typed.ExecutionStarted
if typed.NextRetryAt != nil {
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
info.SnapshotPath = typed.Snapshot
info.Details = typed.Details
if len(typed.RPCData) > 0 {
var rpcData any
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
info.RPCData = rpcData
}
}
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
if typed.Cause != nil {
info.Cause = typed.Cause.Error()
}
if typed.ServerDiag.ServerErrorCode != "" {
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
}
return info
}
func errorTypeForExitCode(code int) string {
switch code {
case 1:
return "api"
case 2:
return "auth"
case 3:
return "validation"
case 4:
return "permission"
case 6:
return "discovery"
default:
return "internal"
}
}
// newPreParseValidationError keeps pipeline handler identity in internal logs // newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users. // while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error { func newPreParseValidationError(err error) error {
@@ -368,6 +562,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil { if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0] rootCtx = ctx[0]
} }
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, nil, true, false) return newRootCommandWithEngine(rootCtx, nil, true, false)
} }
@@ -390,6 +585,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied. // no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command { func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery() registerSchemaRuntimeDelivery()
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, engine, true, false) return newRootCommandWithEngine(rootCtx, engine, true, false)
} }
@@ -414,6 +610,18 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help() return cmd.Help()
}, },
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := output.ValidateUnifiedFormat(cmd); err != nil {
return apperrors.NewValidation(err.Error(), apperrors.WithReason("unsupported_format"))
}
// Cobra performs these checks after persistent pre-run hooks. Run
// them before opening the transactional sink so validation errors
// cannot strand a temporary file during direct ExecuteC calls.
if err := cmd.ValidateRequiredFlags(); err != nil {
return err
}
if err := cmd.ValidateFlagGroups(); err != nil {
return err
}
// Validate caller-provided identity labels before any edition hook // Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers // or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead. // use the best-effort path in resolveIdentityHeaders instead.
@@ -439,16 +647,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
if err := configureOutputSink(cmd); err != nil { if err := configureOutputSink(cmd); err != nil {
return err return err
} }
installOutputSinkErrorCleanup(cmd)
if fn := edition.Get().AfterPersistentPreRun; fn != nil { if fn := edition.Get().AfterPersistentPreRun; fn != nil {
return fn(cmd, args) if err := runWithOutputSinkErrorCleanup(cmd, func() error { return fn(cmd, args) }); err != nil {
return err
}
} }
return nil return nil
}, },
PersistentPostRunE: func(cmd *cobra.Command, args []string) error { PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
_, emitted, emitErr := output.EmitStoredResult(cmd)
StopAllStdioClients() StopAllStdioClients()
CloseAuditSink() CloseAuditSink()
CloseFileLogger() if emitErr != nil {
return closeOutputSink(cmd) return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if output.UsesUnifiedResult(cmd) && !emitted {
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
}
if closeErr := closeOutputSink(cmd); closeErr != nil {
return closeErr
}
return nil
}, },
} }
@@ -848,6 +1077,26 @@ func deduplicateCommands(root *cobra.Command) {
} }
} }
type outputSinkState struct {
mu sync.Mutex
file *os.File
tempPath string
target string
finished bool
}
type outputPublicationError struct {
cause error
}
func (e *outputPublicationError) Error() string { return e.cause.Error() }
func (e *outputPublicationError) Unwrap() error { return e.cause }
func (e *outputPublicationError) ExitCode() int { return 5 }
func newOutputPublicationError(message string, cause error) error {
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
}
func configureOutputSink(cmd *cobra.Command) error { func configureOutputSink(cmd *cobra.Command) error {
if local := cmd.LocalFlags().Lookup("output"); local != nil { if local := cmd.LocalFlags().Lookup("output"); local != nil {
return nil return nil
@@ -866,26 +1115,153 @@ func configureOutputSink(cmd *cobra.Command) error {
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil { if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err)) return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
} }
file, err := rootCreateFile(outputPath) tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
if err != nil { if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err)) return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
} }
cmd.SetOut(file) cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file)) cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file,
tempPath: file.Name(),
target: outputPath,
}))
return nil return nil
} }
func installOutputSinkErrorCleanup(cmd *cobra.Command) {
if cmd == nil {
return
}
if _, ok := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState); !ok {
return
}
if cmd.PreRunE != nil {
original := cmd.PreRunE
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
}
}
if cmd.PreRun != nil {
original := cmd.PreRun
cmd.PreRun = func(cmd *cobra.Command, args []string) {
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
original(cmd, args)
return nil
})
}
}
if cmd.RunE != nil {
original := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
}
}
if cmd.Run != nil {
original := cmd.Run
cmd.Run = func(cmd *cobra.Command, args []string) {
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
original(cmd, args)
return nil
})
}
}
if cmd.PostRunE != nil {
original := cmd.PostRunE
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
}
}
if cmd.PostRun != nil {
original := cmd.PostRun
cmd.PostRun = func(cmd *cobra.Command, args []string) {
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
original(cmd, args)
return nil
})
}
}
}
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
return run()
}
func warnAbortOutputSink(cmd *cobra.Command) {
if closeErr := abortOutputSink(cmd); closeErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
}
}
func closeOutputSink(cmd *cobra.Command) error { func closeOutputSink(cmd *cobra.Command) error {
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File) state := outputSinkForCommand(cmd)
if !ok || file == nil { if state == nil {
return nil return nil
} }
if err := rootCloseFile(file); err != nil { state.mu.Lock()
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err)) defer state.mu.Unlock()
if state.finished {
return nil
}
state.finished = true
if err := rootSyncFile(state.file); err != nil {
_ = rootCloseFile(state.file)
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to sync output file", err)
}
if err := rootCloseFile(state.file); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to close output file", err)
}
if err := rootRenameFile(state.tempPath, state.target); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to publish output file", err)
} }
return nil return nil
} }
func abortOutputSink(cmd *cobra.Command) error {
state := outputSinkForCommand(cmd)
if state == nil {
return nil
}
state.mu.Lock()
defer state.mu.Unlock()
if state.finished {
return nil
}
state.finished = true
closeErr := rootCloseFile(state.file)
removeErr := rootRemoveFile(state.tempPath)
if closeErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
}
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
}
return nil
}
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
if cmd == nil || cmd.Context() == nil {
return nil
}
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
if state == nil || state.file == nil {
return nil
}
return state
}
func validateOptionalPath(flagName, path string) error { func validateOptionalPath(flagName, path string) error {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if path == "" { if path == "" {
+10 -6
View File
@@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
}) })
oldMkdir := rootMkdirAll oldMkdir := rootMkdirAll
oldCreate := rootCreateFile oldCreate := rootCreateTemp
oldClose := rootCloseFile oldClose := rootCloseFile
t.Cleanup(func() { t.Cleanup(func() {
rootMkdirAll = oldMkdir rootMkdirAll = oldMkdir
rootCreateFile = oldCreate rootCreateTemp = oldCreate
rootCloseFile = oldClose rootCloseFile = oldClose
}) })
wantErr := errors.New("filesystem") wantErr := errors.New("filesystem")
@@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
t.Fatal("mkdir failure succeeded") t.Fatal("mkdir failure succeeded")
} }
rootMkdirAll = func(string, os.FileMode) error { return nil } rootMkdirAll = func(string, os.FileMode) error { return nil }
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr } rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil { if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
t.Fatal("create failure succeeded") t.Fatal("create failure succeeded")
} }
rootCreateFile = oldCreate rootCreateTemp = oldCreate
file, err := os.CreateTemp(t.TempDir(), "close") file, err := os.CreateTemp(t.TempDir(), "close")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
cmd := &cobra.Command{Use: "close"} cmd := &cobra.Command{Use: "close"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file)) cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
}))
rootCloseFile = func(*os.File) error { return wantErr } rootCloseFile = func(*os.File) error { return wantErr }
if err := closeOutputSink(cmd); err == nil { if err := closeOutputSink(cmd); err == nil {
t.Fatal("close failure succeeded") t.Fatal("close failure succeeded")
@@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file)) cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
}))
if err := closeOutputSink(cmd); err != nil { if err := closeOutputSink(cmd); err != nil {
t.Fatalf("close success = %v", err) t.Fatalf("close success = %v", err)
} }
+228
View File
@@ -0,0 +1,228 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
cmd.SetContext(ctx)
return cmd
}
var stdout, stderr bytes.Buffer
executed := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
executed.SetOut(&stdout)
executed.SetErr(&stderr)
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
executed.SetContext(root.Context())
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
return executed, err
}
if _, _, err := output.EmitStoredResult(executed); err != nil {
return executed, err
}
return executed, nil
}
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
}
}
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
// writer 不得收到任何字节。
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
_ = root.PersistentFlags().Set("format", "json")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), `"error"`) {
t.Fatalf("expected JSON error envelope on stderr, got %q", stderr.String())
}
}
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
// 失败走 stderr(PrintHuman),stdout 为空。
func TestRootHumanErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
_ = root.PersistentFlags().Set("format", "table")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "Error:") {
t.Fatalf("expected human error on stderr, got %q", stderr.String())
}
}
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// ok / pending(信封 success/pending 语义)→ 0
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
if code := Execute(); code != 0 {
t.Fatalf("success Execute code = %d, want 0", code)
}
// An error cannot carry partial succeeded/failed data and fails closed.
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
}
if code := Execute(); code != 5 {
t.Fatalf("partial error Execute code = %d, want 5", code)
}
// confirmation_required(validation 子类)→ 3
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
}
if code := Execute(); code != 3 {
t.Fatalf("confirmation Execute code = %d, want 3", code)
}
// plain internal → 5
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, errors.New("plain")
}
if code := Execute(); code != 5 {
t.Fatalf("plain Execute code = %d, want 5", code)
}
}
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
// (StopAllStdioClients)在错误路径也被调用。
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
stopped := false
rootStopAllStdioClients = func() { stopped = true }
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewInternal("fail")
}
_ = Execute()
if !stopped {
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
}
}
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
func TestRootSilenceErrorsFlag(t *testing.T) {
t.Parallel()
root := NewRootCommand()
if !root.SilenceErrors || !root.SilenceUsage {
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
}
}
@@ -0,0 +1,147 @@
package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
if err := file.Close(); err != nil {
return err
}
return errors.New("late close diagnostic")
}
root := NewRootCommand(context.Background())
leaf := &cobra.Command{
Use: "lifecycle-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
executed, err := root.ExecuteC()
if err == nil || apperrors.ExitCode(err) != 5 {
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
}
if executed != leaf {
t.Fatalf("executed=%v, want lifecycle leaf", executed)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
rootCloseFile = func(file *os.File) error {
_ = file.Close()
return errors.New("legacy close failed")
}
root := NewRootCommandWithEngine(context.Background(), nil)
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
}
}
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
return file.Close()
}
root := NewRootCommand(context.Background())
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
return errors.New("handler failed")
}})
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
var stdout, stderr bytes.Buffer
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetContext(ctx)
return cmd
}
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
if err := output.StoreResult(cmd.Context(), result); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
panic("after emission")
}
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
}
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
}
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
t.Fatalf("panic diagnostic missing: %q", stderr.String())
}
}
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
info := errorInfoFromExecutionError(err)
if info.TraceID != "trace-1" || info.RequestID != "" {
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
}
}
+274
View File
@@ -0,0 +1,274 @@
package app
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestOutputSinkAtomicallyReplacesTargetWithMode0600(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
var tempMode os.FileMode
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
info, err := cmd.OutOrStdout().(*os.File).Stat()
if err != nil {
return err
}
tempMode = info.Mode().Perm()
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC: %v", err)
}
assertOutputFile(t, target, "replacement", 0o600)
if tempMode != 0o600 {
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
}
assertNoOutputTemps(t, target)
}
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
return errors.New("handler failed")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
panic("boom")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if recovered := executeAndRecover(root); recovered == nil {
t.Fatal("ExecuteC did not panic")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
t.Fatalf("ExecuteC error=%v, want publication failure", err)
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
tests := []struct {
name string
seam func(*testing.T)
}{
{
name: "sync",
seam: func(t *testing.T) {
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
},
},
{
name: "close",
seam: func(t *testing.T) {
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
_ = file.Close()
return errors.New("close failed")
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.seam(t)
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
})
}
}
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-output-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-output-unified", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("unified ExecuteC succeeded without publishing its output")
} else if code := apperrors.ExitCode(err); code != 5 {
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final output exists after publication failure: %v", err)
}
assertNoOutputTemps(t, target)
}
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-emission-failure",
RunE: func(cmd *cobra.Command, _ []string) error {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
return err
}
return cmd.OutOrStdout().(*os.File).Close()
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded after emission failure")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().String("required", "", "")
_ = leaf.MarkFlagRequired("required")
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-validation", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded without required flag")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
root := NewRootCommand()
root.AddCommand(&cobra.Command{
Use: "atomic-output",
RunE: func(cmd *cobra.Command, _ []string) error {
return run(cmd)
},
})
return root
}
func executeAndRecover(cmd *cobra.Command) (recovered any) {
defer func() { recovered = recover() }()
_, _ = cmd.ExecuteC()
return nil
}
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
if string(data) != want {
t.Fatalf("output=%q, want %q", data, want)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if mode := info.Mode().Perm(); mode != wantMode {
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
}
}
func assertNoOutputTemps(t *testing.T, target string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary output files remain: %v", matches)
}
}
+116
View File
@@ -0,0 +1,116 @@
package app
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
var rootEscalateSignal = func(sig os.Signal) {
signal.Reset(sig)
if syscallSig, ok := sig.(syscall.Signal); ok {
_ = syscall.Kill(os.Getpid(), syscallSig)
}
}
type processInterruption struct {
signal os.Signal
}
func (e *processInterruption) Error() string {
return fmt.Sprintf("process interrupted by %s", e.signal)
}
func (e *processInterruption) Unwrap() error { return context.Canceled }
func (e *processInterruption) ExitCode() int {
if e.signal == syscall.SIGTERM {
return 143
}
return 130
}
func (e *processInterruption) Subtype() string {
if e.signal == syscall.SIGTERM {
return "terminated"
}
return "cancelled_by_user"
}
type processSignalState struct {
mu sync.Mutex
interruption *processInterruption
primaryCompletedAtSignal bool
}
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.interruption != nil {
return false
}
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
s.interruption = &processInterruption{signal: sig}
return true
}
func (s *processSignalState) outcome() (*processInterruption, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.interruption, s.primaryCompletedAtSignal
}
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
signals := make(chan os.Signal, 2)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
}
func manageProcessSignals(
parent context.Context,
store *output.ResultStore,
signals <-chan os.Signal,
stopNotify func(),
escalate func(os.Signal),
) (context.Context, *processSignalState, func()) {
ctx, cancel := context.WithCancelCause(parent)
state := &processSignalState{}
done := make(chan struct{})
stopped := make(chan struct{})
var stopOnce sync.Once
go func() {
defer close(stopped)
for {
select {
case sig := <-signals:
if sig == nil {
continue
}
if state.record(sig, store) {
cancel(state.interruption)
continue
}
escalate(sig)
return
case <-done:
return
}
}
}()
stop := func() {
stopOnce.Do(func() {
stopNotify()
close(done)
<-stopped
cancel(context.Canceled)
})
}
return ctx, state, stop
}
+244
View File
@@ -0,0 +1,244 @@
package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"syscall"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
if unified {
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
}
cmd.SetContext(ctx)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
return cmd
})
}
func signalSelf(t *testing.T, sig syscall.Signal) {
t.Helper()
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatalf("signal process: %v", err)
}
}
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, &stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
}
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
}
})
}
}
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
for _, tc := range []struct {
signal syscall.Signal
code int
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
t.Run(tc.signal.String(), func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
})
}
}
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
return cmd, context.DeadlineExceeded
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
t.Fatalf("deadline error=%+v", env.Error)
}
}
func TestSignalAfterEmissionAttemptDoesNotEmitAgain(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(failingWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 130 {
t.Fatalf("Execute code=%d, want 130", code)
}
if stdout.Len() != 0 {
t.Fatalf("second envelope emitted: %q", stdout.String())
}
}
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want established success code 0", code)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
_, _ = fmt.Fprintln(os.Stderr, "READY")
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
os.Exit(Execute())
}
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
}
if err := cmd.Process.Signal(tc.signal); err != nil {
t.Fatal(err)
}
payload, readErr := io.ReadAll(stdout)
if readErr != nil {
t.Fatal(readErr)
}
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(payload, &env); err != nil {
t.Fatalf("decode helper output: %v; output=%q", err, payload)
}
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("helper error=%+v", env.Error)
}
})
}
}
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
signals := make(chan os.Signal, 2)
escalated := make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
escalated <- sig
})
signals <- syscall.SIGINT
<-ctx.Done()
if !errors.Is(context.Cause(ctx), context.Canceled) {
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
}
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated %v, want SIGTERM", got)
}
stop()
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
+1
View File
@@ -30,6 +30,7 @@ type ContractFinalPayload struct {
Parameters []ParamDecl Parameters []ParamDecl
Safety *SafetySpec Safety *SafetySpec
DryRun *DryRunSpec DryRun *DryRunSpec
Result *ResultSpec
Interface *InterfaceSpec Interface *InterfaceSpec
Selection *SelectionSpec Selection *SelectionSpec
Identity *ToolIdentitySpec Identity *ToolIdentitySpec
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package contract
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestNormalizeResultSpecCanonicalizesAndCopies(t *testing.T) {
in := &ResultSpec{
Outcomes: []ResultOutcome{ResultOutcomeFailure, ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{ "properties": {"items":{"type":"array"}}, "type":"object" }`),
NDJSON: &ResultNDJSONSpec{
RecordPath: "items",
RecordSchema: json.RawMessage(`{ "type": "object" }`),
},
Pagination: &ResultPaginationSpec{CursorPath: "nextCursor", ExhaustionPath: "hasMore"},
SensitivePaths: []string{"items.secret", "credential"},
}
got, err := NormalizeResultSpec(in, "dev.list")
if err != nil {
t.Fatalf("NormalizeResultSpec() error = %v", err)
}
if want := []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeFailure}; !reflect.DeepEqual(got.Outcomes, want) {
t.Fatalf("outcomes = %#v, want %#v", got.Outcomes, want)
}
if string(got.DataSchema) != `{"properties":{"items":{"type":"array"}},"type":"object"}` {
t.Fatalf("data_schema = %s", got.DataSchema)
}
if want := []string{"credential", "items.secret"}; !reflect.DeepEqual(got.SensitivePaths, want) {
t.Fatalf("sensitive_paths = %#v, want %#v", got.SensitivePaths, want)
}
in.Outcomes[0] = ResultOutcomePending
in.DataSchema[0] = '['
in.NDJSON.RecordSchema[0] = '['
in.SensitivePaths[0] = "changed"
if got.Outcomes[0] != ResultOutcomeSuccess || got.DataSchema[0] != '{' || got.NDJSON.RecordSchema[0] != '{' || got.SensitivePaths[0] != "credential" {
t.Fatalf("normalized result aliases input: %#v", got)
}
}
func TestNormalizeResultSpecRejectsInvalidContractsDeterministically(t *testing.T) {
valid := func() *ResultSpec {
return &ResultSpec{Outcomes: []ResultOutcome{ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object"}`)}
}
tests := []struct {
name string
edit func(*ResultSpec)
want string
}{
{"no outcomes", func(r *ResultSpec) { r.Outcomes = nil }, "no outcomes"},
{"unknown outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{"ok"} }, "unknown outcome"},
{"duplicate outcome", func(r *ResultSpec) { r.Outcomes = []ResultOutcome{ResultOutcomeSuccess, ResultOutcomeSuccess} }, "duplicate outcome"},
{"schema array", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`[]`) }, "data_schema: must be one JSON object"},
{"multiple schemas", func(r *ResultSpec) { r.DataSchema = json.RawMessage(`{} {}`) }, "data_schema: must be one JSON object"},
{"ndjson pair", func(r *ResultSpec) { r.NDJSON = &ResultNDJSONSpec{RecordPath: "items"} }, "record_path and record_schema together"},
{"unsafe path", func(r *ResultSpec) { r.SensitivePaths = []string{"$.token"} }, "unsafe segment"},
{"duplicate path", func(r *ResultSpec) { r.SensitivePaths = []string{"token", " token "} }, "duplicate sensitive path"},
{"pagination pair", func(r *ResultSpec) { r.Pagination = &ResultPaginationSpec{CursorPath: "cursor"} }, "must declare cursor_path and exhaustion_path together"},
{"pagination collision", func(r *ResultSpec) {
r.Pagination = &ResultPaginationSpec{CursorPath: "cursor", ExhaustionPath: "cursor"}
}, "paths must differ"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
spec := valid()
test.edit(spec)
_, err := NormalizeResultSpec(spec, "dev.test")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
+168
View File
@@ -14,7 +14,10 @@
package contract package contract
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io"
"sort" "sort"
"strings" "strings"
) )
@@ -59,6 +62,171 @@ type DryRunSpec struct {
RemoteReads bool `json:"remote_reads,omitempty"` RemoteReads bool `json:"remote_reads,omitempty"`
} }
// ResultOutcome is one closed unified-output envelope outcome.
type ResultOutcome string
const (
ResultOutcomeSuccess ResultOutcome = "success"
ResultOutcomePending ResultOutcome = "pending"
ResultOutcomePartialFailure ResultOutcome = "partial_failure"
ResultOutcomeFailure ResultOutcome = "failure"
)
var canonicalResultOutcomes = [...]ResultOutcome{
ResultOutcomeSuccess,
ResultOutcomePending,
ResultOutcomePartialFailure,
ResultOutcomeFailure,
}
// ResultNDJSONSpec identifies the records emitted by NDJSON projections.
// RecordPath is relative to the envelope data value.
type ResultNDJSONSpec struct {
RecordPath string `json:"record_path"`
RecordSchema json.RawMessage `json:"record_schema"`
}
// ResultPaginationSpec identifies cursor pagination facts in envelope data.
// Both paths are relative to data and must be declared together.
type ResultPaginationSpec struct {
CursorPath string `json:"cursor_path"`
ExhaustionPath string `json:"exhaustion_path"`
ExhaustedWhen bool `json:"exhausted_when"`
}
// ResultSpec is the reviewed return-value contract for one command. DataSchema
// and RecordSchema are canonical JSON Schema objects; all paths are relative to
// the unified-output envelope data value.
type ResultSpec struct {
Outcomes []ResultOutcome `json:"outcomes"`
DataSchema json.RawMessage `json:"data_schema"`
NDJSON *ResultNDJSONSpec `json:"ndjson,omitempty"`
Pagination *ResultPaginationSpec `json:"pagination,omitempty"`
SensitivePaths []string `json:"sensitive_paths,omitempty"`
}
// NormalizeResultSpec returns a validated, canonical, defensively copied
// result contract. It is shared by declaration, ToolSpec, and snapshot paths.
func NormalizeResultSpec(in *ResultSpec, canonical string) (*ResultSpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
out := &ResultSpec{}
seenOutcomes := make(map[ResultOutcome]bool, len(in.Outcomes))
for _, outcome := range in.Outcomes {
outcome = ResultOutcome(strings.TrimSpace(string(outcome)))
valid := false
for _, allowed := range canonicalResultOutcomes {
if outcome == allowed {
valid = true
break
}
}
if !valid {
return nil, fmt.Errorf("schema tool %s result has unknown outcome %q", canonical, outcome)
}
if seenOutcomes[outcome] {
return nil, fmt.Errorf("schema tool %s result has duplicate outcome %q", canonical, outcome)
}
seenOutcomes[outcome] = true
}
if len(seenOutcomes) == 0 {
return nil, fmt.Errorf("schema tool %s result has no outcomes", canonical)
}
for _, outcome := range canonicalResultOutcomes {
if seenOutcomes[outcome] {
out.Outcomes = append(out.Outcomes, outcome)
}
}
var err error
out.DataSchema, err = canonicalJSONObject(in.DataSchema)
if err != nil {
return nil, fmt.Errorf("schema tool %s result data_schema: %w", canonical, err)
}
if in.NDJSON != nil {
path := strings.TrimSpace(in.NDJSON.RecordPath)
if path == "" || len(in.NDJSON.RecordSchema) == 0 {
return nil, fmt.Errorf("schema tool %s result ndjson must declare record_path and record_schema together", canonical)
}
if err := validateResultPath(path); err != nil {
return nil, fmt.Errorf("schema tool %s result ndjson record_path: %w", canonical, err)
}
recordSchema, err := canonicalJSONObject(in.NDJSON.RecordSchema)
if err != nil {
return nil, fmt.Errorf("schema tool %s result ndjson record_schema: %w", canonical, err)
}
out.NDJSON = &ResultNDJSONSpec{RecordPath: path, RecordSchema: recordSchema}
}
if in.Pagination != nil {
cursor := strings.TrimSpace(in.Pagination.CursorPath)
exhausted := strings.TrimSpace(in.Pagination.ExhaustionPath)
if cursor == "" || exhausted == "" {
return nil, fmt.Errorf("schema tool %s result pagination must declare cursor_path and exhaustion_path together", canonical)
}
if cursor == exhausted {
return nil, fmt.Errorf("schema tool %s result pagination paths must differ", canonical)
}
for _, item := range []struct{ name, path string }{{"cursor_path", cursor}, {"exhaustion_path", exhausted}} {
if err := validateResultPath(item.path); err != nil {
return nil, fmt.Errorf("schema tool %s result pagination %s: %w", canonical, item.name, err)
}
}
out.Pagination = &ResultPaginationSpec{CursorPath: cursor, ExhaustionPath: exhausted, ExhaustedWhen: in.Pagination.ExhaustedWhen}
}
seenPaths := make(map[string]bool, len(in.SensitivePaths))
for _, path := range in.SensitivePaths {
path = strings.TrimSpace(path)
if err := validateResultPath(path); err != nil {
return nil, fmt.Errorf("schema tool %s result sensitive path: %w", canonical, err)
}
if seenPaths[path] {
return nil, fmt.Errorf("schema tool %s result has duplicate sensitive path %q", canonical, path)
}
seenPaths[path] = true
out.SensitivePaths = append(out.SensitivePaths, path)
}
sort.Strings(out.SensitivePaths)
if len(out.SensitivePaths) == 0 {
out.SensitivePaths = nil
}
return out, nil
}
func canonicalJSONObject(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("must be one JSON object")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var object map[string]json.RawMessage
if err := decoder.Decode(&object); err != nil || object == nil {
return nil, fmt.Errorf("must be one JSON object")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, fmt.Errorf("must be one JSON object")
}
canonical, err := json.Marshal(object)
if err != nil {
return nil, fmt.Errorf("cannot canonicalize JSON object: %w", err)
}
return json.RawMessage(canonical), nil
}
func validateResultPath(path string) error {
if path == "" || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") || strings.Contains(path, "..") {
return fmt.Errorf("path %q is not a relative data path", path)
}
for _, segment := range strings.Split(path, ".") {
for i, r := range segment {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || (i > 0 && (r == '-' || (r >= '0' && r <= '9')))) {
return fmt.Errorf("path %q contains unsafe segment %q", path, segment)
}
}
}
return nil
}
const ( const (
DryRunPreviewInvocation = "invocation" DryRunPreviewInvocation = "invocation"
DryRunPreviewRequest = "request" DryRunPreviewRequest = "request"
+4
View File
@@ -42,6 +42,7 @@ type ContractDecl struct {
Positionals []contract.RuntimeSchemaPositional Positionals []contract.RuntimeSchemaPositional
Parameters []contract.ParamDecl Parameters []contract.ParamDecl
DryRun *contract.DryRunSpec DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Interface *contract.InterfaceSpec Interface *contract.InterfaceSpec
Selection contract.SelectionSpec Selection contract.SelectionSpec
Identity contract.ToolIdentitySpec Identity contract.ToolIdentitySpec
@@ -144,6 +145,9 @@ func (s ContractDecl) empty() bool {
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" { if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
return false return false
} }
if s.Result != nil {
return false
}
if s.Interface != nil { if s.Interface != nil {
iface := s.Interface iface := s.Interface
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" || if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
@@ -24,6 +24,11 @@ import (
func TestContractFinalTypedRegistryNoJSON(t *testing.T) { func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
cmd := &cobra.Command{Use: "x"} cmd := &cobra.Command{Use: "x"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) }) t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
result := &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: []byte(`{"type":"object"}`),
SensitivePaths: []string{"token"},
}
RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{ RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Title: "T", Title: "T",
@@ -32,7 +37,11 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
}, },
Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}}, Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"}, Identity: &contract.ToolIdentitySpec{ProductID: "p", Name: "n"},
Result: result,
}) })
result.Outcomes[0] = contract.ResultOutcomeFailure
result.DataSchema[0] = '['
result.SensitivePaths[0] = "changed"
if cmd.Annotations != nil { if cmd.Annotations != nil {
if _, ok := cmd.Annotations["dws.schema.final"]; ok { if _, ok := cmd.Annotations["dws.schema.final"]; ok {
t.Fatal("must not write JSON annotation dws.schema.final") t.Fatal("must not write JSON annotation dws.schema.final")
@@ -45,6 +54,14 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
if got.Selection == nil || got.Selection.Reviewed != nil { if got.Selection == nil || got.Selection.Reviewed != nil {
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection) t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
} }
if got.Result == nil || got.Result.Outcomes[0] != contract.ResultOutcomeSuccess || got.Result.DataSchema[0] != '{' || got.Result.SensitivePaths[0] != "token" {
t.Fatalf("stored result aliases registration input: %#v", got.Result)
}
got.Result.Outcomes[0] = contract.ResultOutcomeFailure
again, _ := RuntimeContractFinal(cmd)
if again.Result.Outcomes[0] != contract.ResultOutcomeSuccess {
t.Fatal("RuntimeContractFinal result aliases stored payload")
}
} }
func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) { func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) {
@@ -137,3 +154,37 @@ func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *t
t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload") t.Fatal("typed nil payload must not decode as contract.ContractFinalPayload")
} }
} }
func TestResolveRuntimeSafetyUsesCanonicalOrCLIIdentityAndRejectsUnavailable(t *testing.T) {
read := &cobra.Command{Use: "read"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(read) })
RegisterRuntimeContractFinal(read, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.read", PrimaryCLIPath: "sample get"},
Safety: &contract.SafetySpec{Effect: " read ", Idempotency: " idempotent "},
})
for _, lookup := range []struct {
canonical string
cli string
}{
{canonical: "sample.read"},
{canonical: "different.rpc", cli: "dws sample get"},
} {
safety, declared, ok := ResolveRuntimeSafety(lookup.canonical, lookup.cli)
if !declared || !ok || safety.Effect != "read" || safety.Idempotency != "idempotent" {
t.Fatalf("ResolveRuntimeSafety(%q, %q) = %#v, %v, %v", lookup.canonical, lookup.cli, safety, declared, ok)
}
}
missingSafety := &cobra.Command{Use: "write"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(missingSafety) })
RegisterRuntimeContractFinal(missingSafety, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{CanonicalPath: "sample.write"},
})
if _, declared, ok := ResolveRuntimeSafety("sample.write", ""); !declared || ok {
t.Fatalf("missing safety = declared %v ok %v, want true false", declared, ok)
}
if _, declared, ok := ResolveRuntimeSafety("legacy.call", "legacy call"); declared || ok {
t.Fatalf("legacy lookup = declared %v ok %v, want false false", declared, ok)
}
}
+149 -2
View File
@@ -14,6 +14,7 @@
package contractfinal package contractfinal
import ( import (
"strings"
"sync" "sync"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -34,7 +35,7 @@ func RegisterRuntimeContractFinal(cmd *cobra.Command, payload contract.ContractF
return return
} }
runtimeannotate.AnnotateRuntimeContract(cmd) runtimeannotate.AnnotateRuntimeContract(cmd)
p := payload p := cloneContractFinalPayload(payload)
contractFinalByCommand.Store(cmd, &p) contractFinalByCommand.Store(cmd, &p)
} }
@@ -51,7 +52,89 @@ func RuntimeContractFinal(cmd *cobra.Command) (contract.ContractFinalPayload, bo
if !ok || p == nil { if !ok || p == nil {
return contract.ContractFinalPayload{}, false return contract.ContractFinalPayload{}, false
} }
return *p, true return cloneContractFinalPayload(*p), true
}
func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.ContractFinalPayload {
out := in
out.Positionals = cloneSlice(in.Positionals)
out.Parameters = cloneSlice(in.Parameters)
for i := range out.Parameters {
out.Parameters[i].Enum = cloneSlice(in.Parameters[i].Enum)
if in.Parameters[i].Required != nil {
required := *in.Parameters[i].Required
out.Parameters[i].Required = &required
}
}
if in.Safety != nil {
value := *in.Safety
out.Safety = &value
}
if in.DryRun != nil {
value := *in.DryRun
out.DryRun = &value
}
if in.Result != nil {
value := *in.Result
value.Outcomes = cloneSlice(in.Result.Outcomes)
value.DataSchema = cloneSlice(in.Result.DataSchema)
value.SensitivePaths = cloneSlice(in.Result.SensitivePaths)
if in.Result.NDJSON != nil {
ndjson := *in.Result.NDJSON
ndjson.RecordSchema = cloneSlice(in.Result.NDJSON.RecordSchema)
value.NDJSON = &ndjson
}
if in.Result.Pagination != nil {
pagination := *in.Result.Pagination
value.Pagination = &pagination
}
out.Result = &value
}
if in.Interface != nil {
value := *in.Interface
if in.Interface.Ref != nil {
ref := *in.Interface.Ref
value.Ref = &ref
}
out.Interface = &value
}
if in.Selection != nil {
value := *in.Selection
value.UseWhen = cloneSlice(in.Selection.UseWhen)
value.AvoidWhen = cloneSlice(in.Selection.AvoidWhen)
value.Prerequisites = cloneSlice(in.Selection.Prerequisites)
value.Tips = cloneSlice(in.Selection.Tips)
value.WorkflowRefs = cloneSlice(in.Selection.WorkflowRefs)
value.Examples = cloneSlice(in.Selection.Examples)
value.SourceRefs = cloneSlice(in.Selection.SourceRefs)
value.ExampleDispositions = cloneSlice(in.Selection.ExampleDispositions)
for i := range value.ExampleDispositions {
if in.Selection.ExampleDispositions[i].Index != nil {
index := *in.Selection.ExampleDispositions[i].Index
value.ExampleDispositions[i].Index = &index
}
}
if in.Selection.Reviewed != nil {
reviewed := *in.Selection.Reviewed
value.Reviewed = &reviewed
}
out.Selection = &value
}
if in.Identity != nil {
value := *in.Identity
value.Aliases = cloneSlice(in.Identity.Aliases)
out.Identity = &value
}
return out
}
func cloneSlice[T any](in []T) []T {
if in == nil {
return nil
}
out := make([]T, len(in))
copy(out, in)
return out
} }
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay. // HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
@@ -62,3 +145,67 @@ func HasRuntimeContractFinal(cmd *cobra.Command) bool {
_, ok := contractFinalByCommand.Load(cmd) _, ok := contractFinalByCommand.Load(cmd)
return ok return ok
} }
// ResolveRuntimeSafety finds the live ContractFinal safety declaration for an
// invocation identity. declared distinguishes a matched declaration whose
// safety is unavailable or conflicting from a legacy invocation with no unified
// declaration context. Repeated equivalent command-tree registrations are
// accepted; conflicting matches fail closed with ok=false.
func ResolveRuntimeSafety(canonicalPath, cliPath string) (safety contract.SafetySpec, declared, ok bool) {
canonicalPath = strings.TrimSpace(canonicalPath)
cliPath = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cliPath), "dws "))
var resolved contract.SafetySpec
contractFinalByCommand.Range(func(_, raw any) bool {
payload, valid := raw.(*contract.ContractFinalPayload)
if !valid || payload == nil || payload.Identity == nil ||
!runtimeIdentityMatches(*payload.Identity, canonicalPath, cliPath) {
return true
}
declared = true
if payload.Safety == nil {
ok = false
return false
}
candidate := normalizedRuntimeSafety(*payload.Safety)
if !ok {
resolved = candidate
ok = true
return true
}
if resolved != candidate {
ok = false
return false
}
return true
})
return resolved, declared, ok
}
func runtimeIdentityMatches(identity contract.ToolIdentitySpec, canonicalPath, cliPath string) bool {
if canonicalPath != "" {
for _, value := range []string{identity.CanonicalPath, identity.Path} {
if strings.TrimSpace(value) == canonicalPath {
return true
}
}
}
if cliPath == "" {
return false
}
for _, value := range []string{identity.PrimaryCLIPath, identity.CLIPath} {
if strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "dws ")) == cliPath {
return true
}
}
return false
}
func normalizedRuntimeSafety(safety contract.SafetySpec) contract.SafetySpec {
safety.Effect = strings.TrimSpace(safety.Effect)
safety.EffectSource = strings.TrimSpace(safety.EffectSource)
safety.Risk = strings.TrimSpace(safety.Risk)
safety.Confirmation = strings.TrimSpace(safety.Confirmation)
safety.Idempotency = strings.TrimSpace(safety.Idempotency)
return safety
}
+34 -7
View File
@@ -63,6 +63,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
) )
@@ -224,11 +225,12 @@ const (
// construction time. corecmd stays dispatch-agnostic and never calls a backend: // construction time. corecmd stays dispatch-agnostic and never calls a backend:
// the adapters (FromLeafSpec / FromShortcut) supply the body. // the adapters (FromLeafSpec / FromShortcut) supply the body.
type Spec struct { type Spec struct {
Use string Use string
Short string Short string
Long string Long string
Example string Example string
Hidden bool Hidden bool
OutputRollout output.RolloutState
Flags []FlagSpec Flags []FlagSpec
Constraints []Constraint Constraints []Constraint
@@ -266,6 +268,8 @@ type Spec struct {
RunE func(cmd *cobra.Command, args []string) error RunE func(cmd *cobra.Command, args []string) error
// Invoke executes a single-step command with the assembled toolArgs. // Invoke executes a single-step command with the assembled toolArgs.
Invoke func(c *Ctx, toolArgs map[string]any) error Invoke func(c *Ctx, toolArgs map[string]any) error
// ResultInvoke executes once and returns an immutable framework 2.0 result.
ResultInvoke func(c *Ctx, toolArgs map[string]any) (output.CommandResult, error)
// Orchestrate executes a multi-step command; it assembles whatever payloads // Orchestrate executes a multi-step command; it assembles whatever payloads
// it needs from the Ctx. // it needs from the Ctx.
Orchestrate func(c *Ctx) error Orchestrate func(c *Ctx) error
@@ -385,6 +389,9 @@ func New(spec Spec) *cobra.Command {
if spec.PostMount != nil { if spec.PostMount != nil {
spec.PostMount(cmd) spec.PostMount(cmd)
} }
if spec.OutputRollout != "" {
output.SetCommandRollout(cmd, spec.OutputRollout)
}
if spec.ConfirmFirst { if spec.ConfirmFirst {
if cmd.Annotations == nil { if cmd.Annotations == nil {
cmd.Annotations = map[string]string{} cmd.Annotations = map[string]string{}
@@ -430,6 +437,16 @@ func New(spec Spec) *cobra.Command {
return err return err
} }
} }
if spec.ResultInvoke != nil {
result, err := spec.ResultInvoke(ctx, toolArgs)
if err != nil {
return err
}
if !output.UsesUnifiedResult(cmd) {
return fmt.Errorf("command %q uses ResultInvoke without an active Framework 2.0 rollout", cmd.CommandPath())
}
return output.StoreResult(cmd.Context(), result)
}
return spec.Invoke(ctx, toolArgs) return spec.Invoke(ctx, toolArgs)
} }
return cmd return cmd
@@ -486,12 +503,15 @@ func validateDispatchDecl(spec Spec) {
if spec.Invoke != nil { if spec.Invoke != nil {
declared++ declared++
} }
if spec.ResultInvoke != nil {
declared++
}
if spec.Orchestrate != nil { if spec.Orchestrate != nil {
declared++ declared++
} }
if declared != 1 { if declared != 1 {
panic(fmt.Sprintf( panic(fmt.Sprintf(
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d", "command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d (ResultInvoke is also a dispatcher)",
spec.Use, declared)) spec.Use, declared))
} }
// ConfirmFirst only changes the ordering of a declared confirmation gate. // ConfirmFirst only changes the ordering of a declared confirmation gate.
@@ -669,7 +689,7 @@ func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
if hint == "" { if hint == "" {
hint = fmt.Sprintf("flag --%s is required", flag.Name) hint = fmt.Sprintf("flag --%s is required", flag.Name)
} }
return fmt.Errorf("%s", hint) return apperrors.NewValidation(hint)
} }
} }
return nil return nil
@@ -1348,6 +1368,13 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
d.PreviewKind = strings.TrimSpace(d.PreviewKind) d.PreviewKind = strings.TrimSpace(d.PreviewKind)
payload.DryRun = &d payload.DryRun = &d
} }
if decl.Result != nil {
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Result: %v", cmd.Name(), err))
}
payload.Result = result
}
if decl.Interface != nil { if decl.Interface != nil {
iface := &contract.InterfaceSpec{ iface := &contract.InterfaceSpec{
Mode: strings.TrimSpace(decl.Interface.Mode), Mode: strings.TrimSpace(decl.Interface.Mode),
+51
View File
@@ -0,0 +1,51 @@
package corecmd
import (
"bytes"
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
func TestResultInvokeCarriesOneFrameworkResult(t *testing.T) {
calls := 0
ctx, store := output.WithResultStore(context.Background())
cmd := New(Spec{
Use: "result",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
calls++
return output.Success(map[string]any{"id": "a"}), nil
},
})
cmd.SetContext(ctx)
cmd.PersistentFlags().String("format", "json", "")
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("calls=%d, want 1", calls)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
t.Fatalf("stdout=%s", stdout.String())
}
if output.CommandRollout(cmd) != output.RolloutUnifiedActive {
t.Fatalf("rollout=%s", output.CommandRollout(cmd))
}
}
+82 -2
View File
@@ -29,6 +29,16 @@ import (
var marshalErrorJSON = jsonutil.MarshalIndent var marshalErrorJSON = jsonutil.MarshalIndent
// outcomeFailure 是错误通道的结果标记(契约规范 §2.5 outcome 四值枚举的
// failure 值,B169)。字面量与 internal/output OutcomeFailure 同源同值;
// 不跨包引用是因为 output 已导入 errors,反向引用会形成导入环。
const outcomeFailure = "failure"
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4,
// B170)。字面量与 internal/output subtypeConfirmationRequired 及 runtime
// WithReason("confirmation_required") 形态三方同源。
const subtypeConfirmationRequired = "confirmation_required"
// Category represents a stable error class with a documented exit code. // Category represents a stable error class with a documented exit code.
type Category string type Category string
@@ -38,6 +48,41 @@ const (
CategoryValidation Category = "validation" CategoryValidation Category = "validation"
CategoryDiscovery Category = "discovery" CategoryDiscovery Category = "discovery"
CategoryInternal Category = "internal" CategoryInternal Category = "internal"
// CategoryPartial is retained for source compatibility, but an error cannot
// reconstruct the per-item data required by a partial result. It therefore
// fails closed as internal; callers must use output.Partial for exit code 7.
CategoryPartial Category = "partial_failure"
)
// 退出码表(规划 v1.2 OQ-1 定案;契约规范 §4;轮10裁决⑬——保留现行码表,
// 仅新增 partial_failure 专用码,不做 wire 破坏性重排):
//
// 0 success / pending(异步受理不是失败)
// 1 api (CategoryAPI)
// 2 auth (CategoryAuth)
// 3 validation (CategoryValidation;confirmation_required 子类共享此码,
// 以 reason/subtype 区分,AC-13)
// 4 PAT (PATError 专属,见 pat.go ExitCodePermission;Category 不占用)
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
// 6 discovery (CategoryDiscovery)
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
//
// ExitCodePartial is the partial-result exit code shared with internal/output.
// It is not returned for CategoryPartial errors because they lack the typed
// succeeded/failed/unknown payload required for an honest partial result.
const ExitCodePartial = 7
// 类别专属退出码常量(B171/B172,权威 = 规划 v1.2 OQ-1 定案,契约规范 §4)。
// ExitCode() 的 switch 用内联字面量,本组常量由 exitcodes.go 的
// exitCodeByCategory 映射表引用,值与内联字面量一一对应(同源不双轨)。
// 修改任一值必须先同步 ExitCode() 的 switch 分支与 internal/output 侧码表。
const (
ExitCodeAPI = 1
ExitCodeAuth = 2
ExitCodeValidation = 3
ExitCodeDiscovery = 6
ExitCodeInternal = 5
) )
// Error is the structured repository-local error model for the Go rewrite. // Error is the structured repository-local error model for the Go rewrite.
@@ -82,6 +127,13 @@ type Option func(*Error)
// ExitCodePermission and the exit-code table in docs/reference.md); // ExitCodePermission and the exit-code table in docs/reference.md);
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke" // Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
// apart from "PAT permission insufficient". // apart from "PAT permission insufficient".
//
// confirmation_required 是 validation 的子类而非独立类别(B171,AC-13,
// 规划 v1.2 OQ-1 定案):门禁拦截错误挂 CategoryValidation 并以
// reason=confirmation_required 区分,与 validation 共享 rc=3。信封侧
// internal/output exitCodeForErrorInfo 的「subtype 优先于 type、
// confirmation_required 恒 3」规则与本表同源(轮10裁决⑬;远期独立码
// 保留于规划 OQ-9,落地前不得双轨)。
func (e *Error) ExitCode() int { func (e *Error) ExitCode() int {
switch e.Category { switch e.Category {
case CategoryAPI: case CategoryAPI:
@@ -92,6 +144,10 @@ func (e *Error) ExitCode() int {
return 3 return 3
case CategoryDiscovery: case CategoryDiscovery:
return 6 return 6
case CategoryPartial:
// An error has no per-item succeeded/failed/unknown data and therefore
// cannot truthfully represent partial_failure. Fail closed as internal.
return ExitCodeInternal
default: default:
return 5 return 5
} }
@@ -148,6 +204,11 @@ func WithRetryable(retryable bool) Option {
// WithRetryAfterSeconds records the server-recommended delay before a retry. // WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are // A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance. // ignored as invalid server guidance.
//
// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上
// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于
// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案:
// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。
func WithRetryAfterSeconds(seconds int64) Option { func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) { return func(err *Error) {
if seconds < 0 { if seconds < 0 {
@@ -322,17 +383,27 @@ func ExitCode(err error) int {
} }
// PrintJSON writes a machine-readable JSON error object. // PrintJSON writes a machine-readable JSON error object.
// wire 键与 internal/output Envelope.Error(契约规范 §2.4)对称:
// `category` 为 legacy 兼容键(auth_refresh_retry_runner_test 等既有消费方
// 依赖,B173 保留),`type` 为与 output 侧 ErrorInfo.Type 对齐的规范键
// (B173,category→type 映射:值同源 = category(err));两者值恒等。
// `outcome` 恒为 "failure"(错误信封恒是失败结果,B169);`subtype` 在
// Reason 已知时投影(B170,confirmation_required/rate_limit 等)。
func PrintJSON(w io.Writer, err error) error { func PrintJSON(w io.Writer, err error) error {
errorPayload := map[string]any{ errorPayload := map[string]any{
"code": ExitCode(err), "code": ExitCode(err),
"category": category(err), "category": category(err),
"type": category(err),
"message": err.Error(), "message": err.Error(),
} }
var typed *Error var typed *Error
if stderrors.As(err, &typed) { if stderrors.As(err, &typed) {
if typed.Reason != "" { if typed.Reason != "" {
errorPayload["reason"] = typed.Reason errorPayload["reason"] = typed.Reason
// Reason→subtype 投影(B170,契约 §2.4):reason 值即 subtype
// 规范值(confirmation_required/rate_limit 等),与 output 侧
// ErrorInfo.Subtype 对称。仅当 Reason 非空时投影(omitempty)。
errorPayload["subtype"] = typed.Reason
} }
if typed.Operation != "" { if typed.Operation != "" {
errorPayload["operation"] = typed.Operation errorPayload["operation"] = typed.Operation
@@ -404,7 +475,7 @@ func PrintJSON(w io.Writer, err error) error {
errorPayload["cause"] = typed.Cause.Error() errorPayload["cause"] = typed.Cause.Error()
} }
} }
payload := map[string]any{"error": errorPayload} payload := map[string]any{"outcome": outcomeFailure, "error": errorPayload}
data, marshalErr := marshalErrorJSON(payload, "", " ") data, marshalErr := marshalErrorJSON(payload, "", " ")
if marshalErr != nil { if marshalErr != nil {
@@ -555,9 +626,18 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
return friendlyHint, actionURL return friendlyHint, actionURL
} }
// ServerGuidance exposes the same recovery projection to repository-local
// adapters so legacy JSON and Framework 2.0 errors stay semantically aligned.
func ServerGuidance(diag ServerDiagnostics) (string, string) {
return serverGuidance(diag)
}
func category(err error) string { func category(err error) string {
var typed *Error var typed *Error
if stderrors.As(err, &typed) { if stderrors.As(err, &typed) {
if typed.Category == CategoryPartial {
return string(CategoryInternal)
}
return string(typed.Category) return string(typed.Category)
} }
return string(CategoryInternal) return string(CategoryInternal)
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package errors
import "testing"
// TestErrorsExitCodeMapConsistentWithExitCode 是 B209 的 errors 侧同源锁定:
// exitcodes.go 的 exitCodeByCategory 映射表必须与 ExitCode() 的 switch 分支
// 逐类别一致(同源不双轨,契约 §4)。任一单边修改即失败,防止未来漂移。
// output 侧同源锁定由 internal/output emitter_phase_c_test.go
// TestExitCodeForEnvelopeSameSourceAsErrorsExitCode 交叉断言(B209)。
func TestErrorsExitCodeMapConsistentWithExitCode(t *testing.T) {
t.Parallel()
cats := []Category{
CategoryAPI,
CategoryAuth,
CategoryValidation,
CategoryDiscovery,
CategoryInternal,
CategoryPartial,
}
for _, cat := range cats {
table := exitCodeByCategory[cat]
viaSwitch := (&Error{Category: cat, Message: "x"}).ExitCode()
if table != viaSwitch {
t.Fatalf("exitCodeByCategory[%q]=%d disagrees with ExitCode()=%d", cat, table, viaSwitch)
}
}
}
// TestErrorsExitCodeConstantsEqualMap 锁定类别专属常量与映射表值一致。
func TestErrorsExitCodeConstantsEqualMap(t *testing.T) {
t.Parallel()
want := map[Category]int{
CategoryAPI: ExitCodeAPI,
CategoryAuth: ExitCodeAuth,
CategoryValidation: ExitCodeValidation,
CategoryDiscovery: ExitCodeDiscovery,
CategoryInternal: ExitCodeInternal,
CategoryPartial: ExitCodeInternal,
}
for cat, wantCode := range want {
if got := exitCodeByCategory[cat]; got != wantCode {
t.Fatalf("exitCodeByCategory[%q]=%d, want %d", cat, got, wantCode)
}
}
}
+466
View File
@@ -0,0 +1,466 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package errors
import (
"encoding/json"
"strings"
"testing"
"time"
)
// TestErrorsPrintJSONFieldInventory 是 B168 的 PrintJSON 字段清单核对测试:
// 与 internal/output Envelope.Error / ErrorInfo 的字段逐项对齐(契约规范
// §2.4)。wire-stable 组(type/subtype/code/retryable/retry_after_seconds)
// 与 informational 组(message/hint/request_id)均由单一 PrintJSON 路径产出,
// 字段名与 output 侧 ErrorInfo json tag 一致。
func TestErrorsPrintJSONFieldInventory(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"too many requests",
WithReason("rate_limit"),
WithHint("wait and retry"),
WithRetryable(true),
WithRetryAfterSeconds(30),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
// wire-stable 组(B173 category→type:type 键存在且值 = category)
for _, want := range []string{
`"type": "api"`,
`"subtype": "rate_limit"`,
`"code": 1`,
`"retryable": true`,
`"retry_after_seconds": 30`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing wire-stable field %s in %s", want, got)
}
}
// informational 组
for _, want := range []string{
`"message": "too many requests"`,
`"hint": "wait and retry"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing informational field %s in %s", want, got)
}
}
}
// TestErrorsPrintJSONOutcomeFailure 是 B169 的 outcome 字段断言:错误信封
// 顶层恒携带 outcome=failure(契约 §1/§2.5),与 internal/output 侧
// OutcomeFailure 同值。非错误信封触发路径(未走 PrintJSON 的错误)不要求。
func TestErrorsPrintJSONOutcomeFailure(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAuth("token expired")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(b.String()), &payload); err != nil {
t.Fatal(err)
}
if payload["outcome"] != "failure" {
t.Fatalf("top-level outcome=%v, want failure", payload["outcome"])
}
errorPayload := payload["error"].(map[string]any)
if _, nested := errorPayload["outcome"]; nested {
t.Fatalf("outcome must be a sibling of error: %s", b.String())
}
}
// TestErrorsPrintJSONSubtypeProjection 是 B170 的 subtype 投影断言:Reason
// 非空时投影为 error.subtype(confirmation_required/rate_limit 等),与
// output 侧 ErrorInfo.Subtype 对称;Reason 为空时 subtype 缺席(omitempty)。
func TestErrorsPrintJSONSubtypeProjection(t *testing.T) {
t.Parallel()
t.Run("confirmation_required", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"confirmation required",
WithReason("confirmation_required"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"subtype": "confirmation_required"`) {
t.Fatalf("expected confirmation_required subtype, got %q", got)
}
if !strings.Contains(got, `"type": "validation"`) {
t.Fatalf("expected validation type, got %q", got)
}
})
t.Run("no reason omits subtype", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("plain")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"subtype"`) {
t.Fatalf("subtype must be omitted when Reason is empty, got %q", b.String())
}
})
}
// TestErrorsConfirmationSharesValidationExitCode 是 B171 的契约断言:
// confirmation_required 是 validation 的子类,共享 rc=3(AC-13,规划 v1.2
// OQ-1 定案),靠 error.subtype 区分,而非独立退出码。
func TestErrorsConfirmationSharesValidationExitCode(t *testing.T) {
t.Parallel()
confirmation := NewValidation("blocked", WithReason("confirmation_required"))
validation := NewValidation("bad param")
if got := ExitCode(confirmation); got != 3 {
t.Fatalf("confirmation ExitCode = %d, want 3 (shared with validation)", got)
}
if got := ExitCode(validation); got != 3 {
t.Fatalf("validation ExitCode = %d, want 3", got)
}
// subtype 区分二者(B170 wire 投影)
var b strings.Builder
if err := PrintJSON(&b, confirmation); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"subtype": "confirmation_required"`) {
t.Fatalf("confirmation must carry subtype, got %q", b.String())
}
}
func TestErrorsPartialCategoryFailsClosedAsInternal(t *testing.T) {
t.Parallel()
err := &Error{Category: CategoryPartial, Message: "partial"}
if got := ExitCode(err); got != ExitCodeInternal {
t.Fatalf("ExitCode(partial error) = %d, want internal %d", got, ExitCodeInternal)
}
if ExitCodePartial != 7 {
t.Fatalf("ExitCodePartial = %d, want 7", ExitCodePartial)
}
var b strings.Builder
if err := PrintJSON(&b, err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"code": 5`) || !strings.Contains(b.String(), `"type": "internal"`) {
t.Fatalf("partial error must not masquerade as a partial result: %q", b.String())
}
}
// TestErrorsCategoryMapsToType 是 B173 的 category→error.type 映射断言:
// 每个 Category 在 PrintJSON 的 wire 上同时产出 category(legacy)与 type
// (规范)两键,且两者值恒等(与 output 侧 ErrorInfo.Type 对齐)。
func TestErrorsCategoryMapsToType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want string
}{
{"api", NewAPI("x"), "api"},
{"auth", NewAuth("x"), "auth"},
{"validation", NewValidation("x"), "validation"},
{"discovery", NewDiscovery("x"), "discovery"},
{"internal", NewInternal("x"), "internal"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, tc.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"type": "`+tc.want+`"`) {
t.Fatalf("expected type %q in %s", tc.want, got)
}
if !strings.Contains(got, `"category": "`+tc.want+`"`) {
t.Fatalf("expected legacy category %q in %s", tc.want, got)
}
})
}
}
// TestErrorsWireStableFieldsSubset 是 B174 的断言:PrintJSON 产出的可编程
// 字段键全部落在 wire.go WireStableErrorBodyFields 声明的 wire-stable 集合内
// (除 legacy 兼容键 category/reason/operation 等 informational 附属键)。
// 本测试核对 wire-stable 组字段(type/subtype/code/retryable/
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data)均被
// PrintJSON 产出(在对应 Option 提供时)。
func TestErrorsWireStableFieldsSubset(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"rpc failed",
WithReason("rate_limit"),
WithHint("h"),
WithRetryable(true),
WithRetryAfterSeconds(5),
WithActions("retry"),
WithServerDiag(ServerDiagnostics{TraceID: "t-1"}),
WithRPCCode(-32602),
WithRPCData([]byte(`{"field":"x"}`)),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"type"`, `"subtype"`, `"code"`, `"retryable"`, `"retry_after_seconds"`,
`"message"`, `"hint"`, `"actions"`, `"trace_id"`, `"rpc_code"`, `"rpc_data"`,
} {
if !strings.Contains(got, want) {
t.Errorf("wire-stable field %s missing from %s", want, got)
}
}
}
// TestErrorsRetryableOmitEmpty 是 B175 的断言:retryable 仅 true 时出现在 wire
// (与 output 侧 ErrorInfo.Retryable omitempty 一致);未知三态(RetryableSet
// 未置)时 retryable 缺席。
func TestErrorsRetryableOmitEmpty(t *testing.T) {
t.Parallel()
t.Run("true present", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryable(true))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retryable": true`) {
t.Fatalf("expected retryable:true, got %q", b.String())
}
})
t.Run("unset omitted", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"retryable"`) {
t.Fatalf("unknown retryability must be omitted, got %q", b.String())
}
})
}
// TestErrorsCategorySnapshots 是 B176/B177 的类别错误信封快照测试:api/auth/
// validation(B176)与 discovery/internal/plain(B177)各自产出 stable 的
// type/code 组合,plain 错误归 internal(rc=5)。
func TestErrorsCategorySnapshots(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
typ string
code string
}{
{"api", NewAPI("x"), "api", `"code": 1`},
{"auth", NewAuth("x"), "auth", `"code": 2`},
{"validation", NewValidation("x"), "validation", `"code": 3`},
{"discovery", NewDiscovery("x"), "discovery", `"code": 6`},
{"internal", NewInternal("x"), "internal", `"code": 5`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, tc.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"type": "`+tc.typ+`"`) || !strings.Contains(got, tc.code) {
t.Fatalf("snapshot mismatch for %s: %s", tc.name, got)
}
})
}
}
// TestErrorsActionsArrayPassthrough 是 B178 的 actions 数组透传断言:Actions
// (含 --yes 版本补救命令)原样进 wire,空串条目被过滤。
func TestErrorsActionsArrayPassthrough(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"confirm required",
WithReason("confirmation_required"),
WithActions("dws chat send --yes", "", "dws chat cancel"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"actions"`,
`"dws chat send --yes"`,
`"dws chat cancel"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected %s in actions, got %q", want, got)
}
}
// 空串条目被过滤:不应出现空引号动作。
if strings.Contains(got, `""`) {
t.Fatalf("empty action must be filtered, got %q", got)
}
}
// TestErrorsTraceRPCAndServerDiagPassthrough 是 B179 的透传保留断言:
// trace_id/rpc_code/rpc_data 原样保留在 wire(informational,不进分支字段),
// 与 output 侧 ErrorInfo 的 ServerDiag/RPC 字段对齐。
func TestErrorsTraceRPCAndServerDiagPassthrough(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewAPI(
"rpc failed",
WithServerDiag(ServerDiagnostics{TraceID: "trace-abc"}),
WithRPCCode(-32602),
WithRPCData([]byte(`{"field":"base_id"}`)),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{
`"trace_id": "trace-abc"`,
`"rpc_code": -32602`,
`"field"`,
`"base_id"`,
} {
if !strings.Contains(got, want) {
t.Errorf("expected %s in %s", want, got)
}
}
}
// TestErrorsOutcomeOmitEmptyNonEnvelope 是 B180 的 outcome 语义断言:错误信封
// 路径恒携带 outcome=failure;非信封触发路径(纯 error 值,未走 PrintJSON)
// 不产生 outcome 字段——PrintJSON 只在错误信封场景补 outcome,不影响普通
// 错误文本通道。
func TestErrorsOutcomeOmitEmptyNonEnvelope(t *testing.T) {
t.Parallel()
// PrintJSON 恒为失败信封:outcome 恒在(B169)。
var b strings.Builder
if err := PrintJSON(&b, NewInternal("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"outcome": "failure"`) {
t.Fatalf("failure envelope must carry outcome=failure, got %q", b.String())
}
}
// TestWithRetryAfterSecondsPassthrough 是 B195 的透传断言:WithRetryAfterSeconds
// 把服务端给出的秒数原样存入 RetryAfterSeconds,不做任何钳制(钳制只作用于
// transport 重试延迟选择,B196 双通道分离)。
func TestWithRetryAfterSecondsPassthrough(t *testing.T) {
t.Parallel()
err := NewAPI("limit", WithRetryAfterSeconds(900)).(*Error)
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 900 {
t.Fatalf("RetryAfterSeconds = %v, want 900 (unclamped)", err.RetryAfterSeconds)
}
}
// TestRetryAfterZeroValuePreserved 是 B198 的零值语义断言:0 秒是有意义的
// 服务端建议(立即重试),必须保留;负值被视为非法服务端指引而被拒绝
// (WithRetryAfterSeconds 忽略负值)。
func TestRetryAfterZeroValuePreserved(t *testing.T) {
t.Parallel()
zero := NewAPI("x", WithRetryAfterSeconds(0)).(*Error)
if zero.RetryAfterSeconds == nil || *zero.RetryAfterSeconds != 0 {
t.Fatalf("zero RetryAfterSeconds must be preserved, got %v", zero.RetryAfterSeconds)
}
negative := NewAPI("x", WithRetryAfterSeconds(-1)).(*Error)
if negative.RetryAfterSeconds != nil {
t.Fatalf("negative RetryAfterSeconds must be rejected, got %v", *negative.RetryAfterSeconds)
}
}
// TestRetryAfterSecondsWirePassthrough 是 B199 的 wire 透传断言:retry_after_seconds
// 在 PrintJSON 错误 JSON 中原样出现,值未被 transport 钳制改写(0 秒也透传)。
func TestRetryAfterSecondsWirePassthrough(t *testing.T) {
t.Parallel()
t.Run("nonzero", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(60))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retry_after_seconds": 60`) {
t.Fatalf("expected retry_after_seconds:60 in wire, got %q", b.String())
}
})
t.Run("zero preserved", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x", WithRetryAfterSeconds(0))); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if !strings.Contains(b.String(), `"retry_after_seconds": 0`) {
t.Fatalf("expected retry_after_seconds:0 preserved in wire, got %q", b.String())
}
})
t.Run("unset omitted", func(t *testing.T) {
var b strings.Builder
if err := PrintJSON(&b, NewAPI("x")); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
if strings.Contains(b.String(), `"retry_after_seconds"`) {
t.Fatalf("retry_after_seconds must be omitted when unset, got %q", b.String())
}
})
}
// TestRetryAfterSecondsAndNextRetryAtConsistency 是 B200 的一致性断言:
// RetryAfterSeconds 与 NextRetryAt 两字段同源(都描述"何时可重试")且可共存
// 不互斥;Promise 使用 UTC 归一化(NextRetryAt 转 UTC)。
func TestRetryAfterSecondsAndNextRetryAtConsistency(t *testing.T) {
t.Parallel()
tz := time.FixedZone("CST", 8*60*60)
next := time.Date(2026, time.August, 7, 22, 0, 0, 0, tz)
err := NewAPI("x", WithRetryAfterSeconds(30), WithNextRetryAt(next)).(*Error)
if err.RetryAfterSeconds == nil || *err.RetryAfterSeconds != 30 {
t.Fatalf("RetryAfterSeconds = %v, want 30", err.RetryAfterSeconds)
}
if err.NextRetryAt == nil {
t.Fatal("NextRetryAt must be set")
}
// 两字段同源并存(B200),NextRetryAt 归一化为 UTC。
if got := err.NextRetryAt.UTC().Format(time.RFC3339); got != "2026-08-07T14:00:00Z" {
t.Fatalf("NextRetryAt UTC = %s, want 2026-08-07T14:00:00Z", got)
}
// wire 上两字段同现且互不覆盖。
var b strings.Builder
if err := PrintJSON(&b, err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"retry_after_seconds": 30`) {
t.Fatalf("missing retry_after_seconds:30 in %s", got)
}
if !strings.Contains(got, `"next_retry_at": "2026-08-07T14:00:00Z"`) {
t.Fatalf("missing next_retry_at in %s", got)
}
}
+18
View File
@@ -0,0 +1,18 @@
package errors
// 统一退出码表(B171/B172;轮 10 裁决⑬,权威 = 规划 v1.2 OQ-1 定案):
// 类别与退出码一一对应,`confirmation_required` 是 `validation` 下的子类
// 共享 3,不新增独立退出码。
//
// 跨包同源锁定:internal/output.ExitCodeForEnvelope 对同一信封必须给出
// 本表完全一致的码(api=1/auth=2/validation=3/discovery=6/internal=5)。
// Typed output.Partial remains the only path to partial_failure exit 7.
// 修改本表 = 契约变更,必须双侧同步并更新两侧同源测试。
var exitCodeByCategory = map[Category]int{
CategoryAPI: ExitCodeAPI,
CategoryAuth: ExitCodeAuth,
CategoryValidation: ExitCodeValidation,
CategoryDiscovery: ExitCodeDiscovery,
CategoryInternal: ExitCodeInternal,
CategoryPartial: ExitCodeInternal,
}
+41
View File
@@ -0,0 +1,41 @@
package errors
// 错误信封 wire-stable 字段集(契约规范 §2.4;B174)。
//
// Agent 可编程分流的字段集合:type/subtype/code/retryable/
// retry_after_seconds/message/hint/actions/trace_id/rpc_code/rpc_data/
// outcome。wireErrors 的每个字段都必须落在此集合内;新增字段 = 契约
// 扩展,需评审。wireErrors 未声明 JSON tag 的字段(如 Cause)是内部
// 字段,序列化缺席,不属于 wire。
// WireStableFields 是错误信封 wire-stable 字段名全集(含 outcome)。
var WireStableFields = []string{
"type",
"subtype",
"code",
"retryable",
"retry_after_seconds",
"message",
"hint",
"actions",
"trace_id",
"rpc_code",
"rpc_data",
"outcome",
}
// WireStableErrorBodyFields 是 error 对象体(不含顶层 outcome)的
// wire-stable 字段名子集。
var WireStableErrorBodyFields = []string{
"type",
"subtype",
"code",
"retryable",
"retry_after_seconds",
"message",
"hint",
"actions",
"trace_id",
"rpc_code",
"rpc_data",
}
+43 -24
View File
@@ -22,6 +22,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
) )
@@ -133,10 +134,11 @@ type LeafContract = corecmd.ContractDecl
// //
// 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。 // 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。
type LeafSpec struct { type LeafSpec struct {
Use string Use string
Short string Short string
Long string Long string
Example string Example string
OutputRollout output.RolloutState
// Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走 // Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走
// callMCPTool(按 product 路由)。Call 非空时两者都被忽略。不是 CLI 声明。 // callMCPTool(按 product 路由)。Call 非空时两者都被忽略。不是 CLI 声明。
@@ -167,7 +169,8 @@ type LeafSpec struct {
// Call 是执行体:非空时替代默认 MCP 派发。toolArgs 已由 Flags/ConstParams // Call 是执行体:非空时替代默认 MCP 派发。toolArgs 已由 Flags/ConstParams
// 装配完成;Call 不应再写业务参数。分页等横切由领域工具处理,不进声明。 // 装配完成;Call 不应再写业务参数。分页等横切由领域工具处理,不进声明。
Call func(cmd *cobra.Command, tool string, args map[string]any) error Call func(cmd *cobra.Command, tool string, args map[string]any) error
ResultCall func(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error)
// Validate 是编排钩子(条件式校验),不是声明面。单 flag 转换用 // Validate 是编排钩子(条件式校验),不是声明面。单 flag 转换用
// LeafFlag.Transform;可声明的互斥/至少一个应写 Constraints。 // LeafFlag.Transform;可声明的互斥/至少一个应写 Constraints。
@@ -225,6 +228,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
if spec.Call != nil { if spec.Call != nil {
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Call must be nil (metadata-only mode)", name)) panic(fmt.Sprintf("DeclareLeafMetadata(%q): Call must be nil (metadata-only mode)", name))
} }
if spec.ResultCall != nil {
panic(fmt.Sprintf("DeclareLeafMetadata(%q): ResultCall must be nil (metadata-only mode)", name))
}
if spec.RunE != nil { if spec.RunE != nil {
panic(fmt.Sprintf("DeclareLeafMetadata(%q): RunE must be nil (metadata-only mode)", name)) panic(fmt.Sprintf("DeclareLeafMetadata(%q): RunE must be nil (metadata-only mode)", name))
} }
@@ -241,6 +247,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
panic(fmt.Sprintf("DeclareLeafMetadata(%q): Contract is required", name)) panic(fmt.Sprintf("DeclareLeafMetadata(%q): Contract is required", name))
} }
corecmd.AttachContract(cmd, spec.Safety, spec.Contract, cmd.Short, cmd.Long) corecmd.AttachContract(cmd, spec.Safety, spec.Contract, cmd.Short, cmd.Long)
if spec.OutputRollout != "" {
output.SetCommandRollout(cmd, spec.OutputRollout)
}
confirm := strings.TrimSpace(spec.Safety.Confirmation) == "user_required" confirm := strings.TrimSpace(spec.Safety.Confirmation) == "user_required"
if spec.Validate == nil && !confirm { if spec.Validate == nil && !confirm {
@@ -390,30 +399,40 @@ func HasContractConfirmDeferred(cmd *cobra.Command) bool {
// dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product // dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product
// 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。 // 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。
func FromLeafSpec(spec LeafSpec) corecmd.Spec { func FromLeafSpec(spec LeafSpec) corecmd.Spec {
if spec.Call != nil && spec.ResultCall != nil {
panic(fmt.Sprintf("command %q must not declare both Call and ResultCall", spec.Use))
}
cs := corecmd.Spec{ cs := corecmd.Spec{
Use: spec.Use, Use: spec.Use,
Short: spec.Short, Short: spec.Short,
Long: spec.Long, Long: spec.Long,
Example: spec.Example, Example: spec.Example,
Flags: spec.Flags, OutputRollout: spec.OutputRollout,
Constraints: spec.Constraints, Flags: spec.Flags,
Safety: spec.Safety, Constraints: spec.Constraints,
ConfirmFirst: spec.ConfirmFirst, Safety: spec.Safety,
ConstParams: spec.ConstParams, ConfirmFirst: spec.ConfirmFirst,
Contract: spec.Contract, ConstParams: spec.ConstParams,
Validate: spec.Validate, Contract: spec.Contract,
PostMount: spec.PostMount, Validate: spec.Validate,
RunE: spec.RunE, PostMount: spec.PostMount,
RunE: spec.RunE,
} }
if spec.RunE == nil { if spec.RunE == nil {
cs.Invoke = func(c *corecmd.Ctx, toolArgs map[string]any) error { if spec.ResultCall != nil {
if spec.Call != nil { cs.ResultInvoke = func(c *corecmd.Ctx, toolArgs map[string]any) (output.CommandResult, error) {
return spec.Call(c.Command(), spec.Tool, toolArgs) return spec.ResultCall(c.Command(), spec.Tool, toolArgs)
} }
if spec.Server != "" { } else {
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs) cs.Invoke = func(c *corecmd.Ctx, toolArgs map[string]any) error {
if spec.Call != nil {
return spec.Call(c.Command(), spec.Tool, toolArgs)
}
if spec.Server != "" {
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
}
return callMCPTool(spec.Tool, toolArgs)
} }
return callMCPTool(spec.Tool, toolArgs)
} }
} }
return cs return cs
+493
View File
@@ -0,0 +1,493 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
)
// WriteEnvelope 是统一信封的出口函数(B0 dev 试点桥接,WS1 改动点2/4)。
// 它复用 envelope.go 的权威 Envelope 类型,把信封按当前 --format 分发写出。
// Phase B 的 Emitter 类型将在此之上泛化(渲染先进 buffer、exit code 映射等);
// 本函数仅提供 Phase F dev 域迁移所需的「信封 → cmd 流」出口,不重复定义类型。
//
// format 分发(契约规范 §5.2,经 renderEnvelope 实现):
// - json(默认):输出完整信封——唯一 JSON 契约(§5.2 首行)。
// - table/pretty:业务数据人读视图 + meta 摘要(不输出信封外壳)。
// - csv/ndjson:裸记录(无信封);meta.pagination 走 stderr 诊断行。
// - raw:数据载荷透传(与 Write 的 raw 语义一致)。
//
// 数据出口一律写 cmd.OutOrStdout()、诊断出口写 cmd.ErrOrStderr()(均可被
// 测试重定向),不硬编码 os.Stdout/os.Stderr。未知 --format 值按 §5.2/AC-09
// 降级 fallback 并在此向 stderr 写一条 warning(不崩不静默)。
func WriteEnvelope(cmd *cobra.Command, env *Envelope, fallback Format) error {
w, errW := io.Writer(io.Discard), io.Writer(io.Discard)
format := fallback
fields, jq, warning := "", "", ""
if cmd != nil {
w = cmd.OutOrStdout()
errW = cmd.ErrOrStderr()
format, warning = resolveFormatWithWarning(cmd, fallback)
fields = ResolveFields(cmd)
jq = ResolveJQ(cmd)
}
if env == nil {
env = nilFallbackEnvelope()
}
if err := renderEnvelope(w, errW, env, format, fields, jq); err != nil {
return err
}
// AC-09「不静默」:warning 仅在信封成功写出后补写 stderr——
// 写出失败时错误优先,不留半截通道。
if warning != "" {
if _, werr := fmt.Fprintln(errW, "[WARN] "+warning); werr != nil {
return werr
}
}
return nil
}
// WriteEnvelopeTo 是 WriteEnvelope 的 writer 级实现(签名自 B205 保持不变),
// 供不持有 *cobra.Command 的调用方与单测直接使用。B37 起收编到 renderEnvelope
// 统一分发:json/空 format 输出完整信封(唯一 JSON 契约);table/pretty 输出
// 业务数据人读视图 + meta 摘要;csv/ndjson 输出裸记录(分页诊断行在本签名下
// 无 stderr 出口可走,按 io.Discard 处理——需要诊断行的调用方走 WriteEnvelope
// 的 cmd 路径或 Emitter)。nil 信封降级为 I3 合法的 failure 兜底信封(B208):
// 携带 internal 类 ErrorInfo——框架最后的兜底不得产出被自身 Validate() 拒绝的形态。
func WriteEnvelopeTo(w io.Writer, env *Envelope, format Format, fields, jq string) error {
if env == nil {
env = nilFallbackEnvelope()
}
return renderEnvelope(w, io.Discard, env, format, fields, jq)
}
// EmitResult is the framework 2.0 CLI adapter. Unlike the legacy Emitter,
// machine-readable JSON/NDJSON always writes the primary result to stdout for
// every outcome; stderr is diagnostics-only. Human failure formats retain the
// conventional stderr path. The result is validated before any byte is
// rendered, and the returned exit code comes from the same immutable result.
func EmitResult(cmd *cobra.Command, result CommandResult) (int, error) {
code, _, err := emitResult(cmd, result)
return code, err
}
func emitResult(cmd *cobra.Command, result CommandResult) (int, bool, error) {
if err := ValidateResult(result); err != nil {
return exitCodeInternal, false, err
}
env := result.envelope()
env = redactEnvelope(env)
format, warning := resolveFormatWithWarning(cmd, FormatJSON)
fields, jq := ResolveFields(cmd), ResolveJQ(cmd)
stdout, stderr := io.Writer(io.Discard), io.Writer(io.Discard)
if cmd != nil {
stdout, stderr = cmd.OutOrStdout(), cmd.ErrOrStderr()
}
if env.Outcome == OutcomeFailure && format != FormatJSON && format != FormatNDJSON {
message := "command failed"
if env.Error != nil && strings.TrimSpace(env.Error.Message) != "" {
message = env.Error.Message
}
var buf bytes.Buffer
if _, err := fmt.Fprintln(&buf, "Error: "+message); err != nil {
return exitCodeInternal, false, err
}
n, err := writeAllCount(stderr, buf.Bytes())
if err != nil {
return exitCodeInternal, n > 0, err
}
return result.ExitCode(), n > 0, nil
}
// Failure bypasses jq/fields so a filter cannot erase the typed error.
if env.Outcome == OutcomeFailure {
fields, jq = "", ""
}
var buf bytes.Buffer
if err := renderEnvelope(&buf, stderr, env, format, fields, jq); err != nil {
return exitCodeInternal, false, err
}
n, err := writeAllCount(stdout, buf.Bytes())
if err != nil {
return exitCodeInternal, n > 0, err
}
if warning != "" {
// The primary result is already complete. Diagnostics are best-effort
// and must not reverse its outcome when stderr is unavailable.
_, _ = fmt.Fprintln(stderr, "[WARN] "+warning)
}
return result.ExitCode(), n > 0, nil
}
// nilFallbackEnvelope 构造 nil 信封的兜底信封(B208,轮6裁决⑨)。保留
// 「不 panic、必产合法 failure」的既有语义,且自身满足不变量 I3
// (error 非空 ⇔ outcome==failure)、通过 Envelope.Validate()——框架出口
// 兜底不得被自己的校验器判非法。Type 取 "internal":这是框架内部缺陷而非
// 业务错误(§4 internal=5),Agent 可据 wire-stable 字段分支处置。
func nilFallbackEnvelope() *Envelope {
return &Envelope{
OK: false,
Outcome: OutcomeFailure,
Error: &ErrorInfo{
Type: "internal",
Message: "nil envelope: framework fallback",
},
}
}
// ---------------------------------------------------------------------------
// format 分发(B35~B40):信封 → format 矩阵的统一分发点
// (契约规范 §5.2;适用域仅 stdout 数据通道——轮8裁决⑪)
// ---------------------------------------------------------------------------
// renderEnvelope 是信封渲染的统一分发点(B37,WS1 改动点4;契约规范 §5.2
// format 矩阵)。渲染先进 buffer 再一次性写出:数据通道任何一步失败都不向
// w 泄漏半截输出(§5.2 末条;与 B25 buffer-first 同口径)。
//
// 矩阵各行(适用域仅 stdout 数据通道——失败信封在 Emitter 流路由层恒以
// 完整 JSON 写 stderr,从不进入本矩阵,轮8裁决⑪):
//
// - json / 空:完整信封——唯一 JSON 契约(§5.2 首行,B38);
// - table / pretty:业务数据人读视图 + meta 层人读摘要(count/pagination),
// 不输出信封外壳(§5.2 矩阵行,B39);
// - csv / ndjson:裸记录(无信封外壳);meta.pagination 走 errW 的 stderr
// 诊断行,不污染裸记录机器可读流(§5.2 矩阵行,B40);
// - raw:数据载荷透传(writeRaw 语义)。
//
// --jq 优先于 format:对**完整信封**求值后直接输出结果(§5.2 矩阵行,B42)。
// --fields 先投影数据载荷再按 format 分发(成功通道联动)。errW 为 nil 时按
// io.Discard 处理(csv/ndjson 诊断行无出口即丢弃)。
//
// buffer-first 纪律:全部分发路径(含 json/jq)先渲染进内存 buffer,成功后
// 才一次性写出到 w(§5.2 末条对分发点整体成立)——渲染失败零泄漏、写出
// 失败原子中止、短写报 io.ErrShortWrite。csv/ndjson 的 stderr 诊断行不经
// buffer,且在数据渲染成功后才写(渲染失败不留诊断噪声)。
func renderEnvelope(w io.Writer, errW io.Writer, env *Envelope, format Format, fields, jq string) error {
if errW == nil {
errW = io.Discard
}
if env == nil {
env = nilFallbackEnvelope()
}
env = redactEnvelope(env)
var buf bytes.Buffer
if err := renderEnvelopeInto(&buf, errW, env, format, fields, jq); err != nil {
return err
}
return writeAll(w, buf.Bytes())
}
// renderEnvelopeInto 按 format 矩阵把信封渲染进 buf(renderEnvelope 的渲染段)。
func renderEnvelopeInto(buf *bytes.Buffer, errW io.Writer, env *Envelope, format Format, fields, jq string) error {
// 失败信封不参与 format/fields/jq 分发(轮8裁决⑪;契约规范 §5.1 v1.1
// 回写条款):恒输出完整 JSON 信封。§5.2 矩阵适用域仅 stdout 数据通道,
// 失败信封无 data 载荷(I3),若按矩阵分发或经 jq/fields 过滤,§2.4
// wire-stable 字段组将从错误通道消失。
if env.Outcome == OutcomeFailure {
if format == FormatNDJSON {
return Write(buf, FormatNDJSON, env)
}
return WriteFiltered(buf, FormatJSON, env, "", "")
}
if strings.TrimSpace(jq) != "" {
// jq 优先于 format:对完整信封求值后直接输出(§5.2 矩阵行)。
return ApplyJQ(buf, env, strings.TrimSpace(jq))
}
switch format {
case FormatJSON, "":
// json(默认)= 完整信封(唯一 JSON 契约);fields 投影经
// WriteFiltered 作用于信封顶层键(既有语义,成功通道不变)。
return WriteFiltered(buf, FormatJSON, env, fields, "")
default:
}
payload := env.Data
if trimmed := strings.TrimSpace(fields); trimmed != "" {
payload = SelectFields(payload, strings.Split(trimmed, ","))
}
switch format {
case FormatTable, FormatPretty:
if err := Write(buf, format, payload); err != nil {
return err
}
if summary := renderMetaSummary(env.Meta); summary != "" {
if _, err := fmt.Fprintf(buf, "\n%s\n", summary); err != nil {
return err
}
}
return nil
case FormatCSV, FormatNDJSON:
if err := Write(buf, format, payload); err != nil {
return err
}
// 分页元数据走 stderr 诊断行(§5.2 csv/ndjson 行)。partial_failure
// 例外:§2.3 明确「stderr 零输出」,特例优先于 §5.2 诊断行通则。
if env.Outcome != OutcomePartialFailure {
return writePaginationDiagnostic(errW, env.Meta)
}
return nil
case FormatRaw:
return Write(buf, FormatRaw, payload)
default:
// 未知 Format 常量(归一化/ParseFormat 后理论不可达):按 json
// 数据通道语义兜底,绝不静默丢弃载荷。
return WriteFiltered(buf, FormatJSON, env, fields, "")
}
}
// writeAll 把 p 一次性写入 w;短写(接受了部分字节却未写全)报
// io.ErrShortWrite——与 Emitter.writeOnce 同一短写纪律,绝不把截断输出当成功。
func writeAll(w io.Writer, p []byte) error {
_, err := writeAllCount(w, p)
return err
}
func writeAllCount(w io.Writer, p []byte) (int, error) {
n, err := w.Write(p)
if err != nil {
return n, err
}
if n < len(p) {
return n, io.ErrShortWrite
}
return n, nil
}
// renderMetaSummary 把信封 meta 层渲染为人读摘要行(§5.2:table/pretty 输出
// 「业务数据 + 人读分页摘要」)。无 meta 内容返回空串。count 单独成行;
// pagination 摘要复用 renderPaginationSummary。
func renderMetaSummary(meta *Meta) string {
if meta == nil {
return ""
}
var lines []string
if meta.Count != nil {
lines = append(lines, fmt.Sprintf("count: %d", *meta.Count))
}
if meta.Pagination != nil {
lines = append(lines, "pagination: "+renderPaginationSummary(meta.Pagination))
}
return strings.Join(lines, "\n")
}
// renderPaginationSummary 按 wire 字段名输出分页摘要:
// endpoint_exhausted/pages/items/next_token(snake_case 与 wire 一致,Agent
// 可交叉核对;§3 两态显式语义保留——false 也输出,因为它是「可续跑」的
// 有意义状态)。
func renderPaginationSummary(p *Pagination) string {
parts := []string{fmt.Sprintf("endpoint_exhausted: %t", p.EndpointExhausted)}
if p.Pages > 0 {
parts = append(parts, fmt.Sprintf("pages: %d", p.Pages))
}
if p.Items > 0 {
parts = append(parts, fmt.Sprintf("items: %d", p.Items))
}
if p.NextToken != "" {
parts = append(parts, fmt.Sprintf("next_token: %s", p.NextToken))
}
return strings.Join(parts, ", ")
}
// writePaginationDiagnostic 把分页诊断行写到 errW(§5.2:csv/ndjson 裸记录
// 格式下分页元数据走 stderr 诊断行)。无 meta/pagination 时零输出——
// 不给无分页场景制造噪声。
func writePaginationDiagnostic(errW io.Writer, meta *Meta) error {
if meta == nil || meta.Pagination == nil {
return nil
}
_, err := fmt.Fprintln(errW, "[pagination] "+renderPaginationSummary(meta.Pagination))
return err
}
// ---------------------------------------------------------------------------
// Phase B 泛化(B24~B29):Emitter 类型——success/pending/partial 走 stdout、
// failure 走 stderr 的双向出口;渲染先进 buffer 再一次性写出。
// ---------------------------------------------------------------------------
// Emitter 是统一信封的双向出口(B24,WS1 改动点2)。它持有 stdout/stderr 两个
// 写入目标与当前 format/fields/jq 配置,按契约规范 §5.1 流向纪律把信封分发到
// 正确的流:
//
// - success / pending / partial_failure → stdout(数据通道);
// - failure(含 nil 信封降级)→ stderr,stdout 严格零字节(AC-11)。
//
// 每次写出都经过「先渲染进内存 buffer、再一次性写出」(B25,契约规范 §5.2
// 末条),写一半失败不产生残缺 JSON。渲染复用 WriteEnvelopeTo(B205),
// Emitter 不重新定义信封类型或 wire 键。
type Emitter struct {
stdout io.Writer
stderr io.Writer
format Format
fields string
jq string
}
// NewEmitter 构造 Emitter(B24)。nil writer 按 io.Discard 处理(等价丢弃,
// 防 panic)。format 是解析后的当前 format(空值由 WriteEnvelopeTo 按 json
// 处理);fields/jq 为命令解析出的 --fields/--jq 过滤条件。
func NewEmitter(stdout, stderr io.Writer, format Format, fields, jq string) *Emitter {
if stdout == nil {
stdout = io.Discard
}
if stderr == nil {
stderr = io.Discard
}
return &Emitter{stdout: stdout, stderr: stderr, format: format, fields: fields, jq: jq}
}
// Emit 按 env 的 outcome 把信封写到对应流(B26/B27/B28,契约规范 §5.1):
//
// - success / pending:完整信封落 stdout(B26,ok:true,§2.1/§2.2);
// - partial_failure:三通道明细落 stdout、stderr 零输出(B28,§2.3);
// - failure:信封落 stderr、stdout 严格零字节(B27,AC-11);
// - nil 信封由 WriteEnvelopeTo 降级为 failure,同样落 stderr。
//
// exit 0 语义标注(B32 收口,契约规范 §4/I2):Emit 的流路由与
// ExitCodeForEnvelope 的退出码语义一一对应——落 stdout 的信封
// (success/pending/partial_failure 中 ok:true 的前两者)对应 exit 0,
// 落 stderr 的 failure(含 nil 降级)对应非零退出码。Emitter 只负责写出,
// 不自行 os.Exit;进程退出码由 runner 出口按同一映射统一决定(§4 规则)。
//
// 失败信封恒以完整 JSON 信封渲染并绕过 format/fields/jq:失败信封没有数据
// 载荷(I3),§5.1 要求 stderr 上的失败信封恒为结构化信封;「非 json 只渲染
// data」的分发规则只适用于携带数据的信封。若 --jq 作用于失败信封会把错误
// 通道过滤为空,Agent 将丢失错误明细,故同样绕过。
func (em *Emitter) Emit(env *Envelope) error {
if env == nil || env.Outcome == OutcomeFailure {
return em.writeOnce(em.stderr, env, FormatJSON, "", "")
}
return em.writeOnce(em.stdout, env, em.format, em.fields, em.jq)
}
// writeOnce 先把信封完整渲染进内存 buffer,再一次性写出到 target(B25,
// 契约规范 §5.2 末条「渲染先进 buffer 再一次性写出」):渲染失败不向 target
// 泄漏任何字节;写出失败原子中止——writer 报错即原样返回,短写(接受了部分
// 字节却未写全)报 io.ErrShortWrite,绝不把截断输出当成功。渲染统一走
// renderEnvelope 分发点(B37),csv/ndjson 的分页诊断行(B40)以 em.stderr
// 为诊断出口。
func (em *Emitter) writeOnce(target io.Writer, env *Envelope, format Format, fields, jq string) error {
if env == nil {
env = nilFallbackEnvelope()
}
var buf bytes.Buffer
if err := renderEnvelope(&buf, em.stderr, env, format, fields, jq); err != nil {
return err
}
p := buf.Bytes()
n, err := target.Write(p)
if err != nil {
return err
}
if n < len(p) {
return io.ErrShortWrite
}
return nil
}
// ParseFormat 把字符串形式的 format 值(来自 --format flag 或 caller 接口)
// 归一化为 Format 常量;大小写不敏感,未知/空值返回 fallback。它是不持有
// *cobra.Command 的调用方(如主漏斗 callMCPToolInternalOpts)的 ResolveFormat
// 等价物,二者共用同一归一化规则。
func ParseFormat(raw string, fallback Format) Format {
return normalizeFormat(raw, fallback)
}
// ---------------------------------------------------------------------------
// 退出码映射(B32/B33/B34):outcome / error 类别 → exit code
// (契约规范 §4;不变量 I2 —— exit code == 0 ⇔ ok == true)
// ---------------------------------------------------------------------------
// 退出码表取值对齐:契约规范 §4 × 规划 v1.2 决策(OQ-1 已关闭——保留现行
// 码表 api=1 / auth=2 / validation=3 / PAT=4 / internal=5 / discovery=6,
// 仅新增 partial_failure 专用码 7;草案表中 validation→2 / confirmation→3
// 的拆分属 wire 破坏性重排,放弃)。confirmation_required 是 failure 的子类,
// 与 validation 共享 rc=3、以 subtype 区分(AC-13;runtime 同源于
// internal/errors 的 ExitCode 表)。
const (
exitCodeOK = 0
exitCodeAPI = 1
exitCodeAuth = 2
exitCodeValidation = 3 // confirmation_required 子类共享此码(OQ-1 定案)
exitCodePermission = 4 // PAT / permission failures
exitCodeInternal = 5
exitCodeDiscovery = 6
exitCodePartial = 7 // partial_failure 专用(契约 §4;规划 WS2 第4项;B142 将在 errors 侧补同源常量)
)
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4),
// 与 runtime apperrors.WithReason("confirmation_required") 形态同源。
const subtypeConfirmationRequired = "confirmation_required"
// ExitCodeForEnvelope 计算信封对应的进程退出码(B33,AC-10/I2;契约规范 §4)。
// 纯函数:只读信封字段、不做任何 I/O。映射规则:
//
// - success / pending → 0(I2:exit 0 ⇔ ok;异步受理不是失败,§1/§4);
// - partial_failure → 7(部分成功专用码,区别于普通失败,§4);
// - failure → 按 Error.Type 类别映射,confirmation_required 子类恒 3;
// - nil 信封 → 5(与 WriteEnvelopeTo 的 internal 类兜底信封同源);
// - 非法 outcome → 5(非法信封属框架缺陷,归 internal)。
//
// 判定只看 outcome / error 类别,不看可能被命令层篡改的 OK 字段(与 IsOK
// 同口径,I1)。退出码由 runner 出口统一决定、命令层禁止自行 os.Exit
// (§4 规则);本函数为 Emitter 语义标注、runner 映射与测试提供同一依据。
func ExitCodeForEnvelope(env *Envelope) int {
if env == nil {
return exitCodeInternal
}
switch env.Outcome {
case OutcomeSuccess, OutcomePending:
return exitCodeOK
case OutcomePartialFailure:
return exitCodePartial
case OutcomeFailure:
return exitCodeForErrorInfo(env.Error)
default:
return exitCodeInternal
}
}
// exitCodeForErrorInfo 把失败信封的 error 类别映射为退出码(契约规范 §4)。
// subtype=confirmation_required 优先于 type:门禁拦截无论挂在哪个 type 下
// 都恒为 3(AC-13)。error 缺席(违反 I3 的形态)按 internal 处理——
// 没有明细可分支的失败不得假装可恢复。未知 type 归 internal(不应发生)。
func exitCodeForErrorInfo(info *ErrorInfo) int {
if info == nil {
return exitCodeInternal
}
if info.Subtype == subtypeConfirmationRequired {
return exitCodeValidation
}
switch info.Type {
case "api":
return exitCodeAPI
case "auth":
return exitCodeAuth
case "validation":
return exitCodeValidation
case "permission":
return exitCodePermission
case "discovery":
return exitCodeDiscovery
default:
return exitCodeInternal
}
}
+736
View File
@@ -0,0 +1,736 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"bytes"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
var errEmitterInjected = errors.New("emitter injected write failure")
// emitterFailingWriter 记录被接受的字节并在写出时失败,
// 用于 B25「写一半失败不产生残缺 JSON」断言。
type emitterFailingWriter struct {
acceptN int // >0 时先接受 acceptN 字节再报错(模拟写到一半失败)
accepted []byte
}
func (w *emitterFailingWriter) Write(p []byte) (int, error) {
if w.acceptN > 0 && len(w.accepted) < w.acceptN {
k := w.acceptN - len(w.accepted)
if k > len(p) {
k = len(p)
}
w.accepted = append(w.accepted, p[:k]...)
return k, errEmitterInjected
}
return 0, errEmitterInjected
}
// emitterCountingWriter 统计 Write 调用次数并留存全部字节。
type emitterCountingWriter struct {
writes int
buf bytes.Buffer
}
func (w *emitterCountingWriter) Write(p []byte) (int, error) {
w.writes++
return w.buf.Write(p)
}
// emitterShortWriter 接受一半字节但谎报成功(n < len(p), err == nil),
// Emitter 必须检出短写而不是把截断输出当成功。
type emitterShortWriter struct {
n int
}
func (w *emitterShortWriter) Write(p []byte) (int, error) {
w.n = len(p) / 2
return w.n, nil
}
func newPartialEnvelopeFixture() *Envelope {
return NewPartialEnvelope(map[string]any{
"total": 3,
"succeeded": []any{map[string]any{"id": "a", "messageId": "m_1"}},
"failed": []any{map[string]any{"id": "b", "error": map[string]any{"type": "api", "code": 40001}}},
"unknown": []any{map[string]any{"id": "c", "reason": "timeout after submit"}},
})
}
// --- B24:Emitter 类型与构造 ---
func TestNewEmitterHandlesNilWriters(t *testing.T) {
em := NewEmitter(nil, nil, FormatJSON, "", "")
if em == nil {
t.Fatal("NewEmitter returned nil")
}
// nil writer 等价 io.Discard:不 panic,写出成功且无副作用。
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
t.Fatalf("Emit with nil writers: %v", err)
}
if err := em.Emit(NewFailureEnvelope(&ErrorInfo{Type: "api"})); err != nil {
t.Fatalf("Emit failure with nil writers: %v", err)
}
}
func TestNewEmitterKeepsConfig(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatTable, "id,name", ".data")
if em.format != FormatTable || em.fields != "id,name" || em.jq != ".data" {
t.Fatalf("NewEmitter dropped config: format=%q fields=%q jq=%q", em.format, em.fields, em.jq)
}
}
// --- B26:成功信封 stdout 出口(恰为一个完整 JSON 文档)---
func TestEmitterSuccessEnvelopeGoesToStdoutOnly(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewSuccessEnvelope(map[string]any{"todoId": "t_123"})
env.Meta = &Meta{Count: NewCount(1)}
if err := em.Emit(env); err != nil {
t.Fatalf("Emit success: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("success envelope must not touch stderr, got %d bytes: %s", stderr.Len(), stderr.String())
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not exactly one complete JSON document: %v\n%s", err, stdout.String())
}
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
if err := dec.Decode(&map[string]any{}); err != nil {
t.Fatalf("first JSON document invalid: %v", err)
}
if dec.More() {
t.Fatalf("stdout must contain exactly one JSON document, got trailing content: %s", stdout.String())
}
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
}
if decoded["outcome"] != "success" {
t.Fatalf("outcome = %v, want success", decoded["outcome"])
}
}
func TestEmitterPendingEnvelopeGoesToStdoutOnly(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewPendingEnvelope(&OperationInfo{ID: "task_abc", State: "processing"})
if err := em.Emit(env); err != nil {
t.Fatalf("Emit pending: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("pending envelope must not touch stderr, got %d bytes", stderr.Len())
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
}
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
t.Fatalf("pending envelope ok = %v, want true", decoded["ok"])
}
if decoded["outcome"] != "pending" {
t.Fatalf("outcome = %v, want pending", decoded["outcome"])
}
}
// --- B27:失败信封 stderr 出口(stdout 严格零字节)---
func TestEmitterFailureEnvelopeGoesToStderrWithZeroStdout(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018})
if err := em.Emit(env); err != nil {
t.Fatalf("Emit failure: %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure envelope must leave stdout empty, got %d bytes: %s", stdout.Len(), stdout.String())
}
var decoded map[string]any
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
t.Fatalf("stderr failure envelope is not valid JSON: %v\n%s", err, stderr.String())
}
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
t.Fatalf("failure envelope ok = %v, want false (bool)", decoded["ok"])
}
if decoded["outcome"] != "failure" {
t.Fatalf("outcome = %v, want failure", decoded["outcome"])
}
if _, hasError := decoded["error"]; !hasError {
t.Fatalf("failure envelope must carry error detail: %s", stderr.String())
}
}
func TestEmitterNilEnvelopeDegradesToFailureOnStderr(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(nil); err != nil {
t.Fatalf("Emit(nil): %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("nil envelope must leave stdout empty, got %d bytes", stdout.Len())
}
var decoded map[string]any
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
t.Fatalf("stderr is not valid JSON: %v\n%s", err, stderr.String())
}
if decoded["outcome"] != "failure" {
t.Fatalf("nil envelope must degrade to failure: %s", stderr.String())
}
// B208(轮8顺带核查①:兜底修正后同步强化):降级信封必须是 I3
// 合法形态——ok:false 恒真、error 明细存在且为 internal 类,
// 兜底信封自身通过 Validate()。
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
t.Fatalf("nil fallback ok = %v, want false (bool): %s", decoded["ok"], stderr.String())
}
errObj, hasErr := decoded["error"].(map[string]any)
if !hasErr {
t.Fatalf("nil fallback must carry error detail (I3, B208): %s", stderr.String())
}
if errObj["type"] != "internal" {
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], stderr.String())
}
if err := nilFallbackEnvelope().Validate(); err != nil {
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
}
}
func TestEmitterFailureEnvelopeBypassesFormatAndJQ(t *testing.T) {
// 失败信封恒以完整 JSON 信封落 stderr,不受 -f table/--jq 影响:
// 失败信封无 data 载荷(I3),按 format 分发只会渲染空值,
// Agent 必须总能读到结构化错误明细(§5.1/§2.4)。
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatTable, "id", ".data")
env := NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad flag"})
if err := em.Emit(env); err != nil {
t.Fatalf("Emit failure with table/jq config: %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout must stay empty for failure, got %d bytes", stdout.Len())
}
var decoded map[string]any
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
t.Fatalf("stderr failure envelope must stay full JSON regardless of format: %v\n%s", err, stderr.String())
}
if decoded["outcome"] != "failure" || decoded["error"] == nil {
t.Fatalf("stderr lost failure envelope shape under -f table: %s", stderr.String())
}
}
// --- B28:partial_failure 明细落 stdout、stderr 零输出 ---
func TestEmitterPartialFailureStdoutOnly(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(newPartialEnvelopeFixture()); err != nil {
t.Fatalf("Emit partial_failure: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
}
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
t.Fatalf("partial_failure ok = %v, want false (bool)", decoded["ok"])
}
if decoded["outcome"] != "partial_failure" {
t.Fatalf("outcome = %v, want partial_failure", decoded["outcome"])
}
data, isMap := decoded["data"].(map[string]any)
if !isMap {
t.Fatalf("partial data must be the three-channel detail object: %s", stdout.String())
}
for _, channel := range []string{"total", "succeeded", "failed", "unknown"} {
if _, ok := data[channel]; !ok {
t.Fatalf("partial data missing %q channel: %s", channel, stdout.String())
}
}
}
// --- B25:渲染先进 buffer 再一次性写出 ---
func TestEmitterRenderErrorLeavesZeroBytes(t *testing.T) {
// 渲染失败(marshal 报错)必须不向目标流泄漏任何字节。
testseam.Swap(t, &marshalJSONIndent, func(any, string, string) ([]byte, error) {
return nil, errEmitterInjected
})
target := &emitterFailingWriter{acceptN: 64}
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
if err == nil {
t.Fatal("Emit must surface render failure")
}
if len(target.accepted) != 0 {
t.Fatalf("render failure leaked %d bytes to the stream: %s", len(target.accepted), target.accepted)
}
}
func TestEmitterWriteFailureLeavesZeroBytes(t *testing.T) {
// 写出失败:buffer 先行意味着目标流要么收到完整文档、要么零字节。
target := &emitterFailingWriter{} // 第一次 Write 即整体拒绝
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err == nil {
t.Fatal("Emit must surface write failure")
}
if len(target.accepted) != 0 {
t.Fatalf("write failure leaked %d bytes", len(target.accepted))
}
}
func TestEmitterWriteOnceEmitsSingleWriteOfFullDocument(t *testing.T) {
// 「一次性写出」的直接断言:目标流只收到一次 Write,
// 且字节与独立重渲染结果逐字节一致(无分块流式拼接)。
target := &emitterCountingWriter{}
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
env := NewSuccessEnvelope(map[string]any{"id": "x"})
if err := em.Emit(env); err != nil {
t.Fatalf("Emit: %v", err)
}
if target.writes != 1 {
t.Fatalf("expected exactly one Write call (buffer-first single flush), got %d", target.writes)
}
var want bytes.Buffer
if err := WriteEnvelopeTo(&want, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
if !bytes.Equal(target.buf.Bytes(), want.Bytes()) {
t.Fatalf("streamed bytes differ from re-render:\n got: %s\nwant: %s", target.buf.String(), want.String())
}
}
func TestEmitterDetectsShortWrite(t *testing.T) {
// writer 谎报成功但只接受一半字节(短写):Emitter 必须报
// io.ErrShortWrite,绝不把截断(残缺)JSON 当成功。
target := &emitterShortWriter{}
em := NewEmitter(target, io.Discard, FormatJSON, "", "")
err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"}))
if !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("Emit on short write = %v, want io.ErrShortWrite", err)
}
}
// --- B29:meta.count 渲染位置(meta 层而非 data 层)---
func TestEmitterMetaCountRenderedInMetaLayer(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a"},
map[string]any{"id": "b"},
map[string]any{"id": "c"},
})
env.Meta = &Meta{Count: NewCount(3)}
if err := em.Emit(env); err != nil {
t.Fatalf("Emit: %v", err)
}
raw := stdout.String()
// count 只允许出现在 meta 层:wire 上 "count" 键恰出现一次。
if got := strings.Count(raw, `"count"`); got != 1 {
t.Fatalf(`"count" appears %d times on the wire, want exactly 1 (meta layer only): %s`, got, raw)
}
var decoded struct {
Data []map[string]any `json:"data"`
Meta struct {
Count *int `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, raw)
}
if decoded.Meta.Count == nil || *decoded.Meta.Count != 3 {
t.Fatalf("meta.count = %v, want 3: %s", decoded.Meta.Count, raw)
}
for i, row := range decoded.Data {
if _, hasCount := row["count"]; hasCount {
t.Fatalf("data[%d] must not carry count (belongs to meta layer): %s", i, raw)
}
}
}
// --- B30:dry_run 顶层字段注入断言(契约规范 §6 场景6)---
func TestEmitterDryRunFieldInjectedAndOmitEmpty(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewSuccessEnvelope(map[string]any{"plan": "create app DemoApp"})
env.DryRun = true
if err := em.Emit(env); err != nil {
t.Fatalf("Emit dry-run envelope: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("dry-run success envelope must not touch stderr, got %d bytes", stderr.Len())
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
}
dryRun, isBool := decoded["dry_run"].(bool)
if !isBool {
t.Fatalf("dry_run must be a JSON boolean, got %v (%T): %s", decoded["dry_run"], decoded["dry_run"], stdout.String())
}
if !dryRun {
t.Fatalf("dry_run = %v, want true (§6 场景6): %s", dryRun, stdout.String())
}
if okVal, _ := decoded["ok"].(bool); !okVal {
t.Fatalf("dry-run preview keeps ok=true (exit 0 semantics): %s", stdout.String())
}
}
func TestEmitterDryRunFalseStaysOmitted(t *testing.T) {
// dry_run 为 false(非 dry-run)时必须整体缺席(omitempty,不输出 false)。
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
t.Fatalf("Emit: %v", err)
}
raw := stdout.String()
if strings.Contains(raw, `"dry_run"`) {
t.Fatalf("dry_run=false must be omitted from wire (§2.5 omitempty): %s", raw)
}
}
// --- B31:identity 字段(user/app)omitempty 注入断言(契约规范 §2.5)---
func TestEmitterIdentityInjectedAndOmitEmpty(t *testing.T) {
for _, identity := range []string{"user", "app"} {
t.Run(identity, func(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewSuccessEnvelope(map[string]any{"id": "x"})
env.Identity = identity
if err := em.Emit(env); err != nil {
t.Fatalf("Emit: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
}
if decoded["identity"] != identity {
t.Fatalf("identity = %v, want %q: %s", decoded["identity"], identity, stdout.String())
}
})
}
}
func TestEmitterIdentityEmptyStaysOmitted(t *testing.T) {
// identity 未注入(空串)时字段缺席,wire 中不出现 "identity"。
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(NewSuccessEnvelope(map[string]any{"id": "x"})); err != nil {
t.Fatalf("Emit: %v", err)
}
if raw := stdout.String(); strings.Contains(raw, `"identity"`) {
t.Fatalf("empty identity must be omitted (§2.5 omitempty): %s", raw)
}
}
// --- B32:pending 信封出口完整化(ok:true + operation + exit 0 语义标注)---
func TestEmitterPendingOutletFullFormWithOperationAndExitZero(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
env := NewPendingEnvelope(&OperationInfo{
ID: "task_abc",
State: "processing",
TimedOut: true, // §2.2:轮询超时保持真实 state + timed_out:true,禁止伪装 success
NextCommand: "dws wiki task-result --task task_abc",
})
env.Data = map[string]any{"spaceId": "s_1", "nodeId": "n_9"}
if err := em.Emit(env); err != nil {
t.Fatalf("Emit pending: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("pending envelope keeps stderr empty (§2.2 走 stdout), got %d bytes", stderr.Len())
}
var decoded struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Data any `json:"data"`
Meta struct {
Operation *OperationInfo `json:"operation"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
}
if !decoded.OK || decoded.Outcome != "pending" {
t.Fatalf("pending envelope = ok:%v outcome:%q, want ok:true outcome:pending: %s",
decoded.OK, decoded.Outcome, stdout.String())
}
op := decoded.Meta.Operation
if op == nil {
t.Fatalf("pending envelope must carry meta.operation (§2.2): %s", stdout.String())
}
if op.State != "processing" || !op.TimedOut {
t.Fatalf("operation must keep real state + timed_out:true (§2.2 anti-spoof): got state=%q timed_out=%v", op.State, op.TimedOut)
}
if op.NextCommand != "dws wiki task-result --task task_abc" {
t.Fatalf("next_command = %q, want executable recovery command (§2.2): %s", op.NextCommand, stdout.String())
}
if decoded.Data == nil {
t.Fatalf("pending envelope data payload lost: %s", stdout.String())
}
// exit 0 语义标注(B32 与 B33 的耦合点):异步受理 = ok:true → exit 0。
if code := ExitCodeForEnvelope(env); code != 0 {
t.Fatalf("pending envelope exit code = %d, want 0 (§1/§4/I2)", code)
}
// 非 json format 下 pending 同样走 stdout(数据通道),stderr 保持零字节。
var outTable, errTable bytes.Buffer
if err := NewEmitter(&outTable, &errTable, FormatTable, "", "").Emit(env); err != nil {
t.Fatalf("Emit pending under -f table: %v", err)
}
if errTable.Len() != 0 {
t.Fatalf("pending under -f table must keep stderr empty, got %d bytes", errTable.Len())
}
if err := env.Validate(); err != nil {
t.Fatalf("pending envelope must pass Validate(): %v", err)
}
}
// --- B33:ExitCodeForEnvelope 的 outcome→exit code 映射 ---
func TestExitCodeForEnvelopeOutcomeCategoryMapping(t *testing.T) {
cases := []struct {
name string
env *Envelope
want int
}{
{"success → 0", NewSuccessEnvelope(map[string]any{"id": "x"}), 0},
{"pending → 0", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), 0},
{"partial_failure → 7", newPartialEnvelopeFixture(), 7},
{"failure api → 1", NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018}), 1},
{"failure auth → 2", NewFailureEnvelope(&ErrorInfo{Type: "auth"}), 2},
{"failure validation → 3", NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"}), 3},
{"failure discovery → 6", NewFailureEnvelope(&ErrorInfo{Type: "discovery"}), 6},
{"failure internal → 5", NewFailureEnvelope(&ErrorInfo{Type: "internal"}), 5},
{"failure unknown type → 5 (internal fallback)", NewFailureEnvelope(&ErrorInfo{Type: "mystery"}), 5},
// confirmation 子类 → 3(AC-13;subtype 优先于 type,挂在任何 type 下恒 3)
{"failure confirmation subtype on api → 3",
NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "confirmation_required",
Actions: []string{"dws dev app delete --app-id a1 --yes"}}), 3},
{"failure confirmation subtype on validation → 3",
NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"}), 3},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ExitCodeForEnvelope(c.env); got != c.want {
t.Fatalf("ExitCodeForEnvelope = %d, want %d", got, c.want)
}
})
}
}
func TestExitCodeForEnvelopeNilEnvelopeIsInternal(t *testing.T) {
// nil 信封与 WriteEnvelopeTo 兜底的 internal 类 failure 信封同源(B208)。
if got := ExitCodeForEnvelope(nil); got != 5 {
t.Fatalf("ExitCodeForEnvelope(nil) = %d, want 5 (internal-class fallback)", got)
}
}
func TestExitCodeForEnvelopeIgnoresTamperedOKAndInvalidOutcome(t *testing.T) {
// 只看 outcome(I1 同口径):被篡改 OK 字段的失败信封仍非零。
tampered := &Envelope{OK: true, Outcome: OutcomeFailure, Error: &ErrorInfo{Type: "api"}}
if got := ExitCodeForEnvelope(tampered); got == 0 {
t.Fatal("ExitCodeForEnvelope must ignore tampered OK field (outcome=failure is never exit 0)")
}
// 非法 outcome 属框架缺陷 → internal(5),不得落入 0。
invalid := &Envelope{OK: true, Outcome: "succeeded"}
if got := ExitCodeForEnvelope(invalid); got != 5 {
t.Fatalf("ExitCodeForEnvelope(invalid outcome) = %d, want 5", got)
}
}
// --- B34:I2 穷举断言——exit 0 ⇔ ok(对 ExitCodeForEnvelope 全组合)---
func TestInvariantI2ExhaustiveExitZeroIffOK(t *testing.T) {
// I2:exit code == 0 ⇔ ok == true。穷举全部规范 outcome(合法装配形态,
// 满足 I3)+ nil 信封 + 非法 outcome + 全部 failure 类别细分。
legal := []struct {
outcome Outcome
env *Envelope
}{
{OutcomeSuccess, NewSuccessEnvelope(map[string]any{"id": "x"})},
{OutcomePending, NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"})},
{OutcomePartialFailure, newPartialEnvelopeFixture()},
{OutcomeFailure, NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit"})},
}
if len(legal) != 4 {
t.Fatalf("I2 exhaustive table must cover exactly the four canonical outcomes, got %d", len(legal))
}
for _, c := range legal {
wantOK := c.outcome == OutcomeSuccess || c.outcome == OutcomePending // I1
code := ExitCodeForEnvelope(c.env)
if (code == 0) != wantOK {
t.Fatalf("outcome=%q: exit=%d but ok=%v (I2: exit 0 iff ok)", c.outcome, code, wantOK)
}
if (c.env.IsOK() != wantOK) || ((code == 0) != c.env.IsOK()) {
t.Fatalf("outcome=%q: IsOK()=%v exit=%d disagree (I1/I2 cross-check)", c.outcome, c.env.IsOK(), code)
}
}
// failure 全类别细分(api/auth/validation/discovery/internal/unknown/无明细)
// 无一 exit 0:ok=false 分支的反向穷举。
failureVariants := []*Envelope{
NewFailureEnvelope(&ErrorInfo{Type: "api"}),
NewFailureEnvelope(&ErrorInfo{Type: "auth"}),
NewFailureEnvelope(&ErrorInfo{Type: "validation"}),
NewFailureEnvelope(&ErrorInfo{Type: "discovery"}),
NewFailureEnvelope(&ErrorInfo{Type: "internal"}),
NewFailureEnvelope(&ErrorInfo{Type: "mystery"}),
NewFailureEnvelope(&ErrorInfo{Subtype: "confirmation_required"}), // type 缺席 + confirmation 子类:仍 3,非 0
}
for i, env := range failureVariants {
if got := ExitCodeForEnvelope(env); got == 0 {
t.Fatalf("failure variant[%d] must never be exit 0 (I2)", i)
}
}
// 边界组合:nil 信封与非法 outcome 同样非零(ok=false 语义)。
if ExitCodeForEnvelope(nil) == 0 {
t.Fatal("nil envelope must never be exit 0 (I2)")
}
if ExitCodeForEnvelope(&Envelope{OK: true, Outcome: "succeeded"}) == 0 {
t.Fatal("invalid outcome must never be exit 0 (I2)")
}
// partial_failure 专用码精确断言(非零且恰为 7,§4)。
if got := ExitCodeForEnvelope(newPartialEnvelopeFixture()); got != 7 {
t.Fatalf("partial_failure exit code = %d, want dedicated 7 (§4)", got)
}
}
// --- B32/B33 联动:Emitter 流路由与 ExitCodeForEnvelope 一致 ---
func TestEmitterStreamRoutingConsistentWithExitCode(t *testing.T) {
// Emit 的流选择与退出码语义一一对应(exit 0 语义标注的行为端):
// - ok:true(success/pending)→ stdout + exit 0(I2);
// - partial_failure(ok:false)→ stdout(§2.3 stderr 零输出)+ exit 7;
// - failure(含 nil 降级)→ stderr + exit 非零,stdout 严格零字节。
cases := []struct {
name string
env *Envelope
wantStdout bool // 信封数据通道是否落 stdout
wantExitZero bool // I2:exit 0 ⇔ ok
}{
{"success", NewSuccessEnvelope(map[string]any{"id": "x"}), true, true},
{"pending", NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}), true, true},
{"partial_failure", newPartialEnvelopeFixture(), true, false},
{"failure", NewFailureEnvelope(&ErrorInfo{Type: "api"}), false, false},
{"nil→fallback failure", nil, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(c.env); err != nil {
t.Fatalf("Emit: %v", err)
}
code := ExitCodeForEnvelope(c.env)
if (code == 0) != c.wantExitZero {
t.Fatalf("exit code = %d, wantExitZero=%v (I2)", code, c.wantExitZero)
}
if c.wantStdout {
if stdout.Len() == 0 {
t.Fatal("stdout-routed envelope must carry payload on stdout")
}
if stderr.Len() != 0 {
t.Fatalf("stdout-routed envelope must keep stderr empty (§2.3/§5.1), got %d bytes", stderr.Len())
}
} else {
if stdout.Len() != 0 {
t.Fatalf("failure must leave stdout empty, got %d bytes", stdout.Len())
}
if stderr.Len() == 0 {
t.Fatal("failure envelope must be emitted on stderr")
}
}
// partial_failure 专用码精确断言(§4):stdout 通道但非零。
if c.env != nil && c.env.Outcome == OutcomePartialFailure && code != 7 {
t.Fatalf("partial_failure exit code = %d, want dedicated 7", code)
}
})
}
}
// --- B208:nil 兜底信封 I3 合法化(轮6裁决⑨)---
func TestNilFallbackEnvelopeCarriesInternalErrorInfo(t *testing.T) {
// 兜底信封必须携带 internal 类 ErrorInfo(框架内部缺陷),
// 自身通过 Validate()——不得再是被校验器拒绝的形态。
env := nilFallbackEnvelope()
if env.Error == nil {
t.Fatal("nil fallback envelope must carry ErrorInfo (I3: error 非空 ⇔ failure)")
}
if env.Error.Type != "internal" {
t.Fatalf("fallback error.type = %q, want internal", env.Error.Type)
}
if env.Error.Message != "nil envelope: framework fallback" {
t.Fatalf("fallback error.message = %q, want %q", env.Error.Message, "nil envelope: framework fallback")
}
if env.OK || env.Outcome != OutcomeFailure {
t.Fatalf("fallback envelope = ok:%v outcome:%q, want ok:false outcome:failure", env.OK, env.Outcome)
}
if err := env.Validate(); err != nil {
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
}
}
func TestWriteEnvelopeToNilEnvelopeEmitsLegalFailure(t *testing.T) {
// WriteEnvelopeTo 的 nil 兜底:不 panic、必产合法 failure,
// error 明细在 wire 上可解码且类型/消息正确。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, nil, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(nil) must not fail: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Error *ErrorInfo `json:"error"`
}
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("fallback envelope is not valid JSON: %v\n%s", err, buf.String())
}
if decoded.OK || decoded.Outcome != "failure" {
t.Fatalf("fallback = ok:%v outcome:%q, want ok:false outcome:failure: %s", decoded.OK, decoded.Outcome, buf.String())
}
if decoded.Error == nil {
t.Fatalf("fallback envelope must carry error detail on wire: %s", buf.String())
}
if decoded.Error.Type != "internal" || decoded.Error.Message != "nil envelope: framework fallback" {
t.Fatalf("fallback error = %+v, want internal / nil envelope: framework fallback", decoded.Error)
}
// 兜底信封与 ExitCodeForEnvelope 同源:internal → 5(非零,I2)。
if code := ExitCodeForEnvelope(nil); code == 0 {
t.Fatal("nil fallback must map to non-zero exit code")
}
}
@@ -0,0 +1,274 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
// Phase C 收尾(B41/B42;契约规范 §5.2)。落盘策略:轮8裁决⑩新文件——
// 不编辑 emitter_phase_c_test.go 既有断言。
//
// 既有覆盖对照:raw≠json 逐字节差异与 raw=writeRaw(data) 透传已由
// TestRawPassThroughEnvelopeDiffersFromJSONByteForByte 覆盖;jq 优先于
// -f table 已由 TestJQOverridesFormatAndEvaluatesFullEnvelope 覆盖。
// 本文件补全:raw 适用域(仅 stdout 数据通道)+ raw×fields / raw×jq
// 穷举断言(B41),以及 jq × 全六格式穷举 + jq 对 fields 的优先级(B42)。
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// --- B41:-f raw 透传完整语义——适用域穷举 ---
func TestRawDispatchScopeIsStdoutDataChannelOnly(t *testing.T) {
// §5.2 分发矩阵的适用域仅 stdout 数据通道(轮8裁决⑪):携带数据载荷的
// outcome(success/pending/partial_failure)在 raw 下透传 data;failure
// 信封即使被配置为 raw 也恒以完整 JSON 走 stderr、stdout 严格零字节。
cases := []struct {
name string
env *Envelope
}{
{"success", phaseCListFixture()},
{"pending", newRawPendingFixture()},
{"partial_failure", newRawPartialFixture(t)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatRaw, "", "")
if err := em.Emit(c.env); err != nil {
t.Fatalf("Emit raw: %v", err)
}
if stdout.Len() == 0 {
t.Fatal("data-carrying envelope under raw must emit on stdout")
}
if stderr.Len() != 0 {
t.Fatalf("data channel must keep stderr empty, got %d bytes: %s", stderr.Len(), stderr.String())
}
// raw 输出不含信封外壳键(透传的是 data 载荷本身)。
if strings.Contains(stdout.String(), `"outcome"`) {
t.Fatalf("raw must pass through data only, no envelope wrapper: %s", stdout.String())
}
// 透传等价于 writeRaw(data)(与分发点单一渲染一致)。
var direct bytes.Buffer
if err := Write(&direct, FormatRaw, c.env.Data); err != nil {
t.Fatalf("Write(raw, data): %v", err)
}
if !bytes.Equal(stdout.Bytes(), direct.Bytes()) {
t.Fatalf("raw stdout must be verbatim writeRaw(data):\n got: %s\nwant: %s", stdout.String(), direct.String())
}
})
}
// failure 信封:raw 配置被绕过(轮8裁决⑪)——stderr 完整 JSON、stdout 零。
t.Run("failure bypasses raw", func(t *testing.T) {
failure := NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 40001, Message: "boom"})
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatRaw, "id", ".data")
if err := em.Emit(failure); err != nil {
t.Fatalf("Emit raw failure: %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must leave stdout empty under any format, got %d bytes", stdout.Len())
}
var decoded map[string]any
if err := json.Unmarshal(stderr.Bytes(), &decoded); err != nil {
t.Fatalf("failure under raw must stay full JSON on stderr: %v\n%s", err, stderr.String())
}
if decoded["outcome"] != "failure" || decoded["error"] == nil {
t.Fatalf("failure envelope lost wire-stable fields under raw bypass: %s", stderr.String())
}
})
// 分发点层同判:WriteEnvelopeTo(无流路由签名)下 failure + raw 也恒为
// 完整 JSON 信封——raw 的适用域不含失败通道。
t.Run("dispatch point also bypasses raw for failure", func(t *testing.T) {
failure := NewFailureEnvelope(&ErrorInfo{Type: "validation", Param: "--name"})
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, failure, FormatRaw, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo failure raw: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("failure under raw at dispatch point must be full JSON: %v\n%s", err, buf.String())
}
if decoded["outcome"] != "failure" {
t.Fatalf("outcome = %v: %s", decoded["outcome"], buf.String())
}
})
}
func newRawPendingFixture() *Envelope {
// pending 形态携带 data 载荷:raw 适用域覆盖所有携带数据的 outcome。
env := NewPendingEnvelope(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
env.Data = map[string]any{"taskId": "t_1", "accepted": true}
return env
}
func newRawPartialFixture(t *testing.T) *Envelope {
t.Helper()
pd, err := NewPartialData(2,
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001}}},
nil,
)
if err != nil {
t.Fatalf("NewPartialData: %v", err)
}
return NewPartialEnvelope(pd)
}
// --- B41:raw × fields / raw × jq 穷举 ---
func TestRawTimesFieldsProjectsDataBeforePassThrough(t *testing.T) {
// --fields 先投影数据载荷再按 format 分发(成功通道联动):raw 下
// fields 作用于 data 载荷本身——透传的是投影后的载荷,信封外壳仍缺席。
env := phaseCListFixture()
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, env, FormatRaw, "id", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(raw, fields=id): %v", err)
}
var rows []map[string]any
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
t.Fatalf("raw×fields output must be decodable JSON of projected data: %v\n%s", err, buf.String())
}
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3: %s", len(rows), buf.String())
}
for i, row := range rows {
if len(row) != 1 {
t.Fatalf("row %d must be projected to id only: %v", i, row)
}
if _, has := row["id"]; !has {
t.Fatalf("row %d lost id after projection: %v", i, row)
}
}
// 信封外壳键不出现(raw 恒只透传 data 通道)。
if strings.Contains(buf.String(), `"outcome"`) || strings.Contains(buf.String(), `"meta"`) {
t.Fatalf("raw×fields must not emit envelope wrapper: %s", buf.String())
}
}
func TestRawTimesJQJQWinsAndEvaluatesFullEnvelope(t *testing.T) {
// --jq 优先于 format(含 raw):对**完整信封**求值后直接输出结果——
// raw×jq 下 jq 表达式可触及信封外壳键(.ok/.outcome),证明求值对象是
// 完整信封而非 raw 的 data 载荷。
for _, tc := range []struct {
jq string
want string
}{
{".ok", "true"},
{".outcome", `"success"`},
{".meta.pagination.next_token", `"cursor_xyz"`},
} {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatRaw, "", tc.jq); err != nil {
t.Fatalf("WriteEnvelopeTo(raw, jq=%q): %v", tc.jq, err)
}
if got := strings.TrimSpace(buf.String()); got != tc.want {
t.Fatalf("raw×jq %q = %q, want %q (jq must win over raw on the full envelope)", tc.jq, got, tc.want)
}
}
// raw×jq×fields 三者同设:jq 仍恒赢——输出是 jq 求值结果,fields 不得
// 先行改写求值输入(jq 优先级链在分发点入口,fields 投影只在无 jq 分支)。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatRaw, "id", ".outcome"); err != nil {
t.Fatalf("WriteEnvelopeTo(raw, fields+jq): %v", err)
}
if got := strings.TrimSpace(buf.String()); got != `"success"` {
t.Fatalf("raw×fields×jq = %q, want \"success\" (jq wins over both)", got)
}
}
func TestRawStringPayloadVerbatimPassThrough(t *testing.T) {
// raw 完整语义的字符串分支:data 为字符串时 raw 输出净化后的原文本
// (writeRaw 字符串分支),不是 JSON 引号包裹——这是「透传」的第二形态。
env := NewSuccessEnvelope("hello raw world")
var rawBuf, jsonBuf bytes.Buffer
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
}
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(json): %v", err)
}
if got := strings.TrimSpace(rawBuf.String()); got != "hello raw world" {
t.Fatalf("raw string payload = %q, want verbatim text", got)
}
if strings.Contains(rawBuf.String(), `"hello`) {
t.Fatalf("raw string branch must not JSON-quote the payload: %s", rawBuf.String())
}
// json 形态仍是完整信封(两形态互不污染)。
if !strings.Contains(jsonBuf.String(), `"outcome"`) {
t.Fatalf("json must keep the envelope wrapper: %s", jsonBuf.String())
}
}
// --- B42:--jq 优先于 format——全六格式穷举对完整信封求值 ---
func TestJQOverridesAllSixFormatsEvaluatingFullEnvelope(t *testing.T) {
// jq 优先级对 §5.2 矩阵全部六格式成立:无论 format 是什么,输出恒为
// jq 对完整信封的求值结果——matrix 各行(table/pretty/csv/ndjson/raw/
// json)全部被 jq 短路。
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
for _, format := range formats {
t.Run("format="+string(format), func(t *testing.T) {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), format, "", ".outcome"); err != nil {
t.Fatalf("WriteEnvelopeTo(%s, jq=.outcome): %v", format, err)
}
if got := strings.TrimSpace(buf.String()); got != `"success"` {
t.Fatalf("format %s × jq = %q, want \"success\" (jq must win over every format)", format, got)
}
})
}
// Emitter 路径同判(双向出口共用分发点):-f csv + jq 下 stdout 仍是
// jq 结果而非裸记录。
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatCSV, "", ".meta.count")
if err := em.Emit(phaseCListFixture()); err != nil {
t.Fatalf("Emit csv+jq: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "3" {
t.Fatalf("Emitter csv×jq = %q, want 3", got)
}
if stderr.Len() != 0 {
t.Fatalf("jq path must keep stderr empty, got %d bytes: %s", stderr.Len(), stderr.String())
}
}
func TestJQWinsOverFieldsAsWell(t *testing.T) {
// jq 与 fields 同设:jq 恒赢,fields 不得改写 jq 的求值输入。
// 求值对象是可触及 meta 的完整信封——若 fields 先行投影,.meta 将缺席。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatJSON, "id", ".meta.count"); err != nil {
t.Fatalf("WriteEnvelopeTo(fields+jq): %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "3" {
t.Fatalf("fields×jq = %q, want 3 (jq must win over fields on the full envelope)", got)
}
}
func TestJQInvalidExpressionFailsCleanly(t *testing.T) {
// jq 表达式非法时求值失败即报错(validation 类)——不静默降级回 format
// 输出,也不泄漏半截渲染(buffer-first)。
var buf bytes.Buffer
err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data[")
if err == nil {
t.Fatalf("invalid jq expression must fail, got output: %s", buf.String())
}
if !strings.Contains(err.Error(), "jq") {
t.Fatalf("error must name jq: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("invalid jq must leave zero bytes on the target (buffer-first): %s", buf.String())
}
}
+822
View File
@@ -0,0 +1,822 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// phaseCListFixture 是三格式(table/csv/ndjson)共用的列表载荷:
// 3 条同构对象记录,配 meta.count 与 meta.pagination 以便同时断言
// 「人读摘要」与「分页诊断行」两条分发行为。
func phaseCListFixture() *Envelope {
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a", "name": "alpha"},
map[string]any{"id": "b", "name": "beta"},
map[string]any{"id": "c", "name": "gamma"},
})
env.Meta = &Meta{
Count: NewCount(3),
Pagination: &Pagination{
EndpointExhausted: false,
Pages: 2,
Items: 60,
NextToken: "cursor_xyz",
},
}
return env
}
// --- B35:format 值归一化(trim/小写)——normalizeFormat 既有实现的补差断言 ---
func TestNormalizeFormatTrimAndCaseExhaustive(t *testing.T) {
// 六格式 × (原始小写 / 大写 / 混合大小写 + 前后空白)全部命中对应常量;
// 归一化规则 trim + 小写,与 ResolveFormat/ParseFormat 单一事实源。
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
for _, want := range formats {
base := string(want)
for _, raw := range []string{
base,
strings.ToUpper(base),
" " + strings.ToUpper(base[:1]) + base[1:] + "\t",
} {
if got := normalizeFormat(raw, FormatJSON); got != want {
t.Fatalf("normalizeFormat(%q) = %q, want %q", raw, got, want)
}
// ParseFormat 薄包装与 normalizeFormat 结果恒一致(单一事实源)。
if got := ParseFormat(raw, FormatJSON); got != want {
t.Fatalf("ParseFormat(%q) = %q, want %q", raw, got, want)
}
}
}
// 空串 = 未指定 → fallback;未知值 → fallback(B36 降级的归一化前提)。
if got := normalizeFormat("", FormatTable); got != FormatTable {
t.Fatalf("normalizeFormat empty = %q, want fallback table", got)
}
if got := normalizeFormat(" \t ", FormatCSV); got != FormatCSV {
t.Fatalf("normalizeFormat whitespace-only = %q, want fallback csv", got)
}
if got := normalizeFormat("yaml", FormatJSON); got != FormatJSON {
t.Fatalf("normalizeFormat unknown = %q, want fallback json", got)
}
}
// --- B36:未知 format 降级 + stderr warning(不崩不静默,AC-09)---
func TestResolveFormatWithWarningKnownValuesSilent(t *testing.T) {
formats := []Format{FormatJSON, FormatTable, FormatRaw, FormatPretty, FormatNDJSON, FormatCSV}
for _, want := range formats {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if err := cmd.Flags().Set("format", string(want)); err != nil {
t.Fatalf("set format: %v", err)
}
got, warning := resolveFormatWithWarning(cmd, FormatJSON)
if got != want {
t.Fatalf("resolveFormatWithWarning(%q) = %q, want %q", want, got, want)
}
if warning != "" {
t.Fatalf("known format %q must not warn, got %q", want, warning)
}
}
}
func TestResolveFormatWithWarningUnknownDegradesWithWarning(t *testing.T) {
for _, fallback := range []Format{FormatJSON, FormatTable} {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if err := cmd.Flags().Set("format", "yaml"); err != nil {
t.Fatalf("set format: %v", err)
}
got, warning := resolveFormatWithWarning(cmd, fallback)
if got != fallback {
t.Fatalf("unknown format must degrade to fallback %q, got %q", fallback, got)
}
if warning == "" {
t.Fatal("unknown format must produce a warning (AC-09: no silent swallow)")
}
if !strings.Contains(warning, "yaml") || !strings.Contains(warning, string(fallback)) {
t.Fatalf("warning must name the unknown value and the fallback target: %q", warning)
}
}
}
func TestResolveFormatWithWarningEmptyAndMissingSilent(t *testing.T) {
// 空值 = 未指定(走 fallback 是正常默认路径),flag 缺席同:均不报警。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if got, warning := resolveFormatWithWarning(cmd, FormatRaw); got != FormatRaw || warning != "" {
t.Fatalf("empty format = (%q, %q), want (raw, silent)", got, warning)
}
cmd2 := &cobra.Command{Use: "t2"}
if got, warning := resolveFormatWithWarning(cmd2, FormatRaw); got != FormatRaw || warning != "" {
t.Fatalf("missing flag = (%q, %q), want (raw, silent)", got, warning)
}
if got, warning := resolveFormatWithWarning(nil, FormatJSON); got != FormatJSON || warning != "" {
t.Fatalf("nil cmd = (%q, %q), want (json, silent)", got, warning)
}
}
func TestResolveFormatKeepsWarningFreeReturnContract(t *testing.T) {
// ResolveFormat(既有导出 API)行为不变:只返回 format,未知值静默降级。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
if err := cmd.Flags().Set("format", "bogus"); err != nil {
t.Fatalf("set format: %v", err)
}
if got := ResolveFormat(cmd, FormatJSON); got != FormatJSON {
t.Fatalf("ResolveFormat unknown = %q, want json fallback", got)
}
}
func TestWriteEnvelopeUnknownFormatDegradesToJSONWithStderrWarning(t *testing.T) {
// 端到端:未知 --format 不崩不静默——stdout 仍为完整 json 信封(降级
// 生效,数据不丢),stderr 恰有一条 warning 点名未知值与降级目标。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "yaml"); err != nil {
t.Fatalf("set format: %v", err)
}
env := NewSuccessEnvelope(map[string]any{"name": "DemoApp"})
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope with unknown format must not crash: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout must degrade to a full json envelope: %v\n%s", err, stdout.String())
}
if decoded["outcome"] != "success" {
t.Fatalf("degraded envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
}
data, _ := decoded["data"].(map[string]any)
if data == nil || data["name"] != "DemoApp" {
t.Fatalf("degraded envelope lost data payload: %s", stdout.String())
}
if got := strings.Count(stderr.String(), "[WARN]"); got != 1 {
t.Fatalf("stderr must carry exactly one warning line, got %d: %s", got, stderr.String())
}
if !strings.Contains(stderr.String(), "yaml") || !strings.Contains(stderr.String(), "json") {
t.Fatalf("warning must name unknown value and fallback: %s", stderr.String())
}
}
func TestWriteEnvelopeKnownFormatLeavesStderrSilent(t *testing.T) {
// 已知 format 不产生 warning(stderr 零字节),防 B36 误伤正常路径。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "table"); err != nil {
t.Fatalf("set format: %v", err)
}
if err := WriteEnvelope(cmd, NewSuccessEnvelope(map[string]any{"name": "x"}), FormatJSON); err != nil {
t.Fatalf("WriteEnvelope: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("known format must not warn, stderr = %d bytes: %s", stderr.Len(), stderr.String())
}
}
// --- B37:信封渲染接统一 format 分发点(WS1 改动点4)---
func TestRenderEnvelopeSingleDispatchPointEmitterAndWriterAgree(t *testing.T) {
// 分发点唯一性:Emitter 路径与 WriteEnvelopeTo 路径对同一信封 + 同一
// format 逐字节一致——两条出口共用 renderEnvelope,不存在第二套渲染。
formats := []Format{FormatJSON, FormatTable, FormatPretty, FormatCSV, FormatNDJSON, FormatRaw}
for _, format := range formats {
env := phaseCListFixture()
var direct bytes.Buffer
if err := WriteEnvelopeTo(&direct, env, format, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(%s): %v", format, err)
}
var stdout, stderr bytes.Buffer
if err := NewEmitter(&stdout, &stderr, format, "", "").Emit(env); err != nil {
t.Fatalf("Emit(%s): %v", format, err)
}
if !bytes.Equal(direct.Bytes(), stdout.Bytes()) {
t.Fatalf("format %s: Emitter and WriteEnvelopeTo disagree:\n writer: %s\nemitter: %s",
format, direct.String(), stdout.String())
}
}
}
func TestRenderEnvelopeBufferFirstZeroLeakOnRenderFailure(t *testing.T) {
// §5.2 末条对整个分发点成立:渲染失败(不可序列化载荷 chan)
// 不向目标流泄漏任何字节——渲染段在写出前整体失败。
unrenderable := NewSuccessEnvelope(map[string]any{"bad": make(chan int)})
for _, format := range []Format{FormatJSON, FormatTable, FormatCSV, FormatNDJSON} {
target := &emitterFailingWriter{acceptN: 64}
err := WriteEnvelopeTo(target, unrenderable, format, "", "")
if err == nil {
t.Fatalf("format %s must surface render failure", format)
}
if len(target.accepted) != 0 {
t.Fatalf("format %s leaked %d bytes on render failure: %s", format, len(target.accepted), target.accepted)
}
}
}
func TestRenderEnvelopeFailureEnvelopeBypassesFormatAtDispatchPoint(t *testing.T) {
// 轮8裁决⑪在分发点内落地:failure 信封即使被调用方以 table/jq 配置
// 送入 renderEnvelope(WriteEnvelopeTo 层无流路由),也恒输出完整 JSON
// 信封——format/jq/fields 不得抹掉 §2.4 wire-stable 字段组。
var buf bytes.Buffer
env := NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018, Retryable: true})
if err := WriteEnvelopeTo(&buf, env, FormatTable, "id", ".data"); err != nil {
t.Fatalf("WriteEnvelopeTo failure under table/jq: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("failure envelope must stay full JSON regardless of format: %v\n%s", err, buf.String())
}
if decoded["outcome"] != "failure" {
t.Fatalf("outcome = %v, want failure: %s", decoded["outcome"], buf.String())
}
errObj, ok := decoded["error"].(map[string]any)
if !ok {
t.Fatalf("failure envelope lost error detail: %s", buf.String())
}
for _, key := range []string{"type", "subtype", "code", "retryable"} {
if _, has := errObj[key]; !has {
t.Fatalf("wire-stable key %q lost under format/jq bypass: %s", key, buf.String())
}
}
}
// --- B38:-f json 输出完整信封(唯一 JSON 契约)---
func TestWriteEnvelopeJSONIsTheOnlyJSONContract(t *testing.T) {
// json 与空 format 等价:stdout 恰为一个完整信封 JSON 文档,
// ok/outcome/data/meta 外壳键齐备(唯一 JSON 契约,§5.2 首行)。
for _, format := range []Format{FormatJSON, ""} {
var buf bytes.Buffer
env := phaseCListFixture()
if err := WriteEnvelopeTo(&buf, env, format, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(%q): %v", format, err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("format %q stdout is not valid JSON: %v\n%s", format, err, buf.String())
}
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
}
if decoded["outcome"] != "success" {
t.Fatalf("outcome = %v, want success", decoded["outcome"])
}
for _, key := range []string{"data", "meta"} {
if _, has := decoded[key]; !has {
t.Fatalf("full envelope must carry %q: %s", key, buf.String())
}
}
}
}
func TestWriteEnvelopeJSONFieldsProjectionOnEnvelope(t *testing.T) {
// --fields 与 json 联动(成功通道):投影作用于信封顶层键。
var buf bytes.Buffer
env := NewSuccessEnvelope(map[string]any{"name": "DemoApp"})
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "ok,outcome", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if _, has := decoded["ok"]; !has {
t.Fatalf("fields=ok,outcome must keep ok: %s", buf.String())
}
if _, has := decoded["outcome"]; !has {
t.Fatalf("fields=ok,outcome must keep outcome: %s", buf.String())
}
if _, has := decoded["data"]; has {
t.Fatalf("fields=ok,outcome must drop data: %s", buf.String())
}
}
func TestJQOverridesFormatAndEvaluatesFullEnvelope(t *testing.T) {
// --jq 优先于 format(§5.2 矩阵行):-f table 下 jq 仍对**完整信封**
// 求值——.data 可取业务载荷、.ok/.outcome 可取框架字段。
for _, tc := range []struct {
jq string
want string
}{
{".ok", "true"},
{".outcome", `"success"`},
{".meta.pagination.next_token", `"cursor_xyz"`},
} {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", tc.jq); err != nil {
t.Fatalf("WriteEnvelopeTo jq=%q: %v", tc.jq, err)
}
if got := strings.TrimSpace(buf.String()); got != tc.want {
t.Fatalf("jq %q = %q, want %q (jq must win over -f table on the full envelope)", tc.jq, got, tc.want)
}
}
// .data 提取业务载荷:输出不再是信封(无 outcome 外壳键)。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ".data"); err != nil {
t.Fatalf("WriteEnvelopeTo jq=.data: %v", err)
}
if strings.Contains(buf.String(), `"outcome"`) {
t.Fatalf("jq=.data output must not carry the envelope wrapper: %s", buf.String())
}
var rows []map[string]any
if err := json.Unmarshal(buf.Bytes(), &rows); err != nil {
t.Fatalf("jq=.data must emit the bare data payload: %v\n%s", err, buf.String())
}
if len(rows) != 3 {
t.Fatalf("jq=.data rows = %d, want 3", len(rows))
}
}
// --- B39:-f table/pretty 输出业务数据 + 人读摘要(不输出信封外壳)---
func TestTableRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(table): %v", err)
}
out := buf.String()
// 数据行可见。
for _, want := range []string{"alpha", "beta", "gamma"} {
if !strings.Contains(out, want) {
t.Fatalf("table output missing data row %q:\n%s", want, out)
}
}
// 人读摘要:count 与分页摘要(wire 字段名 snake_case,两态显式——
// endpoint_exhausted:false 也输出,它是「可续跑」语义)。
if !strings.Contains(out, "count: 3") {
t.Fatalf("table output missing count summary:\n%s", out)
}
if !strings.Contains(out, "pagination: endpoint_exhausted: false, pages: 2, items: 60, next_token: cursor_xyz") {
t.Fatalf("table output missing pagination summary:\n%s", out)
}
// 不输出信封外壳键。
for _, forbidden := range []string{`"ok"`, `"outcome"`, `"endpoint_exhausted": `} {
if strings.Contains(out, forbidden) {
t.Fatalf("table output must not emit envelope wrapper (%q):\n%s", forbidden, out)
}
}
// stdout 不是 JSON 文档(人读视图)。
if strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Fatalf("table output should not be a JSON document:\n%s", out)
}
}
func TestTableWithoutMetaEmitsNoSummary(t *testing.T) {
// 无 meta 时不追加摘要行,不留空壳。
var buf bytes.Buffer
env := NewSuccessEnvelope([]any{map[string]any{"id": "a"}})
if err := WriteEnvelopeTo(&buf, env, FormatTable, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(table): %v", err)
}
out := buf.String()
if strings.Contains(out, "count:") || strings.Contains(out, "pagination:") {
t.Fatalf("no-meta table must not emit summary lines:\n%s", out)
}
if !strings.Contains(out, "a") {
t.Fatalf("table output missing data:\n%s", out)
}
}
func TestPrettyRendersDataWithMetaSummaryWithoutEnvelopeWrapper(t *testing.T) {
forceNoColor(t)
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatPretty, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(pretty): %v", err)
}
out := buf.String()
for _, want := range []string{"alpha", "count: 3", "endpoint_exhausted: false"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
for _, forbidden := range []string{`"ok"`, `"outcome"`} {
if strings.Contains(out, forbidden) {
t.Fatalf("pretty output must not emit envelope wrapper (%q):\n%s", forbidden, out)
}
}
}
func TestTableWithFieldsProjectionNarrowsColumns(t *testing.T) {
// --fields 与 table 联动(成功通道):投影先作用于数据载荷再渲染。
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), FormatTable, "id", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(table, fields=id): %v", err)
}
out := buf.String()
// id 列保留(值 a/b/c 可见),name 列与其值被投影掉。
for _, want := range []string{"id", "│ a", "│ b", "│ c"} {
if !strings.Contains(out, want) {
t.Fatalf("fields=id must keep id column/values (%q):\n%s", want, out)
}
}
if strings.Contains(out, "name") || strings.Contains(out, "alpha") {
t.Fatalf("fields=id must drop name column and its values:\n%s", out)
}
}
// --- B40:-f csv/ndjson 裸记录;分页元数据走 stderr 诊断行 ---
// newPhaseCCmd 构造带 --format flag 且 stdout/stderr 均可捕获的测试命令
// (诊断行断言必须走 cmd 出口路径:WriteEnvelopeTo 的 writer 级签名无
// stderr 出口,诊断行在该签名下按 io.Discard 处理)。
func newPhaseCCmd(t *testing.T, formatValue string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
cmd := &cobra.Command{Use: "t"}
cmd.Flags().StringP("format", "f", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", formatValue); err != nil {
t.Fatalf("set format: %v", err)
}
return cmd, &stdout, &stderr
}
func TestNDJSONEmitsBareRecordsWithoutEnvelope(t *testing.T) {
cmd, stdout, stderr := newPhaseCCmd(t, "ndjson")
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a"},
map[string]any{"id": "b"},
map[string]any{"id": "c"},
})
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope(ndjson): %v", err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 3 {
t.Fatalf("ndjson must emit one bare record per line, got %d:\n%s", len(lines), stdout.String())
}
for i, line := range lines {
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("ndjson line %d is not valid JSON: %v", i, err)
}
if _, has := rec["outcome"]; has {
t.Fatalf("ndjson line %d carries envelope wrapper: %s", i, line)
}
}
// 无 pagination 时 stderr 零诊断行。
if stderr.Len() != 0 {
t.Fatalf("no-pagination ndjson must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
}
}
func TestCSVEmitsBareRecordsWithoutEnvelope(t *testing.T) {
cmd, stdout, stderr := newPhaseCCmd(t, "csv")
env := NewSuccessEnvelope([]any{
map[string]any{"id": "a", "name": "alpha"},
map[string]any{"id": "b", "name": "beta"},
})
if err := WriteEnvelope(cmd, env, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope(csv): %v", err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 3 { // header + 2 records
t.Fatalf("csv must emit header + one row per record, got %d lines:\n%s", len(lines), stdout.String())
}
if !strings.Contains(lines[0], "id") || !strings.Contains(lines[0], "name") {
t.Fatalf("csv header missing columns: %s", lines[0])
}
if strings.Contains(stdout.String(), `"outcome"`) || strings.Contains(stdout.String(), `"ok"`) {
t.Fatalf("csv must not emit envelope wrapper keys:\n%s", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("no-pagination csv must keep diagnostics silent, got %d bytes: %s", stderr.Len(), stderr.String())
}
}
func TestCSVNDJSONPaginationMetadataGoesToStderrDiagnostic(t *testing.T) {
// 分页元数据不进裸记录流,走 stderr 诊断行(§5.2 csv/ndjson 行)。
for _, format := range []Format{FormatCSV, FormatNDJSON} {
cmd, stdout, stderr := newPhaseCCmd(t, string(format))
if err := WriteEnvelope(cmd, phaseCListFixture(), FormatJSON); err != nil {
t.Fatalf("WriteEnvelope(%s): %v", format, err)
}
diag := stderr.String()
if !strings.Contains(diag, "[pagination]") {
t.Fatalf("%s pagination diagnostic missing from stderr: %q", format, diag)
}
for _, want := range []string{"endpoint_exhausted: false", "next_token: cursor_xyz"} {
if !strings.Contains(diag, want) {
t.Fatalf("%s diagnostic missing %q: %q", format, want, diag)
}
}
if got := strings.Count(diag, "[pagination]"); got != 1 {
t.Fatalf("%s must emit exactly one pagination diagnostic line, got %d: %q", format, got, diag)
}
// stdout 保持纯裸记录:无诊断行、无信封键、无分页键。
out := stdout.String()
if strings.Contains(out, "[pagination]") || strings.Contains(out, "next_token") || strings.Contains(out, `"outcome"`) {
t.Fatalf("%s stdout polluted by diagnostics/envelope:\n%s", format, out)
}
}
}
func TestEmitterCSVNDJSONDiagnosticRoutedToEmitterStderr(t *testing.T) {
// Emitter 路径:诊断行落 Emitter 的 stderr writer(可注入),stdout
// 只承载裸记录;流路由纪律不变(success → stdout)。
for _, format := range []Format{FormatCSV, FormatNDJSON} {
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, format, "", "")
if err := em.Emit(phaseCListFixture()); err != nil {
t.Fatalf("Emit(%s): %v", format, err)
}
if !strings.Contains(stderr.String(), "[pagination]") {
t.Fatalf("%s: emitter stderr must carry pagination diagnostic: %q", format, stderr.String())
}
if strings.Contains(stdout.String(), "[pagination]") {
t.Fatalf("%s: stdout must stay bare records:\n%s", format, stdout.String())
}
}
}
func TestPartialFailureCSVKeepsStderrZero(t *testing.T) {
// §2.3 特例优先:partial_failure 的 stderr 零输出约束压过 csv/ndjson
// 的诊断行通则——即便携带分页元数据也不写诊断行。
env := newPartialEnvelopeFixture()
env.Meta = &Meta{
Count: NewCount(3),
Pagination: &Pagination{EndpointExhausted: true},
}
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatCSV, "", "")
if err := em.Emit(env); err != nil {
t.Fatalf("Emit partial under csv: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("partial_failure must keep stderr zero (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
}
if stdout.Len() == 0 {
t.Fatal("partial_failure details must stay on stdout (§2.3)")
}
}
// --- B43:--json 语义 = --format json 简写(显式 --format 优先)---
func TestResolveFormatWithJSONShorthandMatrix(t *testing.T) {
// 优先级链穷举(契约规范 §5.2:「--json 自动注册为 --format json 简写
// (显式 --format 优先)」):显式 --format 非空值恒优先;--format 缺席
// 或为空时 --json=true 等价 --format json;两者皆无 → fallback。
newCmd := func(formatSet string, formatValue string, jsonSet bool, jsonValue bool) *cobra.Command {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("format", "", "")
cmd.Flags().Bool("json", false, "")
if formatSet != "" {
if err := cmd.Flags().Set("format", formatValue); err != nil {
t.Fatalf("set format: %v", err)
}
}
if jsonSet {
if err := cmd.Flags().Set("json", jsonFlagValue(jsonValue)); err != nil {
t.Fatalf("set json: %v", err)
}
}
return cmd
}
cases := []struct {
name string
cmd *cobra.Command
fallback Format
want Format
}{
{"json shorthand alone", newCmd("", "", true, true), FormatTable, FormatJSON},
{"explicit format wins over json shorthand", newCmd("set", "table", true, true), FormatJSON, FormatTable},
{"explicit raw wins over json shorthand", newCmd("set", "raw", true, true), FormatJSON, FormatRaw},
{"empty format does not shadow shorthand", newCmd("set", "", true, true), FormatTable, FormatJSON},
{"json=false is not shorthand", newCmd("", "", true, false), FormatTable, FormatTable},
{"neither flag", newCmd("", "", false, false), FormatTable, FormatTable},
{"unknown format not rescued by json shorthand", newCmd("set", "yaml", true, true), FormatJSON, FormatJSON},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ResolveFormatWithJSONShorthand(c.cmd, c.fallback); got != c.want {
t.Fatalf("ResolveFormatWithJSONShorthand = %q, want %q (fallback=%q)", got, c.want, c.fallback)
}
})
}
if got := ResolveFormatWithJSONShorthand(nil, FormatRaw); got != FormatRaw {
t.Fatalf("nil cmd = %q, want fallback raw", got)
}
}
// jsonFlagValue 把 bool 渲染成 pflag.Set 可接受的字符串。
func jsonFlagValue(v bool) string {
if v {
return "true"
}
return "false"
}
func TestResolveFormatWithJSONShorthandIgnoresStringTypedJSONFlag(t *testing.T) {
// 业务层存在同名 string flag(如 table create --json 载荷参数):
// GetBool 失败即跳过,不得被误判为简写。
cmd := &cobra.Command{Use: "t"}
cmd.Flags().String("json", "", "base JSON payload")
if err := cmd.Flags().Set("json", `{"x":1}`); err != nil {
t.Fatalf("set json: %v", err)
}
if got := ResolveFormatWithJSONShorthand(cmd, FormatTable); got != FormatTable {
t.Fatalf("string-typed --json must not act as shorthand, got %q", got)
}
}
func TestResolveFormatWithJSONShorthandReadsInheritedAndPersistentFlags(t *testing.T) {
// --format 继承自 root persistent(与 ResolveFormat 同查找面);
// --json 挂在子命令本地亦生效。
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "", "")
child := &cobra.Command{Use: "list"}
child.Flags().Bool("json", false, "")
root.AddCommand(child)
if err := root.PersistentFlags().Set("format", "csv"); err != nil {
t.Fatalf("set format: %v", err)
}
if err := child.Flags().Set("json", "true"); err != nil {
t.Fatalf("set json: %v", err)
}
// 显式 format(继承)优先于 json 简写。
if got := ResolveFormatWithJSONShorthand(child, FormatJSON); got != FormatCSV {
t.Fatalf("inherited explicit format must win, got %q", got)
}
// 清空 format 后 json 简写生效。
if err := root.PersistentFlags().Set("format", ""); err != nil {
t.Fatalf("reset format: %v", err)
}
if got := ResolveFormatWithJSONShorthand(child, FormatTable); got != FormatJSON {
t.Fatalf("json shorthand = %q, want json", got)
}
}
// --- B209(轮10裁决⑭小批,随 Phase C 同轮收口):ExitCodeForEnvelope 边界
// 组合精确值断言 + 与 internal/errors.ExitCode 表同源锁定 ---
func TestExitCodeForEnvelopeBoundaryCombinationsExactValues(t *testing.T) {
// ① failure + Error 缺席(I3 非法的手工形态,构造器不产出)→ internal
// 兜底 exit==5,精确值固化(无明细可分支的失败不得假装可恢复)。
invalid := &Envelope{OK: false, Outcome: OutcomeFailure}
if err := invalid.Validate(); err == nil {
t.Fatal("failure without Error must be rejected by Validate (I3)")
}
if got := ExitCodeForEnvelope(invalid); got != 5 {
t.Fatalf("failure without Error = exit %d, want 5 (internal fallback)", got)
}
// ② failure + Error.Type 空串(wire-stable 字段缺失形态)→ 同样 internal 5。
emptyType := NewFailureEnvelope(&ErrorInfo{Message: "no type"})
if got := ExitCodeForEnvelope(emptyType); got != 5 {
t.Fatalf("failure with empty error.type = exit %d, want 5 (internal fallback)", got)
}
// ③ failure + Error.Type 未知值(不在码表类别内)→ internal 5。
unknown := NewFailureEnvelope(&ErrorInfo{Type: "mystery"})
if got := ExitCodeForEnvelope(unknown); got != 5 {
t.Fatalf("failure with unknown error.type = exit %d, want 5", got)
}
}
func TestExitCodeForEnvelopeSameSourceAsErrorsExitCode(t *testing.T) {
// B33 常量块与 internal/errors/errors.go ExitCode 表同源锁定:逐类别值
// 相等,防未来单边漂移(轮10裁决⑬/⑭)。PAT=4 边界注记:PAT 行为授权
// 专属码经 ExitCoder 接口路径分发,不出现在信封码表(信封 failure 的
// error.type 枚举不含 pat 类别),故不在本锁定表内。
cases := []struct {
category string
err error
}{
{"api", apperrors.NewAPI("x")},
{"auth", apperrors.NewAuth("x")},
{"validation", apperrors.NewValidation("x")},
{"discovery", apperrors.NewDiscovery("x")},
{"internal", apperrors.NewInternal("x")},
}
for _, c := range cases {
want := apperrors.ExitCode(c.err)
env := NewFailureEnvelope(&ErrorInfo{Type: c.category})
if got := ExitCodeForEnvelope(env); got != want {
t.Fatalf("category %q: ExitCodeForEnvelope=%d but errors.ExitCode=%d (code tables drifted)",
c.category, got, want)
}
}
// confirmation_required 子类与 validation 共享 rc=3(OQ-1 定案):
// runtime 形态 = NewValidation + WithReason(confirmation_required)。
confirmErr := apperrors.NewValidation("confirm", apperrors.WithReason("confirmation_required"))
confirmCode := apperrors.ExitCode(confirmErr)
confirmEnv := NewFailureEnvelope(&ErrorInfo{Type: "validation", Subtype: "confirmation_required"})
if got := ExitCodeForEnvelope(confirmEnv); got != confirmCode {
t.Fatalf("confirmation subclass: ExitCodeForEnvelope=%d but runtime errors.ExitCode=%d", got, confirmCode)
}
if confirmCode != 3 {
t.Fatalf("confirmation/validation shared rc = %d, want 3 (OQ-1)", confirmCode)
}
}
// --- B35~B40 综合:六格式在 WriteEnvelopeTo/Emitter 路径的完整分发表 ---
func TestRawPassThroughEnvelopeDiffersFromJSONByteForByte(t *testing.T) {
// -f raw 对信封的透传:只出数据载荷本身(writeRaw 语义),与 json 的
// 完整信封逐字节不同——format flag 不是惰性的(对照附录 A.3:
// 历史上 --format 六值 stdout 逐字节相同即判 flag 惰性缺陷)。
env := NewSuccessEnvelope(map[string]any{"path": "/tmp/a.bin", "size": 42})
var rawBuf, jsonBuf bytes.Buffer
if err := WriteEnvelopeTo(&rawBuf, env, FormatRaw, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(raw): %v", err)
}
if err := WriteEnvelopeTo(&jsonBuf, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(json): %v", err)
}
if bytes.Equal(rawBuf.Bytes(), jsonBuf.Bytes()) {
t.Fatalf("-f raw must differ from -f json byte-for-byte (format flag must not be inert):\n%s", rawBuf.String())
}
// raw = 数据载荷透传:恰为 writeRaw(data) 的输出(紧凑单行 JSON +
// 终端净化),不含信封外壳键。
var direct bytes.Buffer
if err := Write(&direct, FormatRaw, env.Data); err != nil {
t.Fatalf("Write(raw, data): %v", err)
}
if !bytes.Equal(rawBuf.Bytes(), direct.Bytes()) {
t.Fatalf("-f raw output must be a verbatim pass-through of the data payload:\n got: %s\nwant: %s", rawBuf.String(), direct.String())
}
if strings.Contains(rawBuf.String(), `"outcome"`) || strings.Contains(rawBuf.String(), `"ok"`) {
t.Fatalf("-f raw must not emit the envelope wrapper: %s", rawBuf.String())
}
// 数据值可用:raw 输出可解码回业务载荷。
var decoded map[string]any
if err := json.Unmarshal(rawBuf.Bytes(), &decoded); err != nil {
t.Fatalf("raw output is not decodable JSON for structured payload: %v\n%s", err, rawBuf.String())
}
if decoded["path"] != "/tmp/a.bin" {
t.Fatalf("raw payload lost data: %s", rawBuf.String())
}
}
func TestSixFormatDispatchMatrixOnEnvelopePath(t *testing.T) {
// 六格式逐格式断言分发行为(§5.2 矩阵在信封出口的行为端):
// json = 完整信封;table/pretty = 数据 + 摘要;csv/ndjson = 裸记录;
// raw = 数据透传。全部格式 stdout 非空、均不产出「半个信封」。
cases := []struct {
format Format
wantJSON bool // stdout 是否恰为 JSON 文档(完整信封)
wantWrapper bool // 是否含信封外壳键(仅 json 允许)
}{
{FormatJSON, true, true},
{FormatTable, false, false},
{FormatPretty, false, false},
{FormatCSV, false, false},
{FormatNDJSON, false, false},
{FormatRaw, false, false},
}
for _, c := range cases {
t.Run(string(c.format), func(t *testing.T) {
forceNoColor(t)
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, phaseCListFixture(), c.format, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo(%s): %v", c.format, err)
}
out := buf.String()
if len(out) == 0 {
t.Fatalf("format %s produced empty stdout", c.format)
}
hasWrapper := strings.Contains(out, `"outcome"`)
if hasWrapper != c.wantWrapper {
t.Fatalf("format %s wrapper presence = %v, want %v:\n%s", c.format, hasWrapper, c.wantWrapper, out)
}
if c.wantJSON {
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("format %s must emit one complete JSON envelope: %v\n%s", c.format, err, out)
}
if decoded["ok"] != true {
t.Fatalf("format %s envelope ok = %v, want true", c.format, decoded["ok"])
}
} else {
// 数据可见性:三格式与 raw 都能见到业务值。
if !strings.Contains(out, "alpha") {
t.Fatalf("format %s lost data payload:\n%s", c.format, out)
}
}
})
}
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newTestEnvelope() *Envelope {
return &Envelope{
OK: true,
Outcome: OutcomeSuccess,
Data: map[string]any{"name": "DemoApp"},
}
}
func TestWriteEnvelopeTo_JSONFullEnvelope(t *testing.T) {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, newTestEnvelope(), FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if decoded["outcome"] != "success" {
t.Fatalf("outcome = %v, want success", decoded["outcome"])
}
// ok 必须序列化为 JSON 布尔(禁止字符串布尔)。
if okVal, isBool := decoded["ok"].(bool); !isBool || !okVal {
t.Fatalf("ok = %v (%T), want true (bool)", decoded["ok"], decoded["ok"])
}
if _, hasData := decoded["data"]; !hasData {
t.Fatalf("data key missing: %s", buf.String())
}
}
func TestWriteEnvelopeTo_OmitEmptyNoNull(t *testing.T) {
var buf bytes.Buffer
env := &Envelope{OK: true, Outcome: OutcomeSuccess}
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
s := buf.String()
for _, key := range []string{`"meta"`, `"error"`, `"identity"`, `"dry_run"`, `"_notice"`} {
if strings.Contains(s, key) {
t.Fatalf("empty field %s must be omitted, got %s", key, s)
}
}
if strings.Contains(s, "null") {
t.Fatalf("envelope must not emit null: %s", s)
}
}
func TestWriteEnvelopeTo_NonJSONRendersDataOnly(t *testing.T) {
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, newTestEnvelope(), FormatTable, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
s := buf.String()
if strings.Contains(s, `"outcome"`) || strings.Contains(s, `"ok"`) {
t.Fatalf("table/pretty formats must not emit the envelope wrapper: %s", s)
}
if !strings.Contains(s, "DemoApp") {
t.Fatalf("data payload missing from table output: %s", s)
}
}
func TestWriteEnvelope_CommandUsesCmdStdoutAndFormat(t *testing.T) {
cmd := &cobra.Command{Use: "t"}
cmd.Flags().StringP("format", "f", "", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "table"); err != nil {
t.Fatalf("set format: %v", err)
}
if err := WriteEnvelope(cmd, newTestEnvelope(), FormatJSON); err != nil {
t.Fatalf("WriteEnvelope: %v", err)
}
if strings.Contains(stdout.String(), `"outcome"`) {
t.Fatalf("-f table must render data only, got %s", stdout.String())
}
// 默认 json:完整信封落 stdout。
stdout.Reset()
cmd2 := &cobra.Command{Use: "t2"}
cmd2.Flags().StringP("format", "f", "", "")
cmd2.SetOut(&stdout)
cmd2.SetErr(&stderr)
if err := WriteEnvelope(cmd2, newTestEnvelope(), FormatJSON); err != nil {
t.Fatalf("WriteEnvelope: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String())
}
if decoded["outcome"] != "success" {
t.Fatalf("json envelope outcome = %v, want success: %s", decoded["outcome"], stdout.String())
}
}
func TestWriteEnvelope_NilEnvelopeDoesNotPanic(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{Use: "t"}
cmd.Flags().StringP("format", "f", "json", "")
cmd.SetOut(&buf)
if err := WriteEnvelope(cmd, nil, FormatJSON); err != nil {
t.Fatalf("WriteEnvelope: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if decoded["outcome"] != "failure" {
t.Fatalf("nil envelope should serialize as failure: %s", buf.String())
}
// B208(轮6裁决⑨):兜底信封必须是 I3 合法形态——ok:false/outcome:failure
// 保留,error 明细存在且兜底信封自身通过 Validate()。
if okVal, isBool := decoded["ok"].(bool); !isBool || okVal {
t.Fatalf("nil fallback ok = %v (%T), want false (bool): %s", decoded["ok"], decoded["ok"], buf.String())
}
errObj, hasErr := decoded["error"].(map[string]any)
if !hasErr {
t.Fatalf("nil fallback must carry error detail (I3): %s", buf.String())
}
if errObj["type"] != "internal" {
t.Fatalf("fallback error.type = %v, want internal: %s", errObj["type"], buf.String())
}
if msg, _ := errObj["message"].(string); msg != "nil envelope: framework fallback" {
t.Fatalf("fallback error.message = %v, want %q: %s", errObj["message"], "nil envelope: framework fallback", buf.String())
}
if err := nilFallbackEnvelope().Validate(); err != nil {
t.Fatalf("nil fallback envelope must pass Validate(), got %v", err)
}
}
func TestWriteEnvelope_PaginationWireKeys(t *testing.T) {
var buf bytes.Buffer
env := newTestEnvelope()
env.Data = []any{}
env.Meta = &Meta{
Count: NewCount(0),
Pagination: &Pagination{
EndpointExhausted: false,
NextToken: "cursor_xyz",
Pages: 1,
Items: 0,
},
}
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
var decoded struct {
Meta struct {
Count *int `json:"count"`
Pagination struct {
EndpointExhausted bool `json:"endpoint_exhausted"`
NextToken string `json:"next_token"`
Pages int `json:"pages"`
Items int `json:"items"`
} `json:"pagination"`
} `json:"meta"`
}
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, buf.String())
}
// 契约 §3:endpoint_exhausted 两态显式——false 必须可解码区分缺席。
if decoded.Meta.Pagination.EndpointExhausted {
t.Fatalf("endpoint_exhausted = true, want false: %s", buf.String())
}
if decoded.Meta.Pagination.NextToken != "cursor_xyz" {
t.Fatalf("next_token = %q, want cursor_xyz: %s", decoded.Meta.Pagination.NextToken, buf.String())
}
// wire 层 snake_case 恒等:camelCase 键禁止出现。
s := buf.String()
for _, key := range []string{`endpointExhausted`, `nextToken`, `nextCommand`, `timedOut`} {
if strings.Contains(s, key) {
t.Fatalf("camelCase wire key %s forbidden: %s", key, s)
}
}
// endpoint_exhausted:false 必须显式出现在 wire(无 omitempty)。
if !strings.Contains(s, `"endpoint_exhausted": false`) && !strings.Contains(s, `"endpoint_exhausted":false`) {
t.Fatalf("endpoint_exhausted must be explicit when false: %s", s)
}
}
+596
View File
@@ -0,0 +1,596 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// Outcome 是一次 CLI 调用的结果类别(互斥,四值)。
// 门禁拦截(confirmation_required)是 failure 的子类,不占第五值
// (契约规范 §1 结果总图)。
type Outcome string
// 四个规范 Outcome 值。wire 值恒为这些小写字符串(契约规范 §2.5)。
const (
OutcomeSuccess Outcome = "success"
OutcomePending Outcome = "pending"
OutcomePartialFailure Outcome = "partial_failure"
OutcomeFailure Outcome = "failure"
)
// String 返回 Outcome 的 wire 表示(恒为小写规范值)。
func (o Outcome) String() string { return string(o) }
// MarshalJSON 把 Outcome 序列化为小写规范字符串。仅接受四个规范值,
// 非法值(含零值)在序列化期即报错,杜绝静默产出无效 wire 值
// (契约规范 §1/§2.5「仅四值」)。
func (o Outcome) MarshalJSON() ([]byte, error) {
switch o {
case OutcomeSuccess, OutcomePending, OutcomePartialFailure, OutcomeFailure:
return json.Marshal(string(o))
default:
return nil, fmt.Errorf("output: cannot marshal invalid outcome %q", string(o))
}
}
// ParseOutcome 严格解析 s 为 Outcome:仅接受四个小写规范值,
// 拒绝大小写变体、前后空白与空串(契约规范 §1)。
func ParseOutcome(s string) (Outcome, error) {
switch s {
case string(OutcomeSuccess):
return OutcomeSuccess, nil
case string(OutcomePending):
return OutcomePending, nil
case string(OutcomePartialFailure):
return OutcomePartialFailure, nil
case string(OutcomeFailure):
return OutcomeFailure, nil
}
return "", fmt.Errorf("output: invalid outcome %q: must be one of %q, %q, %q, %q",
s, OutcomeSuccess, OutcomePending, OutcomePartialFailure, OutcomeFailure)
}
// Envelope 是统一输出信封(契约规范 §2 信封结构)。
// 字段声明顺序对齐 §2.5 字段总表;Go 序列化按声明顺序输出,
// 由此得到稳定的顶层键顺序(golden 断言的前提)。
type Envelope struct {
// OK 为框架计算值:ok == (outcome ∈ {success, pending})(不变量 I1)。
// 恒序列化(无 omitempty),类型为 bool,禁止字符串布尔。
OK bool `json:"ok"`
// Outcome 仅四值(见 Outcome 常量)。恒序列化。
Outcome Outcome `json:"outcome"`
// Identity 为执行身份(user/app),空则省略(§2.5)。
Identity string `json:"identity,omitempty"`
// DryRun 在 dry-run 预览时为 true,默认省略(§6 场景6)。
DryRun bool `json:"dry_run,omitempty"`
// Data 是业务载荷(L2):强类型 DTO 序列化,禁止手拼 JSON。
// 可为 object 或 array;空数组 [] 是合法载荷(原样序列化,
// 见空结果表达 data:[] + count:0)。
Data any `json:"data,omitempty"`
// Meta 是元数据层(count/operation/pagination),整体 omitempty。
Meta *Meta `json:"meta,omitempty"`
// Error 是失败明细(L1):非空 ⇔ outcome==failure(不变量 I3)。
Error *ErrorInfo `json:"error,omitempty"`
// Notice 是系统级通知注入位(_notice,如升级提示),空则省略。
// 契约仅规定 object 注入位;具体通知 shape 由后续批次定型。
Notice any `json:"_notice,omitempty"`
}
// Meta 是信封元数据层(契约规范 §2.5)。
// 分页/异步操作元数据挂 meta 层而非 data 层(§3)。
type Meta struct {
// Count 是列表结果条数。指针类型区分「未设置」与「0 条」:
// 空结果场景必须显式输出 count:0(§3 / AC-06),
// nil 时字段缺席(omitempty)。
Count *int `json:"count,omitempty"`
// Operation 是异步操作信息(pending 信封,§2.2)。
Operation *OperationInfo `json:"operation,omitempty"`
// Pagination 是分页元数据(§3)。
Pagination *Pagination `json:"pagination,omitempty"`
}
// NewCount 返回 n 的指针,供 Meta.Count 使用,
// 以区分「count:0」(空结果,必须输出)与「count 缺席」(非列表场景)。
func NewCount(n int) *int { return &n }
// OperationInfo 是异步操作元数据(契约规范 §2.2)。
// 轮询超时必须保持 State 真实值并置 TimedOut:true,禁止伪装 success;
// NextCommand 必须是可直接执行的完整命令(恢复方式)。
type OperationInfo struct {
ID string `json:"id,omitempty"`
State string `json:"state,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
NextCommand string `json:"next_command,omitempty"`
}
// Pagination 是分页元数据(契约规范 §3)。
// EndpointExhausted 仅表示「观察到服务端分页耗尽」,不承诺索引健康或
// 数据全覆盖;弃用 complete 键。EndpointExhausted:false 必须携带
// NextToken(可续跑)——两者合起来即全部状态,不设 stop_reason。
// EndpointExhausted 不加 omitempty:true/false 两态都必须在 wire 上显式出现。
type Pagination struct {
EndpointExhausted bool `json:"endpoint_exhausted"`
Pages int `json:"pages,omitempty"`
Items int `json:"items,omitempty"`
NextToken string `json:"next_token,omitempty"`
}
// ErrorInfo 是失败信封的错误明细(契约规范 §2.4)。
// Type/Subtype/Code/Retryable/RetryAfterSeconds 为 wire-stable(Agent 可分支);
// Message/Hint/RequestID 为 informational(禁止分支)。
type ErrorInfo struct {
Type string `json:"type,omitempty"`
Subtype string `json:"subtype,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
UpstreamCode any `json:"upstream_code,omitempty"`
RPCCode int `json:"rpc_code,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
// Retryable 仅为 true 时出现在 wire(§2.4;重试设计 §2.1):
// bool 零值 false 遇 omitempty 即字段缺席。CLI 不自动重放。
Retryable bool `json:"retryable,omitempty"`
// RetryAfterSeconds 是服务端给出的重试等待秒数。指针类型保留
// 零值语义:0 秒有意义(nil 缺席、&0 序列化为 0),负值应在
// 构造侧拒绝。
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
RequestID string `json:"request_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
Operation string `json:"operation,omitempty"`
ServerKey string `json:"server_key,omitempty"`
Origin string `json:"origin,omitempty"`
Stage string `json:"stage,omitempty"`
ExecutionStarted *bool `json:"execution_started,omitempty"`
NextRetryAt string `json:"next_retry_at,omitempty"`
AvailableFlags []string `json:"available_flags,omitempty"`
SnapshotPath string `json:"snapshot_path,omitempty"`
Details map[string]any `json:"details,omitempty"`
RPCData any `json:"rpc_data,omitempty"`
TechnicalDetail string `json:"technical_detail,omitempty"`
FriendlyHint string `json:"friendly_hint,omitempty"`
ActionURL string `json:"action_url,omitempty"`
Cause string `json:"cause,omitempty"`
// Param/Params 在校验错误时指明违规参数(§2.4)。
Param string `json:"param,omitempty"`
Params []string `json:"params,omitempty"`
// Actions 是可执行的补救命令(门禁拦截 subtype=confirmation_required
// 时携带,含 --yes 版本;§2.4)。
Actions []string `json:"actions,omitempty"`
}
// ValidateDataErrorExclusivity 校验 wire 层面的 data/error 互斥(契约规范
// §1 不变量 I3 的字段级推论;§2.3/§2.4 形态):error 非空时 data 必须缺席——
// 失败信封只承载 error 明细,部分成功明细走 data 且不携带顶层 error。
// 无违反返回 nil;聚合校验入口(B18 Envelope.Validate)将组合本检查。
func (e *Envelope) ValidateDataErrorExclusivity() error {
if e == nil {
return nil
}
if e.Error != nil && e.Data != nil {
return fmt.Errorf("output: envelope violates invariant I3: data and error are mutually exclusive, data must be absent when error is present")
}
return nil
}
// Validate 是信封的聚合校验入口(B18,契约规范 §1):一次性收集全部违反项
// (errors.Join)而非命中即返回,便于装配侧一轮定位所有契约缺陷。校验项:
//
// - outcome 必须是四个规范值之一(§1/§2.5,复用 ParseOutcome);
// - 不变量 I1:OK == (outcome ∈ {success, pending})——框架计算值一致性;
// - 不变量 I3:Error 非空 ⇔ outcome == failure(双向);
// - I3 字段级推论:data/error 互斥(复用 ValidateDataErrorExclusivity);
// - Data 为类型化 *PartialData 时的三通道纪律(对账、非空 succeeded、
// id 可标识与跨通道互斥,§2.3;裸 map 等业务载荷形态不做解释)。
//
// outcome 非法时其派生检查(I1/I3)失去判定基准,仅报告 outcome 本身。
// Validate 只做静态字段校验,不解释业务载荷内容(L3 真实性归产品命令,§9)。
// nil 信封返回 nil:wire 出口(WriteEnvelopeTo)对 nil 已有降级 failure
// 信封的兜底,类型层不重复拦截。
func (e *Envelope) Validate() error {
if e == nil {
return nil
}
outcome, err := ParseOutcome(string(e.Outcome))
if err != nil {
return err
}
var errs []error
wantOK := outcome == OutcomeSuccess || outcome == OutcomePending
if e.OK != wantOK {
errs = append(errs, fmt.Errorf("output: envelope violates invariant I1: ok=%v but outcome=%q requires ok=%v", e.OK, outcome, wantOK))
}
errorPresent := e.Error != nil
isFailure := outcome == OutcomeFailure
if errorPresent != isFailure {
errs = append(errs, fmt.Errorf("output: envelope violates invariant I3: error present=%v but outcome=%q (error must be present if and only if outcome is failure)", errorPresent, outcome))
}
if err := e.ValidateDataErrorExclusivity(); err != nil {
errs = append(errs, err)
}
if pd, ok := e.Data.(*PartialData); ok && pd != nil {
if err := pd.Validate(); err != nil {
errs = append(errs, err)
}
}
if e.Meta != nil && e.Meta.Operation != nil {
if err := e.Meta.Operation.Validate(); err != nil {
errs = append(errs, err)
}
}
if e.Meta != nil && e.Meta.Pagination != nil {
if err := e.Meta.Pagination.Validate(); err != nil {
errs = append(errs, err)
}
}
if e.Error != nil {
if err := e.Error.Validate(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (p *Pagination) Validate() error {
if p == nil {
return nil
}
if p.EndpointExhausted && strings.TrimSpace(p.NextToken) != "" {
return fmt.Errorf("output: exhausted pagination must not carry next_token")
}
if !p.EndpointExhausted && strings.TrimSpace(p.NextToken) == "" {
return fmt.Errorf("output: non-exhausted pagination requires next_token")
}
return nil
}
func (e *ErrorInfo) Validate() error {
if e == nil {
return fmt.Errorf("output: failure error is nil")
}
errorType := strings.TrimSpace(e.Type)
if errorType == "" {
return fmt.Errorf("output: failure error.type is required")
}
// error.type is a wire-stable Agent branch key, not an open-ended label.
// Keep this set aligned with exitCodeForErrorInfo. "permission" is the
// compatibility projection for PAT failures (rc=4).
switch errorType {
case "api", "auth", "validation", "permission", "discovery", "internal":
default:
return fmt.Errorf("output: unsupported failure error.type %q", e.Type)
}
if e.ExitCode < 0 {
return fmt.Errorf("output: failure error.exit_code must not be negative")
}
if e.RetryAfterSeconds != nil && *e.RetryAfterSeconds < 0 {
return fmt.Errorf("output: failure retry_after_seconds must be non-negative")
}
return nil
}
// NewSuccessEnvelope 构造成功信封(B19,契约规范 §2.1)。
// OK 由框架按 outcome 计算(I1),调用层不得自行改写。data 必须是强类型
// DTO 序列化产物,禁止手拼 JSON(§2.5/§7);空数组 [] 是合法载荷(AC-06)。
func NewSuccessEnvelope(data any) *Envelope {
return &Envelope{OK: true, Outcome: OutcomeSuccess, Data: data}
}
// NewPendingEnvelope 构造异步受理信封(B19,契约规范 §2.2)。
// op 携带 id/state/timed_out/next_command;op 为 nil 时 meta 整体缺席。
// 轮询超时必须保持 State 真实值并置 TimedOut:true,禁止伪装 success;
// NextCommand 必须是可直接执行的完整命令(§2.2 规则)。
//
// 本构造器保持「构造宽松 + 校验严格」分层(轮6裁决⑨):§2.2 两条硬规则
// 不在构造期拦截,由 OperationInfo.Validate 与严格装配助手
// NewPendingEnvelopeForPolling(B147/B148)把守。
func NewPendingEnvelope(op *OperationInfo) *Envelope {
env := &Envelope{OK: true, Outcome: OutcomePending}
if op != nil {
env.Meta = &Meta{Operation: op}
}
return env
}
// NewFailureEnvelope 构造失败信封(B20,契约规范 §2.4)。
// error 非空 ⇔ outcome==failure(I3):info 为 nil 的失败信封无法通过
// Validate(),调用侧不得提交;data 必须缺席(I3 字段级推论)。
func NewFailureEnvelope(info *ErrorInfo) *Envelope {
return &Envelope{OK: false, Outcome: OutcomeFailure, Error: info}
}
// NewPartialEnvelope 构造部分成功信封(B20,契约规范 §2.3)。
// 三通道明细(total/succeeded/failed/unknown)进 data,不携带顶层 error。
// 通道规则(B140/B141/B144 已落盘):推荐以 NewPartialData 装配明细——
// 构造期拒绝空 succeeded(全失败禁用 partial_failure);Envelope.Validate
// 对 *PartialData 载荷追加通道校验(同 id 跨通道互斥、total 对账等)。
func NewPartialEnvelope(data any) *Envelope {
return &Envelope{OK: false, Outcome: OutcomePartialFailure, Data: data}
}
// IsOK 计算不变量 I1(B21,契约规范 §1):ok == (outcome ∈ {success, pending})。
// 这是框架侧的唯一计算函数,命令层不可写或改写该结果;Envelope.OK 字段值
// 必须与 IsOK 一致(Validate 交叉校验,违反即 I1 缺陷)。
// nil 信封视为不 ok:没有结果可言,不得汇报为成功/受理。
// 非法(非四值)outcome 必然返回 false:I1 只定义在四个规范值上,
// 非法值由 ParseOutcome/Validate/MarshalJSON 各自拒绝。
func (e *Envelope) IsOK() bool {
if e == nil {
return false
}
return e.Outcome == OutcomeSuccess || e.Outcome == OutcomePending
}
// ---------------------------------------------------------------------------
// Phase G:partial_failure 三通道明细(B139~B146;契约规范 §2.3)
// ---------------------------------------------------------------------------
// PartialFailedEntry 是 partial 失败通道的单条明细(B139,契约规范 §2.3):
// id 标识条目,Error 携带该条目的失败明细(复用 §2.4 ErrorInfo 形态,
// wire-stable 字段组可供 Agent 分支)。
type PartialFailedEntry struct {
ID string `json:"id"`
Error *ErrorInfo `json:"error,omitempty"`
}
// PartialUnknownEntry 是 partial unknown 通道的单条明细(B144,契约规范
// §2.3/P0#9「报错却写入」类问题的诚实通道):shape = id + reason——
// 已提交但终态无法确认的条目放这里;reason 必须非空,说明不确定的原因。
type PartialUnknownEntry struct {
ID string `json:"id"`
Reason string `json:"reason"`
}
// PartialData 是部分成功三通道明细的类型化形态(B139,契约规范 §2.3),
// 整体放入 Envelope.Data。声明顺序 = wire 顺序:total/succeeded/failed/
// unknown(对齐契约示例)。三通道纪律(B140/B141/B144/B146)由
// NewPartialData 构造期校验 + Validate 集中复验:
//
// - succeeded 必须完整保留(构造器不接受丢弃明细,§2.3 规则1);
// - unknown 条目禁止归入 succeeded/failed(同一 id 跨通道互斥,§2.3 规则2);
// - 空 succeeded 禁用 partial_failure(全失败必须用普通 failure,§2.3 规则4)。
//
// 三个切片字段不带 omitempty:空通道以 [] 显式表达(不输出 null,
// §2.5 无 null 纪律),与 data:[] 空态合法载荷同口径。
type PartialData struct {
Total int `json:"total"`
Succeeded []any `json:"succeeded"`
Failed []PartialFailedEntry `json:"failed"`
Unknown []PartialUnknownEntry `json:"unknown"`
}
// NewPartialData 装配三通道明细(B139/B140/B141/B144/B146,契约规范 §2.3)。
// 装配期即校验通道纪律:
//
// 1. succeeded 非空且原样保留——空 succeeded 的 partial_failure 被拒绝
// (全部失败必须用 outcome:failure,§2.3 规则4;B141);构造器不丢弃、
// 不过滤任何 succeeded 条目(B146:Agent 需要知道哪些已生效,避免重复提交);
// 2. failed/unknown 条目 id 必须非空(条目可标识是跨通道互斥的唯一依据);
// unknown 条目 reason 必须非空(诚实通道必须说明不确定原因,B144);
// 3. 同一 id 不得跨通道或同通道重复出现——一个条目只有一个终态通道
// (「unknown 禁止归入 succeeded/failed」,§2.3 规则2;B140)。succeeded
// 条目是异构业务 DTO,其 id 按 JSON 泛化形态的 "id" 键提取;无 id 或非
// object 的条目不参与互斥判定(框架不解释业务载荷内容,§9);
// 4. total 对账:total == len(succeeded)+len(failed)+len(unknown)——
// 三通道明细是 total 的完整划分,不允许出现无明细的条目。
//
// nil 的 failed/unknown 入参归一为空切片(空通道 wire 上是 [] 而非 null)。
func NewPartialData(total int, succeeded []any, failed []PartialFailedEntry, unknown []PartialUnknownEntry) (*PartialData, error) {
if failed == nil {
failed = []PartialFailedEntry{}
}
if unknown == nil {
unknown = []PartialUnknownEntry{}
}
d := &PartialData{Total: total, Succeeded: succeeded, Failed: failed, Unknown: unknown}
return d, d.Validate()
}
// Validate 校验三通道纪律(B140)。NewPartialData 构造期已校验;本方法供
// 手工装配形态(绕过构造器)集中复验,并由 Envelope.Validate 在
// outcome==partial_failure 且 Data 为 *PartialData 时聚合调用。
func (d *PartialData) Validate() error {
if d == nil {
return nil
}
var errs []error
if d.Succeeded == nil || d.Failed == nil || d.Unknown == nil {
errs = append(errs, fmt.Errorf("output: partial channels must serialize as [] not null (§2.5 no-null discipline)"))
}
// B141:全失败禁用 partial_failure(§2.3 规则4)。
if len(d.Succeeded) == 0 {
errs = append(errs, fmt.Errorf("output: partial_failure with empty succeeded is forbidden: all-failed batches must use outcome:failure (§2.3)"))
}
// B146:succeeded 完整保留——不接受 nil(null)条目混入明细。
for i, item := range d.Succeeded {
if item == nil {
errs = append(errs, fmt.Errorf("output: partial succeeded[%d] is nil: succeeded details must be fully preserved entries (§2.3)", i))
}
}
for i, entry := range d.Failed {
if strings.TrimSpace(entry.ID) == "" {
errs = append(errs, fmt.Errorf("output: partial failed[%d] has empty id: entries must be identifiable (§2.3)", i))
}
if entry.Error == nil {
errs = append(errs, fmt.Errorf("output: partial failed[%d] requires a typed error (§2.3)", i))
} else if err := entry.Error.Validate(); err != nil {
errs = append(errs, fmt.Errorf("output: partial failed[%d] has invalid typed error: %w", i, err))
}
}
// B144:unknown shape = id + reason,两者都必须非空。
for i, entry := range d.Unknown {
if strings.TrimSpace(entry.ID) == "" {
errs = append(errs, fmt.Errorf("output: partial unknown[%d] has empty id: entries must be identifiable (§2.3)", i))
}
if strings.TrimSpace(entry.Reason) == "" {
errs = append(errs, fmt.Errorf("output: partial unknown[%d] has empty reason: the honest channel must state why the terminal state is unconfirmed (§2.3/P0#9)", i))
}
}
// B141/§2.3:total 对账——三通道明细是 total 的完整划分。
if sum := len(d.Succeeded) + len(d.Failed) + len(d.Unknown); d.Total != sum {
errs = append(errs, fmt.Errorf("output: partial total=%d does not reconcile with channel details (succeeded %d + failed %d + unknown %d = %d) (§2.3)",
d.Total, len(d.Succeeded), len(d.Failed), len(d.Unknown), sum))
}
// B140:同一 id 跨通道/同通道互斥(unknown 不得归入 succeeded/failed)。
seen := make(map[string]string)
register := func(id, channel string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
if prev, dup := seen[id]; dup {
errs = append(errs, fmt.Errorf("output: partial entry id %q appears in both %s and %s: one entry has exactly one terminal channel (§2.3)", id, prev, channel))
return
}
seen[id] = channel
}
for _, item := range d.Succeeded {
register(partialEntryID(item), "succeeded")
}
for _, entry := range d.Failed {
register(entry.ID, "failed")
}
for _, entry := range d.Unknown {
register(entry.ID, "unknown")
}
return errors.Join(errs...)
}
// partialEntryID 从异构 succeeded 条目中提取 "id"(JSON 泛化形态的字符串键)。
// 无 id、id 非字符串或非 object 的条目返回空串(不参与互斥判定)。
func partialEntryID(item any) string {
generic, ok := toGeneric(item).(map[string]any)
if !ok {
return ""
}
id, _ := generic["id"].(string)
return id
}
// ---------------------------------------------------------------------------
// Phase H:operation / pagination 装配助手(B147~B152;契约规范 §2.2/§3)
// ---------------------------------------------------------------------------
// operation.state 取值表(B150,契约规范 §2.2):state 描述异步操作所处阶段,
// 规范值为以下集合(开放枚举——业务特化阶段容忍,但以下三值是 Agent 必须能
// 分支的最小集)。timed_out:true 禁止与完成态共存(§2.2 规则1,见
// OperationInfo.ValidateTimeoutState)。
const (
OperationStateProcessing = "processing"
OperationStateCompleted = "completed"
OperationStateFailed = "failed"
)
// ValidateNextCommand 校验 next_command 的可执行形态(B148,契约规范 §2.2
// 规则2「next_command 必须是可直接执行的完整命令」):非空时必须是无前后
// 空白、不含换行的单行命令串——这是框架侧对「可直接执行」的形态代理;
// 命令真实可执行性(命令存在、参数有效)归装配侧保证(§9)。
// 空串在此不拦截:omitempty wire 允许无 operation 明细的裸 pending 信封,
// 轮询形态装配 NewPendingEnvelopeForPolling 才要求非空。
func (op *OperationInfo) ValidateNextCommand() error {
if op == nil {
return nil
}
if op.NextCommand == "" {
return nil
}
if strings.TrimSpace(op.NextCommand) != op.NextCommand {
return fmt.Errorf("output: operation.next_command %q must carry no leading/trailing whitespace (§2.2: directly executable)", op.NextCommand)
}
if strings.ContainsAny(op.NextCommand, "\n\r") {
return fmt.Errorf("output: operation.next_command must be a single executable command line (§2.2)")
}
return nil
}
// ValidateTimeoutState 执行 §2.2 规则1 的防伪装校验(B149):轮询超时必须
// 保持 state 真实值并置 timed_out:true——TimedOut 时 State 必须非空(真实值
// 必报),且不得宣称任何完成态(completed/success):超时操作宣称完成态
// 即伪装 success(对齐 lark wiki_node_delete 教训)。
func (op *OperationInfo) ValidateTimeoutState() error {
if op == nil || !op.TimedOut {
return nil
}
state := strings.TrimSpace(op.State)
if state == "" {
return fmt.Errorf("output: operation.timed_out=true requires the real state to be reported (empty state, §2.2 anti-spoof)")
}
if state == OperationStateCompleted || strings.EqualFold(state, string(OutcomeSuccess)) {
return fmt.Errorf("output: operation.timed_out=true must keep the real state %q: a timed-out operation must not claim a completed state (§2.2 anti-spoof)", state)
}
return nil
}
// Validate 聚合 operation 的 §2.2 形态校验。必填字段由新框架的
// CommandResult 边界校验;这里保持旧 Envelope 构造 API 的兼容性。
func (op *OperationInfo) Validate() error {
if op == nil {
return nil
}
return errors.Join(op.ValidateNextCommand(), op.ValidateTimeoutState())
}
// NewPendingEnvelopeForPolling 装配轮询形态的 pending 信封(B147 增强,
// 契约规范 §2.2):比 NewPendingEnvelope 更严格——轮询形态的恢复方式只有
// next_command 一条出口,故 op 与 next_command 必须非空且通过可执行形态
// 校验;state 必须遵守 timed_out 防伪装规则。返回 I1 一致(ok:true/
// outcome:pending)的信封;装配失败返回 error(信封为 nil)。
func NewPendingEnvelopeForPolling(op *OperationInfo) (*Envelope, error) {
if op == nil {
return nil, fmt.Errorf("output: polling-form pending envelope requires operation (§2.2: meta.operation is the recovery carrier)")
}
if strings.TrimSpace(op.NextCommand) == "" {
return nil, fmt.Errorf("output: polling-form pending envelope requires a non-empty, directly executable next_command (§2.2 rule 2)")
}
if err := op.Validate(); err != nil {
return nil, err
}
return NewPendingEnvelope(op), nil
}
// NewPagination 装配分页元数据(B152,契约规范 §3):两态完备校验——
// endpoint_exhausted:false 必须携带 next_token(可续跑:无 token 则
// 「可续跑」不可表达,破坏 §3 两态完备性);endpoint_exhausted:true
// 不得携带 next_token(耗尽即无续跑凭据,两者合起来即全部状态,
// 不设 stop_reason)。Pages/Items 为信息性计数(omitempty),由调用方
// 按观察到的页数/条数在返回结构上另行设置。
func NewPagination(endpointExhausted bool, nextToken string) (*Pagination, error) {
token := strings.TrimSpace(nextToken)
if !endpointExhausted && token == "" {
return nil, fmt.Errorf("output: pagination endpoint_exhausted=false requires next_token (§3: resumable state must carry the resume handle)")
}
if endpointExhausted && token != "" {
return nil, fmt.Errorf("output: pagination endpoint_exhausted=true must not carry next_token (§3: exhausted and resumable are the only two states)")
}
pg := &Pagination{EndpointExhausted: endpointExhausted, NextToken: token}
return pg, nil
}
@@ -0,0 +1,167 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
// Phase I:信封渲染字节稳定性(B188;AC-07)。落盘策略:轮8裁决⑩新文件。
//
// 契约卫生的可复现前提:同一信封输入,多次渲染必须逐字节一致。Go 的 map
// 迭代顺序随机、encoding/json 对 map 按键排序序列化——本测试用大 map 载荷
// 反复渲染,锁死「字节稳定」这一性质:任何引入非确定性渲染(如依赖 map
// 迭代序、时间戳、指针地址、随机序)的改动都会让多次渲染产生字节漂移,
// 从而命中断言。golden 比对(B157)锁的是「与基准一致」,本测试锁的是
// 「自身多次渲染一致」,两者互补。
import (
"bytes"
"fmt"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
)
// buildLargeMapPayload 构造一个多键 map 载荷:键数足够多,使 map 迭代顺序
// 随机性的影响面充分暴露(小 map 可能偶然同序,掩盖非确定性)。
func buildLargeMapPayload(n int) map[string]any {
m := make(map[string]any, n)
for i := 0; i < n; i++ {
m[fmt.Sprintf("key_%03d", i)] = map[string]any{
"id": fmt.Sprintf("id_%03d", i),
"name": fmt.Sprintf("name_%03d", i),
"weight": i,
"active": i%2 == 0,
}
}
return m
}
// renderEnvelopeBytes 用生产路径同一序列化函数渲染信封(两空格缩进)。
func renderEnvelopeBytes(t *testing.T, env *Envelope) []byte {
t.Helper()
data, err := jsonutil.MarshalIndent(env, "", " ")
if err != nil {
t.Fatalf("jsonutil.MarshalIndent: %v", err)
}
return data
}
// TestEnvelopeRenderByteStabilityAcrossRepeatedRenders 是 B188 的核心断言:
// 同一信封反复渲染 N 次,每次输出逐字节相同(防 map 序抖动)。
func TestEnvelopeRenderByteStabilityAcrossRepeatedRenders(t *testing.T) {
// 载荷用大 map,放大迭代序随机性;envelope 字段声明序是确定的,
// 但 data 内的 map 若被非确定性渲染即会漂移。
env := NewSuccessEnvelope(buildLargeMapPayload(64))
env.Meta = &Meta{Count: NewCount(64)}
first := renderEnvelopeBytes(t, env)
if len(first) == 0 {
t.Fatal("render produced empty output")
}
const iterations = 50
for i := 1; i < iterations; i++ {
got := renderEnvelopeBytes(t, env)
if !bytes.Equal(got, first) {
t.Fatalf("render #%d drifted byte-for-byte from render #0 (map-order jitter / nondeterminism): first diff at byte %d",
i, firstDiffIndex(first, got))
}
}
}
// firstDiffIndex 返回两个字节切片首个不同位置的索引(全等返回 -1),
// 便于定位漂移起点。
func firstDiffIndex(a, b []byte) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
if len(a) != len(b) {
return n
}
return -1
}
// TestEnvelopeRenderByteStabilityAcrossAllFourOutcomes 把字节稳定性断言扩展到
// 四类 outcome(含携带 map 载荷的 pending/partial),确保各形态渲染均稳定。
func TestEnvelopeRenderByteStabilityAcrossAllFourOutcomes(t *testing.T) {
envs := map[string]*Envelope{
"success": NewSuccessEnvelope(buildLargeMapPayload(40)),
"pending": newStabilityPendingFixture(),
"partial_failure": newStabilityPartialFixture(t),
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api", Code: 90018, Message: "rate limited"}),
}
for name, env := range envs {
t.Run(name, func(t *testing.T) {
first := renderEnvelopeBytes(t, env)
for i := 1; i < 20; i++ {
got := renderEnvelopeBytes(t, env)
if !bytes.Equal(got, first) {
t.Fatalf("%s render #%d drifted (nondeterminism): first diff at byte %d",
name, i, firstDiffIndex(first, got))
}
}
})
}
}
func newStabilityPendingFixture() *Envelope {
env := NewPendingEnvelope(&OperationInfo{ID: "t_stab", State: OperationStateProcessing, NextCommand: "dws op get t_stab"})
env.Data = buildLargeMapPayload(40)
return env
}
func newStabilityPartialFixture(t *testing.T) *Envelope {
t.Helper()
succeeded := make([]any, 0, 8)
for i := 0; i < 8; i++ {
succeeded = append(succeeded, map[string]any{
"id": fmt.Sprintf("s_%03d", i),
"detail": buildLargeMapPayload(8), // 每条 succeeded 内嵌多键 map
"message": fmt.Sprintf("msg_%03d", i),
})
}
pd, err := NewPartialData(len(succeeded)+1,
succeeded,
[]PartialFailedEntry{{ID: "f_001", Error: &ErrorInfo{Type: "api", Code: 40001}}},
nil,
)
if err != nil {
t.Fatalf("NewPartialData: %v", err)
}
return NewPartialEnvelope(pd)
}
// TestEnvelopeRenderByteStabilityIndependentOfConstructionOrder 验证字节稳定
// 不依赖构造顺序:两个字段插入顺序不同但内容等价的 map,渲染结果逐字节一致
// (encoding/json 对 map 按键排序,故构造序不影响 wire——锁死这一性质)。
func TestEnvelopeRenderByteStabilityIndependentOfConstructionOrder(t *testing.T) {
// 正向插入。
a := map[string]any{}
for _, k := range []string{"alpha", "beta", "gamma", "delta"} {
a[k] = k + "_value"
}
// 逆向插入(同一键集,不同插入序)。
b := map[string]any{}
for _, k := range []string{"delta", "gamma", "beta", "alpha"} {
b[k] = k + "_value"
}
gotA := renderEnvelopeBytes(t, NewSuccessEnvelope(a))
gotB := renderEnvelopeBytes(t, NewSuccessEnvelope(b))
if !bytes.Equal(gotA, gotB) {
t.Fatalf("construction order leaked into wire bytes (map keys must be sorted on marshal):\n--- a ---\n%s\n--- b ---\n%s", gotA, gotB)
}
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"strings"
"testing"
)
// --- B21:IsOK() I1 框架计算函数 ---
func TestEnvelopeIsOKComputesFromOutcome(t *testing.T) {
// IsOK 只看 outcome,不看(可能被命令层篡改的)OK 字段——
// 「由框架计算,命令层不可写」(契约规范 §1 I1)。
env := &Envelope{OK: true, Outcome: OutcomeFailure}
if env.IsOK() {
t.Fatalf("IsOK must ignore the tampered OK field: outcome=failure requires false")
}
env2 := &Envelope{OK: false, Outcome: OutcomeSuccess}
if !env2.IsOK() {
t.Fatalf("IsOK must compute from outcome: success requires true regardless of OK field")
}
}
func TestEnvelopeIsOKNilAndInvalidOutcome(t *testing.T) {
var env *Envelope
if env.IsOK() {
t.Fatalf("nil envelope must not be ok: no outcome means no success")
}
for _, bad := range []Outcome{"", "SUCCESS", "Failure", "ok", "partial"} {
if (&Envelope{Outcome: bad}).IsOK() {
t.Fatalf("IsOK(%q) = true, want false: I1 is defined only over the four canonical outcomes", bad)
}
}
}
// --- B22:I1 全四值穷举 ---
func TestInvariantI1ExhaustiveOverFourOutcomes(t *testing.T) {
// I1:ok == (outcome ∈ {success, pending}),四值全穷举。
cases := []struct {
outcome Outcome
wantOK bool
}{
{OutcomeSuccess, true},
{OutcomePending, true},
{OutcomePartialFailure, false},
{OutcomeFailure, false},
}
if len(cases) != 4 {
t.Fatalf("I1 exhaustive table must cover exactly the four canonical outcomes, got %d", len(cases))
}
for _, c := range cases {
// failure 形态附 error 以同时满足 I3(error 非空 ⇔ outcome==failure),
// 使 Validate() 对 I1 一致信封整体放行。
var errPtr *ErrorInfo
if c.outcome == OutcomeFailure {
errPtr = &ErrorInfo{Type: "api"}
}
env := &Envelope{OK: c.wantOK, Outcome: c.outcome, Error: errPtr}
if got := env.IsOK(); got != c.wantOK {
t.Fatalf("IsOK() for outcome=%q = %v, want %v (I1)", c.outcome, got, c.wantOK)
}
if env.OK != env.IsOK() {
t.Fatalf("envelope ok=%v disagrees with framework-computed IsOK=%v for outcome=%q (I1)",
env.OK, env.IsOK(), c.outcome)
}
if err := env.Validate(); err != nil {
t.Fatalf("Validate() on I1-consistent outcome=%q envelope = %v, want nil", c.outcome, err)
}
// 反向:人为翻转 OK 字段 → Validate 必须报 I1 违反。
tampered := &Envelope{OK: !c.wantOK, Outcome: c.outcome}
if tampered.IsOK() != c.wantOK {
t.Fatalf("IsOK must stay outcome-driven when OK field is tampered (outcome=%q)", c.outcome)
}
if err := tampered.Validate(); err == nil || !strings.Contains(err.Error(), "I1") {
t.Fatalf("Validate() on tampered outcome=%q envelope must report I1 violation, got %v", c.outcome, err)
}
}
}
func TestInvariantI1ConstructorAgreement(t *testing.T) {
// 四个构造器产出的信封,其 OK 字段与 IsOK() 恒一致(I1 的装配端闭环)。
envs := map[string]*Envelope{
"success": NewSuccessEnvelope(nil),
"pending": NewPendingEnvelope(nil),
"partial_failure": NewPartialEnvelope(nil),
"failure": NewFailureEnvelope(&ErrorInfo{Type: "api"}),
}
for name, env := range envs {
if env.OK != env.IsOK() {
t.Fatalf("constructor %q: OK=%v but IsOK()=%v (I1)", name, env.OK, env.IsOK())
}
}
}
// --- B23:I3 双向穷举(error 非空 ⇔ outcome==failure)---
func TestInvariantI3BidirectionalExhaustive(t *testing.T) {
// 双向穷举:4 个 outcome × {error 缺席, error 非空} = 8 组合,
// 仅 outcome==failure ⇔ error 非空 的 2 组合合法(对角线两侧各一)。
errInfo := &ErrorInfo{Type: "api", Code: 90018, Message: "too many requests"}
for _, outcome := range canonicalOutcomes {
for _, errPtr := range []*ErrorInfo{nil, errInfo} {
env := &Envelope{
OK: outcome == OutcomeSuccess || outcome == OutcomePending,
Outcome: outcome,
Error: errPtr,
}
errorPresent := env.Error != nil
isFailure := env.Outcome == OutcomeFailure
wantValid := errorPresent == isFailure
err := env.Validate()
if wantValid && err != nil {
t.Fatalf("outcome=%q errorPresent=%v must satisfy I3, Validate()=%v", outcome, errorPresent, err)
}
if !wantValid {
if err == nil {
t.Fatalf("outcome=%q errorPresent=%v violates I3 but Validate() passed", outcome, errorPresent)
}
if !strings.Contains(err.Error(), "I3") {
t.Fatalf("I3 violation must be reported as such, got %v", err)
}
}
}
}
}
func TestInvariantI3WireDirectionFromConstructors(t *testing.T) {
// wire 方向断言:四类构造器产出的信封中,error 字段出现当且仅当
// outcome==failure(「⇔」的两个方向都要显式断言)。
envs := []*Envelope{
NewSuccessEnvelope(map[string]any{"id": "x"}),
NewPendingEnvelope(&OperationInfo{ID: "t_1", State: "processing"}),
NewPartialEnvelope(map[string]any{"total": 1}),
NewFailureEnvelope(&ErrorInfo{Type: "validation", Message: "bad"}),
}
for _, env := range envs {
errorPresent := env.Error != nil
isFailure := env.Outcome == OutcomeFailure
if errorPresent != isFailure {
t.Fatalf("constructor envelope outcome=%q: error present=%v, want == isFailure(%v) (I3)",
env.Outcome, errorPresent, isFailure)
}
}
}
+414
View File
@@ -0,0 +1,414 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
// Phase G:partial_failure / unknown 通道(B139~B146;契约规范 §2.3)。
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
// B143(partial 信封 → exit 7 出口映射)已由轮9 B34 覆盖
// (emitter_phase_b_test.go:TestInvariantI2ExhaustiveOverFourOutcomes 的
// partial==7 精确断言 + TestEmitterStreamRoutingConsistentWithExitCode 的
// stdout 通道 + exit 7 联动断言),本文件不重复造测试。
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
)
// --- B139:PartialData 三通道明细类型(total/succeeded[]/failed[]/unknown[])---
func TestPartialDataWireShapeAndKeyOrder(t *testing.T) {
// wire 形态对齐契约 §2.3 示例:顶层键序 total/succeeded/failed/unknown
// (声明序 = wire 序),空通道显式 [] 而非 null(§2.5 无 null 纪律)。
pd, err := NewPartialData(2,
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
nil, // nil failed 归一为 []
[]PartialUnknownEntry{{ID: "c", Reason: "timeout after submit"}},
)
if err != nil {
t.Fatalf("NewPartialData: %v", err)
}
raw, err := json.Marshal(pd)
if err != nil {
t.Fatalf("json.Marshal(PartialData): %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("PartialData wire is not valid JSON: %v\n%s", err, raw)
}
if got, ok := decoded["total"].(float64); !ok || got != 2 {
t.Fatalf("total = %v, want 2: %s", decoded["total"], raw)
}
for _, channel := range []string{"succeeded", "failed", "unknown"} {
arr, ok := decoded[channel].([]any)
if !ok {
t.Fatalf("channel %q must serialize as a JSON array: %s", channel, raw)
}
if arr == nil {
t.Fatalf("channel %q must be [] not null: %s", channel, raw)
}
}
if strings.Contains(string(raw), "null") {
t.Fatalf("partial channels must never emit null (§2.5): %s", raw)
}
// 键序断言:json.Decoder 保序读入。
dec := json.NewDecoder(bytes.NewReader(raw))
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
t.Fatalf("PartialData must be an object: %v (%v)", tok, err)
}
var keys []string
for dec.More() {
tok, err := dec.Token()
if err != nil {
t.Fatalf("token: %v", err)
}
keys = append(keys, tok.(string))
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
t.Fatalf("decode value: %v", err)
}
}
if want := []string{"total", "succeeded", "failed", "unknown"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("PartialData key order = %v, want %v (§2.3)", keys, want)
}
}
func TestPartialFailedEntryCarriesErrorInfoShape(t *testing.T) {
// failed 通道条目 = id + error(复用 §2.4 ErrorInfo,wire-stable 字段可分支)。
entry := PartialFailedEntry{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001, Message: "invalid recipient"}}
raw, err := json.Marshal(entry)
if err != nil {
t.Fatalf("json.Marshal(failed entry): %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("failed entry wire invalid: %v\n%s", err, raw)
}
if decoded["id"] != "b" {
t.Fatalf("failed entry id = %v: %s", decoded["id"], raw)
}
errObj, ok := decoded["error"].(map[string]any)
if !ok {
t.Fatalf("failed entry must carry error detail (§2.3): %s", raw)
}
if errObj["type"] != "api" {
t.Fatalf("failed entry error.type = %v: %s", errObj["type"], raw)
}
}
// --- B141:全失败禁用 partial_failure(空 succeeded 拒绝构造)---
func TestNewPartialDataRejectsEmptySucceeded(t *testing.T) {
// §2.3 规则4:全部失败时必须用 outcome:failure,禁止空 succeeded 的
// partial_failure。构造期即拒绝。
for _, tc := range []struct {
name string
failed []PartialFailedEntry
unknown []PartialUnknownEntry
}{
{"all failed", []PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}}, nil},
{"all unknown", nil, []PartialUnknownEntry{{ID: "c", Reason: "timeout"}}},
{"failed and unknown only",
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}},
[]PartialUnknownEntry{{ID: "c", Reason: "timeout"}}},
{"empty everything", nil, nil},
} {
t.Run(tc.name, func(t *testing.T) {
total := len(tc.failed) + len(tc.unknown)
pd, err := NewPartialData(total, nil, tc.failed, tc.unknown)
if err == nil {
t.Fatalf("NewPartialData with empty succeeded must be rejected (§2.3 rule 4), got %+v", pd)
}
if !strings.Contains(err.Error(), "succeeded") || !strings.Contains(err.Error(), "failure") {
t.Fatalf("rejection must name the empty-succeeded/all-failure rule: %v", err)
}
})
}
// 全失败的正确形态:普通 failure 信封(构造合法、Validate 通过)。
failure := NewFailureEnvelope(&ErrorInfo{Type: "api", Message: "all 3 items failed"})
if err := failure.Validate(); err != nil {
t.Fatalf("all-failed batch must use a legal failure envelope: %v", err)
}
}
func TestEnvelopeValidateRejectsHandBuiltAllFailedPartial(t *testing.T) {
// 绕过构造器手工装配的全失败 partial 同样被 Validate 拒绝
// (校验集中,§2.3 规则4)。
pd := &PartialData{
Total: 2,
Succeeded: []any{},
Failed: []PartialFailedEntry{
{ID: "a", Error: &ErrorInfo{Type: "api"}},
{ID: "b", Error: &ErrorInfo{Type: "api"}},
},
Unknown: []PartialUnknownEntry{},
}
env := NewPartialEnvelope(pd)
err := env.Validate()
if err == nil {
t.Fatal("Validate must reject all-failed partial_failure (§2.3 rule 4)")
}
if !strings.Contains(err.Error(), "partial_failure with empty succeeded") {
t.Fatalf("error must name the rule: %v", err)
}
}
func TestPartialDataTotalReconciliation(t *testing.T) {
// total 对账:total != 三通道之和即拒绝(明细是 total 的完整划分)。
_, err := NewPartialData(5, []any{map[string]any{"id": "a"}},
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api"}}}, nil)
if err == nil {
t.Fatal("total=5 with 2 detailed entries must be rejected (§2.3 reconciliation)")
}
if !strings.Contains(err.Error(), "reconcile") {
t.Fatalf("error must name reconciliation: %v", err)
}
}
// --- B140:通道校验规则——unknown 条目禁止归入 succeeded/failed ---
func TestPartialDataRejectsCrossChannelDuplicateIDs(t *testing.T) {
succeeded := func(id string) []any { return []any{map[string]any{"id": id, "messageId": "m_1"}} }
failed := func(id string) []PartialFailedEntry {
return []PartialFailedEntry{{ID: id, Error: &ErrorInfo{Type: "api"}}}
}
unknown := func(id string) []PartialUnknownEntry {
return []PartialUnknownEntry{{ID: id, Reason: "timeout after submit"}}
}
cases := []struct {
name string
succeeded []any
failed []PartialFailedEntry
unknown []PartialUnknownEntry
}{
{"unknown id also in succeeded", succeeded("c"), nil, unknown("c")},
{"unknown id also in failed", nil, failed("c"), unknown("c")},
{"failed id also in succeeded", succeeded("b"), failed("b"), nil},
{"duplicate within unknown", succeeded("a"), nil,
append(unknown("c"), PartialUnknownEntry{ID: "c", Reason: "second report"})},
{"duplicate within succeeded", append(succeeded("a"), map[string]any{"id": "a"}), nil, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
total := len(tc.succeeded) + len(tc.failed) + len(tc.unknown)
_, err := NewPartialData(total, tc.succeeded, tc.failed, tc.unknown)
if err == nil {
t.Fatalf("cross/duplicate channel id must be rejected (§2.3 rule 2)")
}
if !strings.Contains(err.Error(), "one terminal channel") && !strings.Contains(err.Error(), "exactly one terminal channel") {
t.Fatalf("error must name the one-channel-per-entry rule: %v", err)
}
})
}
}
func TestPartialDataAcceptsDistinctChannelIDs(t *testing.T) {
// 三通道 id 互不相交时合法(契约 §2.3 标准形态)。
pd, err := NewPartialData(3,
[]any{map[string]any{"id": "a", "messageId": "m_1"}},
[]PartialFailedEntry{{ID: "b", Error: &ErrorInfo{Type: "api", Code: 40001}}},
[]PartialUnknownEntry{{ID: "c", Reason: "timeout after submit"}},
)
if err != nil {
t.Fatalf("legal three-channel data rejected: %v", err)
}
env := NewPartialEnvelope(pd)
env.Meta = &Meta{Count: NewCount(3)}
if err := env.Validate(); err != nil {
t.Fatalf("legal partial envelope must pass Validate: %v", err)
}
}
func TestPartialDataEntriesMustBeIdentifiable(t *testing.T) {
// failed/unknown 条目 id 必须非空——条目可标识是跨通道互斥的唯一依据。
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
[]PartialFailedEntry{{Error: &ErrorInfo{Type: "api"}}}, nil); err == nil {
t.Fatal("failed entry without id must be rejected (§2.3)")
}
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
nil, []PartialUnknownEntry{{Reason: "timeout"}}); err == nil {
t.Fatal("unknown entry without id must be rejected (§2.3)")
}
}
// --- B144:unknown[] 条目 shape(id + reason)---
func TestPartialUnknownEntryShapeIDPlusReason(t *testing.T) {
// shape 恰为 id + reason 两键(P0#9「报错却写入」类条目的诚实通道)。
entry := PartialUnknownEntry{ID: "c", Reason: "timeout after submit"}
raw, err := json.Marshal(entry)
if err != nil {
t.Fatalf("json.Marshal(unknown entry): %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unknown entry wire invalid: %v\n%s", err, raw)
}
if len(decoded) != 2 {
t.Fatalf("unknown entry must carry exactly id+reason, got %v: %s", decoded, raw)
}
if decoded["id"] != "c" || decoded["reason"] != "timeout after submit" {
t.Fatalf("unknown entry = %v, want id+reason preserved", decoded)
}
}
func TestPartialUnknownEntryRequiresReason(t *testing.T) {
// 诚实通道必须说明不确定原因:空 reason 拒绝。
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
nil, []PartialUnknownEntry{{ID: "c"}}); err == nil {
t.Fatal("unknown entry without reason must be rejected (§2.3/P0#9)")
}
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}},
nil, []PartialUnknownEntry{{ID: "c", Reason: " "}}); err == nil {
t.Fatal("unknown entry with whitespace-only reason must be rejected (§2.3/P0#9)")
}
}
// --- B146:succeeded 明细完整保留(构造器不接受丢弃明细)---
func TestNewPartialDataPreservesSucceededDetailsVerbatim(t *testing.T) {
// 构造器不丢弃、不过滤、不重排任何 succeeded 条目(§2.3 规则1:
// Agent 需要知道哪些已生效,避免重复提交)。
succeeded := []any{
map[string]any{"id": "a", "messageId": "m_1"},
map[string]any{"id": "d", "messageId": "m_4", "extra": map[string]any{"nested": true}},
"d-plain-string-entry", // 异构条目同样原样保留
}
pd, err := NewPartialData(3, succeeded, nil, nil)
if err != nil {
t.Fatalf("NewPartialData: %v", err)
}
if len(pd.Succeeded) != len(succeeded) {
t.Fatalf("succeeded length = %d, want %d (no dropping)", len(pd.Succeeded), len(succeeded))
}
if !reflect.DeepEqual(pd.Succeeded, succeeded) {
t.Fatalf("succeeded details altered:\n got %+v\nwant %+v", pd.Succeeded, succeeded)
}
// wire 层同样完整:异构条目(object 与 string)均可解码回原样。
raw, err := json.Marshal(pd)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var generic struct {
Succeeded []any `json:"succeeded"`
}
if err := json.Unmarshal(raw, &generic); err != nil {
t.Fatalf("decode: %v\n%s", err, raw)
}
if len(generic.Succeeded) != 3 {
t.Fatalf("wire succeeded length = %d, want 3: %s", len(generic.Succeeded), raw)
}
first, _ := generic.Succeeded[0].(map[string]any)
if first == nil || first["messageId"] != "m_1" {
t.Fatalf("wire lost succeeded detail fields: %s", raw)
}
if generic.Succeeded[2] != "d-plain-string-entry" {
t.Fatalf("wire lost heterogeneous succeeded entry: %s", raw)
}
}
func TestPartialDataRejectsNilSucceededEntries(t *testing.T) {
// nil(null)条目混入明细即拒绝:完整保留意味着每条都是真实明细。
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}, nil}, nil, nil); err == nil {
t.Fatal("nil succeeded entry must be rejected (§2.3 full preservation)")
}
}
// --- B145:partial 装配样例——2 成 1 败 1 unknown(契约规范 §6 场景3)---
func TestPartialAssemblyExampleTwoOneOne(t *testing.T) {
// 场景3 行为端:ok:false / rc=7 映射 / stderr 空;三通道明细完整落 stdout。
pd, err := NewPartialData(4,
[]any{
map[string]any{"id": "a", "messageId": "m_1"},
map[string]any{"id": "b", "messageId": "m_2"},
},
[]PartialFailedEntry{{ID: "c", Error: &ErrorInfo{Type: "api", Code: 40001, Message: "invalid recipient"}}},
[]PartialUnknownEntry{{ID: "d", Reason: "timeout after submit"}},
)
if err != nil {
t.Fatalf("NewPartialData: %v", err)
}
env := NewPartialEnvelope(pd)
env.Meta = &Meta{Count: NewCount(4)}
// 不变量面:I1 ok==false(partial 非 ok)、I3 无顶层 error、Validate 通过。
if env.OK || env.Outcome != OutcomePartialFailure {
t.Fatalf("partial envelope = ok:%v outcome:%q, want false/partial_failure", env.OK, env.Outcome)
}
if env.Error != nil {
t.Fatalf("partial envelope must not carry top-level error (I3/§2.3)")
}
if err := env.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
// 出口面:明细落 stdout、stderr 零输出(§2.3)。
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(env); err != nil {
t.Fatalf("Emit partial: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("partial_failure must keep stderr empty (§2.3), got %d bytes: %s", stderr.Len(), stderr.String())
}
var decoded struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Data PartialData `json:"data"`
Meta struct {
Count *int `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
}
if decoded.OK {
t.Fatalf("partial ok = true, want false (§1): %s", stdout.String())
}
if decoded.Outcome != "partial_failure" {
t.Fatalf("outcome = %q: %s", decoded.Outcome, stdout.String())
}
if decoded.Data.Total != 4 || len(decoded.Data.Succeeded) != 2 ||
len(decoded.Data.Failed) != 1 || len(decoded.Data.Unknown) != 1 {
t.Fatalf("channel counts = %d/%d/%d/%d, want 4/2/1/1: %s",
decoded.Data.Total, len(decoded.Data.Succeeded), len(decoded.Data.Failed), len(decoded.Data.Unknown), stdout.String())
}
if decoded.Data.Failed[0].Error == nil || decoded.Data.Failed[0].Error.Type != "api" {
t.Fatalf("failed entry lost error detail: %s", stdout.String())
}
if decoded.Data.Unknown[0].Reason != "timeout after submit" {
t.Fatalf("unknown entry lost reason: %s", stdout.String())
}
if decoded.Meta.Count == nil || *decoded.Meta.Count != 4 {
t.Fatalf("meta.count = %v, want 4: %s", decoded.Meta.Count, stdout.String())
}
// rc=7 映射(§4 partial 专用码;ExitCodeForEnvelope 与 errors 侧同源见 B209)。
if code := ExitCodeForEnvelope(env); code != 7 {
t.Fatalf("partial exit code = %d, want dedicated 7 (§4)", code)
}
}
// --- B143 覆盖核实说明(不重复造测试)---
//
// partial 信封 → exit 7 的出口断言已由轮9 B34 落盘并评审通过:
// - emitter_phase_b_test.go TestInvariantI2ExhaustiveOverFourOutcomes:
// 「partial_failure 专用码精确断言(非零且恰为 7)」;
// - emitter_phase_b_test.go TestEmitterStreamRoutingConsistentWithExitCode:
// partial_failure 走 stdout 通道且 exit 7 的联动断言。
// 本文件 TestPartialAssemblyExampleTwoOneOne 在装配样例内顺带复核 rc=7,
// 仅为场景自洽,不构成新的覆盖重复主张。
+421
View File
@@ -0,0 +1,421 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
// Phase H:operation / pagination 装配助手(B147~B154;契约规范 §2.2/§3)。
// 落盘策略:轮8裁决⑩新文件——不编辑 envelope_test.go/emitter_test.go。
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// --- B150:operation.state 取值表测试 ---
func TestOperationStateValueTable(t *testing.T) {
// state 取值表(§2.2):规范三值 processing/completed/failed 是 Agent
// 必须能分支的最小集;开放枚举容忍业务特化阶段(如 importing、queued)。
// 表驱动:每个规范值装配合法、wire 原样、Validate 通过。
normative := []struct {
state string
// timed_out 共存合法性由 ValidateTimeoutState 把守,这里只表 state 装配。
}{
{OperationStateProcessing},
{OperationStateCompleted},
{OperationStateFailed},
}
if len(normative) != 3 {
t.Fatalf("operation.state normative table must cover exactly 3 values (§2.2), got %d", len(normative))
}
for _, tc := range normative {
t.Run("state="+tc.state, func(t *testing.T) {
op := &OperationInfo{ID: "t_1", State: tc.state, NextCommand: "dws op get t_1"}
env, err := NewPendingEnvelopeForPolling(op)
if err != nil {
t.Fatalf("NewPendingEnvelopeForPolling(state=%q): %v", tc.state, err)
}
raw, err := json.Marshal(env)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var decoded struct {
Meta struct {
Operation struct {
State string `json:"state"`
} `json:"operation"`
} `json:"meta"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode: %v\n%s", err, raw)
}
if decoded.Meta.Operation.State != tc.state {
t.Fatalf("wire state = %q, want %q: %s", decoded.Meta.Operation.State, tc.state, raw)
}
})
}
// 开放枚举:业务特化阶段不在规范表内但可装配(框架不解释语义,§9)。
op := &OperationInfo{ID: "t_2", State: "importing", NextCommand: "dws op get t_2"}
if _, err := NewPendingEnvelopeForPolling(op); err != nil {
t.Fatalf("specialized state must remain assemblable (open enum, §2.2): %v", err)
}
}
// --- B148:next_command 非空可执行断言 ---
func TestPollingPendingEnvelopeRequiresNonEmptyNextCommand(t *testing.T) {
// 轮询形态的恢复方式只有 next_command 一条出口(§2.2 规则2):
// 非空、无前后空白、单行——缺任一项即装配失败。
cases := []struct {
name string
op *OperationInfo
}{
{"nil operation", nil},
{"empty next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing}},
{"whitespace next_command", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " "}},
{"leading space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: " dws op get t_1"}},
{"trailing space", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1 "}},
{"multi-line", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\ndws op get t_1"}},
{"carriage return", &OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1\r"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env, err := NewPendingEnvelopeForPolling(tc.op)
if err == nil {
t.Fatalf("NewPendingEnvelopeForPolling(%+v) must fail (§2.2 rule 2), got %+v", tc.op, env)
}
})
}
// 合法形态:可执行单行命令装配通过。
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
if err != nil {
t.Fatalf("legal next_command rejected: %v", err)
}
if env.Meta == nil || env.Meta.Operation == nil || env.Meta.Operation.NextCommand != "dws op get t_1" {
t.Fatalf("assembled envelope lost next_command: %+v", env)
}
// ValidateNextCommand 对空串不拦截(裸 pending 信封允许无 operation 明细),
// 只对非空值做形态校验——严格性由装配助手把守。
if err := (&OperationInfo{}).ValidateNextCommand(); err != nil {
t.Fatalf("empty next_command must not be rejected by shape validator alone: %v", err)
}
}
// --- B149:timed_out:true 时 state 保留断言(§2.2 防伪装)---
func TestTimedOutKeepsRealState(t *testing.T) {
// §2.2 规则1(lark wiki_node_delete 教训):轮询超时必须保持 state 真实值
// 并置 timed_out:true;超时操作不得宣称完成态。
cases := []struct {
name string
op *OperationInfo
wantErr bool
}{
{"timed_out with real processing state",
&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"}, false},
{"timed_out with specialized real state",
&OperationInfo{ID: "t_1", State: "importing", TimedOut: true, NextCommand: "dws op get t_1"}, false},
{"timed_out with empty state",
&OperationInfo{ID: "t_1", TimedOut: true, NextCommand: "dws op get t_1"}, true},
{"timed_out claiming completed",
&OperationInfo{ID: "t_1", State: OperationStateCompleted, TimedOut: true, NextCommand: "dws op get t_1"}, true},
{"timed_out claiming success (case-insensitive)",
&OperationInfo{ID: "t_1", State: "SUCCESS", TimedOut: true, NextCommand: "dws op get t_1"}, true},
{"no timeout keeps completed legal",
&OperationInfo{ID: "t_1", State: OperationStateCompleted, NextCommand: "dws op get t_1"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.op.ValidateTimeoutState()
if tc.wantErr && err == nil {
t.Fatalf("ValidateTimeoutState(%+v) must reject (§2.2 anti-spoof)", tc.op)
}
if !tc.wantErr && err != nil {
t.Fatalf("ValidateTimeoutState(%+v) must accept: %v", tc.op, err)
}
})
}
// wire 层:timed_out:true 与真实 state 同现。
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, TimedOut: true, NextCommand: "dws op get t_1"})
if err != nil {
t.Fatalf("assembling timed_out envelope: %v", err)
}
raw, err := json.Marshal(env)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var decoded struct {
Meta struct {
Operation struct {
State string `json:"state"`
TimedOut bool `json:"timed_out"`
} `json:"operation"`
} `json:"meta"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode: %v\n%s", err, raw)
}
if decoded.Meta.Operation.State != OperationStateProcessing || !decoded.Meta.Operation.TimedOut {
t.Fatalf("wire must keep real state + timed_out:true (§2.2), got %+v: %s", decoded.Meta.Operation, raw)
}
}
// --- B147:NewPendingEnvelope 装配助手增强 / 轮询形态 ---
func TestPendingEnvelopeAssemblyAssistants(t *testing.T) {
// 宽松构造器(B19)保持构造宽松:无 op / 空 next_command 均可构造。
if env := NewPendingEnvelope(nil); env.Meta != nil || !env.OK || env.Outcome != OutcomePending {
t.Fatalf("NewPendingEnvelope(nil) = %+v, want bare pending", env)
}
if env := NewPendingEnvelope(&OperationInfo{ID: "t_1"}); env.Meta == nil || env.Meta.Operation.ID != "t_1" {
t.Fatalf("NewPendingEnvelope lost operation: %+v", env)
}
// 严格装配助手(B147):轮询形态必须携带 op + 可执行 next_command。
env, err := NewPendingEnvelopeForPolling(&OperationInfo{ID: "t_1", State: OperationStateProcessing, NextCommand: "dws op get t_1"})
if err != nil {
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
}
// I1 一致性:pending → ok:true。
if !env.OK || env.Outcome != OutcomePending {
t.Fatalf("polling pending envelope = ok:%v outcome:%q, want true/pending (I1)", env.OK, env.Outcome)
}
if err := env.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
// 出口面:pending 落 stdout、exit 0。
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(env); err != nil {
t.Fatalf("Emit: %v", err)
}
if stdout.Len() == 0 || stderr.Len() != 0 {
t.Fatalf("pending must route stdout (stdout=%d stderr=%d)", stdout.Len(), stderr.Len())
}
if code := ExitCodeForEnvelope(env); code != 0 {
t.Fatalf("pending exit code = %d, want 0 (I2)", code)
}
}
// --- B151:import_flow 字段映射断言 ---
func TestImportFlowOperationFieldMapping(t *testing.T) {
// 契约 §2.2 的 import_flow 场景映射:业务侧 task 终态 → operation 字段。
// 框架只断言映射后的 wire 形态(§9 语义归装配侧):
// task_id → operation.id
// status → operation.state
// timeout → operation.timed_out(超时时 true,且 state 保留真实值)
// 轮询命令 → operation.next_command
cases := []struct {
name string
taskID string
status string
timedOut bool
nextCmd string
wantTimed bool
wantState string
wantNext string
}{
{"running task", "task_01", "processing", false, "dws op get task_01", false, "processing", "dws op get task_01"},
{"timed-out task keeps real state", "task_02", "processing", true, "dws op get task_02", true, "processing", "dws op get task_02"},
{"completed task", "task_03", "completed", false, "dws op get task_03", false, "completed", "dws op get task_03"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
env, err := NewPendingEnvelopeForPolling(&OperationInfo{
ID: tc.taskID,
State: tc.status,
TimedOut: tc.timedOut,
NextCommand: tc.nextCmd,
})
if err != nil {
t.Fatalf("NewPendingEnvelopeForPolling: %v", err)
}
raw, err := json.Marshal(env)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var decoded struct {
Meta struct {
Operation struct {
ID string `json:"id"`
State string `json:"state"`
TimedOut bool `json:"timed_out"`
NextCommand string `json:"next_command"`
} `json:"operation"`
} `json:"meta"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode: %v\n%s", err, raw)
}
op := decoded.Meta.Operation
if op.ID != tc.taskID {
t.Fatalf("operation.id = %q, want task_id %q: %s", op.ID, tc.taskID, raw)
}
if op.State != tc.wantState {
t.Fatalf("operation.state = %q, want %q: %s", op.State, tc.wantState, raw)
}
if op.TimedOut != tc.wantTimed {
t.Fatalf("operation.timed_out = %v, want %v: %s", op.TimedOut, tc.wantTimed, raw)
}
if op.NextCommand != tc.wantNext {
t.Fatalf("operation.next_command = %q, want %q: %s", op.NextCommand, tc.wantNext, raw)
}
})
}
}
// --- B152:NewPagination 装配助手 ---
func TestNewPaginationTwoStateCompleteness(t *testing.T) {
// §3 两态完备:resumable(携带 next_token)与 exhausted(无 token)是
// 仅有的两个状态;不设 stop_reason。装配助手强制两态互斥完备。
cases := []struct {
name string
exhausted bool
token string
wantErr bool
}{
{"resumable with token", false, "tok_2", false},
{"exhausted without token", true, "", false},
{"resumable missing token", false, "", true},
{"exhausted carrying token", true, "tok_2", true},
{"resumable whitespace-only token", false, " ", true},
{"exhausted whitespace token accepted as empty", true, " ", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pg, err := NewPagination(tc.exhausted, tc.token)
if tc.wantErr {
if err == nil {
t.Fatalf("NewPagination(exhausted=%v, token=%q) must fail (§3)", tc.exhausted, tc.token)
}
return
}
if err != nil {
t.Fatalf("NewPagination(exhausted=%v, token=%q): %v", tc.exhausted, tc.token, err)
}
if pg.EndpointExhausted != tc.exhausted {
t.Fatalf("endpoint_exhausted = %v, want %v", pg.EndpointExhausted, tc.exhausted)
}
wantToken := strings.TrimSpace(tc.token)
if pg.NextToken != wantToken {
t.Fatalf("next_token = %q, want %q", pg.NextToken, wantToken)
}
})
}
}
// --- B153:Pagination pages/items 字段 ---
func TestPaginationPagesItemsFields(t *testing.T) {
// pages/items 为信息性计数(omitempty):设置后 wire 携带,未设置则缺席。
pg, err := NewPagination(false, "tok_2")
if err != nil {
t.Fatalf("NewPagination: %v", err)
}
pages, items := 2, 50
pg.Pages = pages
pg.Items = items
raw, err := json.Marshal(pg)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode: %v\n%s", err, raw)
}
if got, ok := decoded["pages"].(float64); !ok || got != 2 {
t.Fatalf("pages = %v, want 2: %s", decoded["pages"], raw)
}
if got, ok := decoded["items"].(float64); !ok || got != 50 {
t.Fatalf("items = %v, want 50: %s", decoded["items"], raw)
}
if decoded["next_token"] != "tok_2" || decoded["endpoint_exhausted"] != false {
t.Fatalf("pagination wire lost core fields: %s", raw)
}
// 未设置时 omitempty 缺席。
bare, err := NewPagination(true, "")
if err != nil {
t.Fatalf("NewPagination(exhausted): %v", err)
}
raw, err = json.Marshal(bare)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var bareDecoded map[string]any
if err := json.Unmarshal(raw, &bareDecoded); err != nil {
t.Fatalf("decode: %v\n%s", err, raw)
}
if _, present := bareDecoded["pages"]; present {
t.Fatalf("unset pages must be omitted: %s", raw)
}
if _, present := bareDecoded["items"]; present {
t.Fatalf("unset items must be omitted: %s", raw)
}
}
// --- B154:空结果 data:[] + count:0 + endpoint_exhausted 断言 ---
func TestEmptyResultSetWireAssertion(t *testing.T) {
// AC-06/§3:空结果合法形态 = data:[] + meta.count:0 +
// pagination.endpoint_exhausted:true。三要素齐备且 wire 原样。
pg, err := NewPagination(true, "")
if err != nil {
t.Fatalf("NewPagination: %v", err)
}
env := NewSuccessEnvelope([]any{})
env.Meta = &Meta{Count: NewCount(0), Pagination: pg}
if err := env.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
var stdout, stderr bytes.Buffer
em := NewEmitter(&stdout, &stderr, FormatJSON, "", "")
if err := em.Emit(env); err != nil {
t.Fatalf("Emit: %v", err)
}
var decoded struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Data []any `json:"data"`
Meta struct {
Count *int `json:"count"`
Pagination struct {
EndpointExhausted bool `json:"endpoint_exhausted"`
} `json:"pagination"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
}
if !decoded.OK || decoded.Outcome != "success" {
t.Fatalf("empty result = ok:%v outcome:%q, want true/success", decoded.OK, decoded.Outcome)
}
if decoded.Data == nil || len(decoded.Data) != 0 {
t.Fatalf("data must be [] (not null, not missing): %s", stdout.String())
}
if decoded.Meta.Count == nil || *decoded.Meta.Count != 0 {
t.Fatalf("meta.count = %v, want 0: %s", decoded.Meta.Count, stdout.String())
}
if !decoded.Meta.Pagination.EndpointExhausted {
t.Fatalf("empty result must mark endpoint_exhausted:true (§3): %s", stdout.String())
}
// raw 字符串层面:data 必须是 [] 而非 null。
if !strings.Contains(stdout.String(), `"data": []`) && !strings.Contains(stdout.String(), `"data":[]`) {
t.Fatalf("data must serialize as [] on the wire: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"data": null`) || strings.Contains(stdout.String(), `"data":null`) {
t.Fatalf("data must never be null (§2.5): %s", stdout.String())
}
}
File diff suppressed because it is too large. Load diff
+103 -11
View File
@@ -64,17 +64,116 @@ var (
) )
func ResolveFormat(cmd *cobra.Command, fallback Format) Format { func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
format, _ := resolveFormatWithWarning(cmd, fallback)
return format
}
// resolveFormatWithWarning 解析命令的 --format flag(B36,AC-09):已知值
// 归一化为对应 Format 常量;未知非空值**降级 fallback 并产出一条 warning
// 文本**(不崩不静默,契约规范 §5.2「未知值 → 降级 + stderr warning」)。
// warning 的写出由调用方负责(信封出口 WriteEnvelope 写 cmd.ErrOrStderr()),
// 本函数不做 I/O——ResolveFormat 的既有调用方(只读 format 值、不持有出口
// 语义)行为保持不变。flag 缺席或值为空返回 fallback 且不产生 warning
// (空值是「未指定」而非「未知」)。查找顺序与 ResolveFormat 一致:
// cmd.Flags() 优先于 InheritedFlags(),命中 format flag 即返回。
func resolveFormatWithWarning(cmd *cobra.Command, fallback Format) (Format, string) {
if cmd == nil {
return fallback, ""
}
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
value, ok := formatValueFromFlagSet(flags)
if !ok {
continue
}
format := normalizeFormat(value, fallback)
return format, unknownFormatWarning(value, format)
}
return fallback, ""
}
// formatValueFromFlagSet 返回 flagSet 上 --format flag 的原始字符串值
// (B35/B36/B43 共用的查找辅助)。flag 缺席或类型非 string 时返回 ok=false——
// 与 formatFromFlagSet 的容错口径一致(不报错、交由 fallback 兜底)。
func formatValueFromFlagSet(flags *pflag.FlagSet) (string, bool) {
if flags == nil || flags.Lookup("format") == nil {
return "", false
}
value, err := flags.GetString("format")
if err != nil {
return "", false
}
return value, true
}
// unknownFormatWarning 在 raw 为非空未知值(归一化后不等于 resolved,即不是
// 任何已知 format 的大小写/空白变体)时返回 warning 文本,否则返回空串
// (B36,AC-09 不崩不静默)。文本只陈述事实(未知值 + 降级目标),
// 不做过度承诺。
func unknownFormatWarning(raw string, resolved Format) string {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" || trimmed == string(resolved) {
return ""
}
return fmt.Sprintf("unknown --format %q, falling back to %q", raw, string(resolved))
}
// ResolveFormatWithJSONShorthand 解析命令的输出 format,实现 --json 简写语义
// (B43,契约规范 §5.2:「--json 自动注册为 --format json 简写(显式
// --format 优先)」)。优先级链:
//
// 1. 显式 --format(非空值)恒优先——含未知值(按 normalizeFormat 降级
// fallback,--json 不 rescues 未知值);
// 2. --format 缺席或为空时,--json 布尔 flag(Changed 且为 true)等价
// --format json;
// 3. 两者皆无 → fallback。
//
// --json 判定只接受 bool 型 flag(GetBool 成功):业务层存在同名 string
// flag(如 table create --json 载荷参数)的命令不会被误判为简写。本函数
// 纯判定不做 I/O,供不直接走 WriteEnvelope 的调用方(如主漏斗桥接,B44)
// 复用;与 resolveFormatWithWarning 共用 formatValueFromFlagSet /
// normalizeFormat,归一化规则单一事实源。
func ResolveFormatWithJSONShorthand(cmd *cobra.Command, fallback Format) Format {
if cmd == nil { if cmd == nil {
return fallback return fallback
} }
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} { for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
if format, ok := formatFromFlagSet(flags, fallback); ok { value, ok := formatValueFromFlagSet(flags)
return format if !ok {
continue
}
if strings.TrimSpace(value) != "" {
return normalizeFormat(value, fallback)
} }
} }
if jsonShorthandActive(cmd) {
return FormatJSON
}
return fallback return fallback
} }
// jsonShorthandActive 报告 cmd 上是否有生效的 --json 简写(bool flag 被显式
// 设置为 true)。查找 Flags / InheritedFlags / PersistentFlags 三组,与
// internal/app 侧 commandRequestsJSONErrors 的判定面一致;GetBool 失败
// (非 bool 同名 flag)跳过该组继续。
func jsonShorthandActive(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), cmd.PersistentFlags()} {
if flags == nil {
continue
}
flag := flags.Lookup("json")
if flag == nil || !flag.Changed {
continue
}
if value, err := flags.GetBool("json"); err == nil {
return value
}
}
return false
}
func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error { func WriteCommandPayload(cmd *cobra.Command, payload any, fallback Format) error {
if cmd == nil { if cmd == nil {
return Write(io.Discard, fallback, payload) return Write(io.Discard, fallback, payload)
@@ -127,15 +226,8 @@ func unwrapCompatRuntimePayload(payload any) any {
} }
func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) { func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) {
if flags == nil { value, ok := formatValueFromFlagSet(flags)
return fallback, false if !ok {
}
flag := flags.Lookup("format")
if flag == nil {
return fallback, false
}
value, err := flags.GetString("format")
if err != nil {
return fallback, false return fallback, false
} }
return normalizeFormat(value, fallback), true return normalizeFormat(value, fallback), true
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package output
import (
"encoding/json"
"fmt"
)
// MCPResult is the protocol-neutral projection consumed by MCP transports.
// Both CLI and MCP adapters start from the same immutable CommandResult; MCP
// handlers must not rebuild a second business envelope.
type MCPResult struct {
StructuredContent map[string]any `json:"structuredContent"`
IsError bool `json:"isError,omitempty"`
}
// AdaptMCP projects the exact unified envelope into MCP structuredContent. Failure
// and partial failure set isError, while pending remains a successfully
// accepted request whose terminal operation state is carried in the envelope.
func AdaptMCP(result CommandResult) (*MCPResult, error) {
env, err := EnvelopeFromResult(result)
if err != nil {
return nil, err
}
env = redactEnvelope(env)
raw, err := json.Marshal(env)
if err != nil {
return nil, fmt.Errorf("output: marshal MCP result: %w", err)
}
var structured map[string]any
if err := json.Unmarshal(raw, &structured); err != nil {
return nil, fmt.Errorf("output: decode MCP structured result: %w", err)
}
return &MCPResult{
StructuredContent: structured,
IsError: env.Outcome == OutcomeFailure || env.Outcome == OutcomePartialFailure,
}, nil
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package output
import (
"net/url"
"reflect"
"regexp"
"strings"
)
const redactedValue = "[REDACTED]"
var (
urlCredentialsPattern = regexp.MustCompile(`(?i)\b(https?://)[^/@\s]+@`)
urlQueryValuePattern = regexp.MustCompile(`(?i)[?&][^=&#\s]+=[^&#\s"'<>]+`)
headerSecretPattern = regexp.MustCompile(`(?im)\b(authorization|cookie|set-cookie)(\s*:\s*)[^\r\n]+`)
jsonSecretPattern = regexp.MustCompile(`(?i)("(?:authorization|token|access_token|refresh_token|client_secret|cookie|set-cookie|password)"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\s]+)`)
assignedSecretPattern = regexp.MustCompile(`(?i)\b(authorization|token|access_token|refresh_token|client_secret|cookie|set-cookie|password)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)`)
)
// redactEnvelope is the single pre-render boundary for framework-owned
// diagnostics. Business data remains authoritative: commands such as explicit
// credential reads intentionally return secrets and declare those fields in
// ToolSpec.result.sensitive_paths. Error, operation, and notice channels are
// never intentional secret-delivery surfaces and are always redacted.
func redactEnvelope(source *Envelope) *Envelope {
if source == nil {
return nil
}
out := cloneEnvelope(*source)
seen := make(map[redactVisit]struct{})
redactReflectValue(reflect.ValueOf(&out.Meta), "meta", seen)
redactReflectValue(reflect.ValueOf(&out.Error), "error", seen)
redactReflectValue(reflect.ValueOf(&out.Notice), "_notice", seen)
return &out
}
type redactVisit struct {
typ reflect.Type
ptr uintptr
}
func redactReflectValue(value reflect.Value, jsonKey string, seen map[redactVisit]struct{}) {
if !value.IsValid() {
return
}
if isSensitiveOutputKey(jsonKey) && setRedactedValue(value) {
return
}
switch value.Kind() {
case reflect.Interface:
if value.IsNil() {
return
}
copy := reflect.New(value.Elem().Type()).Elem()
copy.Set(value.Elem())
redactReflectValue(copy, jsonKey, seen)
value.Set(copy)
case reflect.Pointer:
if value.IsNil() {
return
}
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
if _, ok := seen[visit]; ok {
return
}
seen[visit] = struct{}{}
redactReflectValue(value.Elem(), jsonKey, seen)
case reflect.Map:
if value.IsNil() {
return
}
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
if _, ok := seen[visit]; ok {
return
}
seen[visit] = struct{}{}
iter := value.MapRange()
for iter.Next() {
key, item := iter.Key(), iter.Value()
copy := reflect.New(item.Type()).Elem()
copy.Set(item)
name := ""
if key.Kind() == reflect.String {
name = key.String()
}
redactReflectValue(copy, name, seen)
value.SetMapIndex(key, copy)
}
case reflect.Slice:
if value.IsNil() {
return
}
visit := redactVisit{typ: value.Type(), ptr: value.Pointer()}
if _, ok := seen[visit]; ok {
return
}
seen[visit] = struct{}{}
for i := 0; i < value.Len(); i++ {
redactReflectValue(value.Index(i), "", seen)
}
case reflect.Array:
for i := 0; i < value.Len(); i++ {
redactReflectValue(value.Index(i), "", seen)
}
case reflect.Struct:
typ := value.Type()
for i := 0; i < value.NumField(); i++ {
fieldInfo := typ.Field(i)
if fieldInfo.PkgPath != "" || fieldInfo.Tag.Get("json") == "-" {
continue
}
name := strings.Split(fieldInfo.Tag.Get("json"), ",")[0]
if name == "" {
name = fieldInfo.Name
}
redactReflectValue(value.Field(i), name, seen)
}
case reflect.String:
if value.CanSet() {
value.SetString(redactRecognizableSecrets(value.String()))
}
}
}
func setRedactedValue(value reflect.Value) bool {
if !value.CanSet() {
return false
}
switch value.Kind() {
case reflect.String:
value.SetString(redactedValue)
return true
case reflect.Interface:
replacement := reflect.ValueOf(redactedValue)
if replacement.Type().AssignableTo(value.Type()) || replacement.Type().Implements(value.Type()) {
value.Set(replacement)
return true
}
case reflect.Pointer:
replacement := reflect.New(value.Type().Elem())
if setRedactedValue(replacement.Elem()) {
value.Set(replacement)
return true
}
}
return false
}
func isSensitiveOutputKey(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "authorization", "token", "access_token", "refresh_token", "client_secret", "cookie", "set-cookie", "password":
return true
default:
return false
}
}
func redactRecognizableSecrets(text string) string {
text = urlCredentialsPattern.ReplaceAllString(text, `${1}`+redactedValue+`@`)
text = urlQueryValuePattern.ReplaceAllStringFunc(text, func(parameter string) string {
equals := strings.IndexByte(parameter, '=')
if equals < 2 {
return parameter
}
key, err := url.QueryUnescape(parameter[1:equals])
if err != nil || !isSensitiveQueryKey(key) {
return parameter
}
return parameter[:equals+1] + redactedValue
})
text = headerSecretPattern.ReplaceAllString(text, `${1}${2}`+redactedValue)
text = jsonSecretPattern.ReplaceAllString(text, `${1}"`+redactedValue+`"`)
return assignedSecretPattern.ReplaceAllString(text, `${1}${2}`+redactedValue)
}
func isSensitiveQueryKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
return isSensitiveOutputKey(normalized) || strings.Contains(normalized, "signature")
}
+180
View File
@@ -0,0 +1,180 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package output
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
type redactionFixture struct {
Password string `json:"password"`
ClientID string `json:"client_id"`
RequestID string `json:"request_id"`
Nested map[string]any `json:"nested"`
}
func TestWriteEnvelopePreservesDeclaredBusinessDataAndRedactsFrameworkChannels(t *testing.T) {
data := &redactionFixture{
Password: "data-password-canary",
ClientID: "client-id-canary",
RequestID: "request-id-canary",
Nested: map[string]any{
"ToKeN": "nested-token-canary",
"items": []any{
map[string]string{"access_token": "slice-token-canary"},
"https://user:url-password-canary@example.test/path?x=usable&x-oss-signature=url-signature-canary",
},
},
}
notice := map[string]any{"cookie": "notice-cookie-canary", "message": "token=notice-text-canary"}
env := &Envelope{
OK: true,
Outcome: OutcomeSuccess,
Data: data,
Meta: &Meta{Operation: &OperationInfo{
ID: "task-id-canary",
State: OperationStateProcessing,
NextCommand: "dws op get 'https://example.test/poll?token=next-command-canary&safe=yes'",
}},
Notice: notice,
}
originalData := cloneResultData(data)
originalNotice := cloneResultData(notice)
var buf bytes.Buffer
if err := WriteEnvelopeTo(&buf, env, FormatJSON, "", ""); err != nil {
t.Fatalf("WriteEnvelopeTo: %v", err)
}
output := buf.String()
for _, secret := range []string{
"notice-cookie-canary", "notice-text-canary", "next-command-canary",
} {
if strings.Contains(output, secret) {
t.Errorf("output leaked %q: %s", secret, output)
}
}
for _, retained := range []string{
"data-password-canary", "nested-token-canary", "slice-token-canary",
"url-password-canary", "url-signature-canary", "client-id-canary",
"request-id-canary", "task-id-canary", "x=usable", "safe=yes",
} {
if !strings.Contains(output, retained) {
t.Errorf("output over-redacted %q: %s", retained, output)
}
}
if got := strings.Count(output, redactedValue); got < 3 {
t.Errorf("redaction placeholder count = %d, want at least 3: %s", got, output)
}
if !reflect.DeepEqual(data, originalData) || !reflect.DeepEqual(notice, originalNotice) {
t.Fatalf("redaction mutated caller values\ndata: %#v\nnotice: %#v", data, notice)
}
if err := env.Validate(); err != nil {
t.Fatalf("caller envelope became invalid: %v", err)
}
}
func TestEmitResultRedactsEveryErrorInfoCanary(t *testing.T) {
info := &ErrorInfo{
Type: "api",
Message: "Authorization: Bearer message-auth-canary",
Hint: "use https://hint-user:hint-pass-canary@example.test/help?access_token=hint-query-canary&doc=usable",
Details: map[string]any{"PASSWORD": "details-password-canary"},
RPCData: map[string]any{"client_secret": "rpc-secret-canary", "task_id": "rpc-task-id-canary"},
TechnicalDetail: `upstream {"refresh_token":"technical-token-canary"}`,
FriendlyHint: "cookie=friendly-cookie-canary",
ActionURL: "https://example.test/retry?signature=action-signature-canary&step=2",
Cause: "token:cause-token-canary",
Actions: []string{"dws retry --url 'https://example.test/run?x-oss-signature=actions-signature-canary&mode=safe'"},
RequestID: "error-request-id-canary",
}
original := cloneErrorInfo(info)
result := Failure(info)
cmd := &cobra.Command{Use: "test"}
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
code, err := EmitResult(cmd, result)
if err != nil {
t.Fatalf("EmitResult: %v", err)
}
if code != exitCodeAPI {
t.Fatalf("exit code = %d, want %d", code, exitCodeAPI)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
output := stdout.String()
for _, secret := range []string{
"message-auth-canary", "hint-pass-canary", "hint-query-canary",
"details-password-canary", "rpc-secret-canary", "technical-token-canary",
"friendly-cookie-canary", "action-signature-canary", "cause-token-canary",
"actions-signature-canary",
} {
if strings.Contains(output, secret) {
t.Errorf("output leaked %q: %s", secret, output)
}
}
for _, retained := range []string{"error-request-id-canary", "rpc-task-id-canary", "doc=usable", "step=2", "mode=safe"} {
if !strings.Contains(output, retained) {
t.Errorf("output over-redacted %q: %s", retained, output)
}
}
var decoded Envelope
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("output is not a typed envelope: %v", err)
}
if err := decoded.Validate(); err != nil {
t.Fatalf("redacted envelope is invalid: %v", err)
}
if !reflect.DeepEqual(info, original) {
t.Fatalf("EmitResult mutated ErrorInfo\ngot: %#v\nwant: %#v", info, original)
}
}
func TestEmitResultHumanFailureRedactsBeforeShortcutRendering(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("format", "table", "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
_, err := EmitResult(cmd, Failure(&ErrorInfo{Type: "api", Message: "password=human-failure-canary"}))
if err != nil {
t.Fatalf("EmitResult: %v", err)
}
if strings.Contains(stderr.String(), "human-failure-canary") || !strings.Contains(stderr.String(), redactedValue) {
t.Fatalf("human failure was not redacted: %q", stderr.String())
}
}
func TestAdaptMCPPreservesBusinessData(t *testing.T) {
data := map[string]any{
"authorization": "mcp-auth-canary",
"client_id": "mcp-client-id-canary",
}
result := Success(data)
mcpResult, err := AdaptMCP(result)
if err != nil {
t.Fatalf("AdaptMCP: %v", err)
}
raw, err := json.Marshal(mcpResult)
if err != nil {
t.Fatalf("marshal MCPResult: %v", err)
}
if !strings.Contains(string(raw), "mcp-auth-canary") {
t.Fatalf("MCP business data was unexpectedly redacted: %s", raw)
}
if !strings.Contains(string(raw), "mcp-client-id-canary") {
t.Fatalf("MCP output over-redacted client_id: %s", raw)
}
if data["authorization"] != "mcp-auth-canary" {
t.Fatalf("AdaptMCP mutated caller data: %#v", data)
}
}
+434
View File
@@ -0,0 +1,434 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package output
import (
"context"
"fmt"
"reflect"
"strings"
"sync"
"github.com/spf13/cobra"
)
// CommandResult is the immutable framework result consumed by CLI and MCP
// adapters. The unexported envelope method prevents product packages from
// implementing ad-hoc result shapes.
type CommandResult interface {
Outcome() Outcome
ExitCode() int
envelope() *Envelope
}
type commandResult struct {
env Envelope
exitCode int
exitCodeOverride bool
}
func (r *commandResult) Outcome() Outcome { return r.env.Outcome }
func (r *commandResult) ExitCode() int { return r.exitCode }
func (r *commandResult) envelope() *Envelope {
copy := cloneEnvelope(r.env)
return &copy
}
// ResultOption enriches a result without exposing mutable framework fields.
type ResultOption struct{ apply func(*Envelope) }
func WithIdentity(identity string) ResultOption {
return ResultOption{apply: func(env *Envelope) { env.Identity = identity }}
}
func WithDryRun() ResultOption {
return ResultOption{apply: func(env *Envelope) { env.DryRun = true }}
}
func WithMeta(meta *Meta) ResultOption {
return ResultOption{apply: func(env *Envelope) { env.Meta = cloneMeta(meta) }}
}
// Success constructs an immutable success result.
func Success(data any, opts ...ResultOption) CommandResult {
return newCommandResult(OutcomeSuccess, data, nil, opts...)
}
// Pending constructs an immutable accepted-but-not-terminal result.
func Pending(data any, operation *OperationInfo, opts ...ResultOption) CommandResult {
opts = append(opts, ResultOption{apply: func(env *Envelope) {
if env.Meta == nil {
env.Meta = &Meta{}
}
env.Meta.Operation = operation
}})
return newCommandResult(OutcomePending, data, nil, opts...)
}
// Partial constructs an immutable multi-status result with exit code 7.
func Partial(data *PartialData, opts ...ResultOption) CommandResult {
return newCommandResult(OutcomePartialFailure, data, nil, opts...)
}
// Failure constructs an immutable typed failure result.
func Failure(info *ErrorInfo, opts ...ResultOption) CommandResult {
if info != nil {
info = cloneErrorInfo(info)
// Product code does not own process status. The framework derives it
// from type/subtype so wire and process status cannot drift.
info.ExitCode = 0
}
return newCommandResult(OutcomeFailure, nil, info, opts...)
}
// FailureWithExitCode adapts an existing repository error whose historical
// process status is part of a compatibility contract (for example SIGINT=130).
// Product handlers must use Failure; only the root legacy-error adapter should
// call this function.
func FailureWithExitCode(info *ErrorInfo, exitCode int, opts ...ResultOption) CommandResult {
if info != nil {
info = cloneErrorInfo(info)
info.ExitCode = 0
}
return newCommandResultWithExitCode(OutcomeFailure, nil, info, exitCode, true, opts...)
}
func newCommandResult(outcome Outcome, data any, info *ErrorInfo, opts ...ResultOption) CommandResult {
return newCommandResultWithExitCode(outcome, data, info, 0, false, opts...)
}
func newCommandResultWithExitCode(outcome Outcome, data any, info *ErrorInfo, override int, hasOverride bool, opts ...ResultOption) CommandResult {
env := Envelope{Outcome: outcome, Data: data, Error: info}
env.OK = outcome == OutcomeSuccess || outcome == OutcomePending
for _, opt := range opts {
if opt.apply != nil {
opt.apply(&env)
}
}
exitCode := ExitCodeForEnvelope(&env)
if hasOverride && override > 0 {
exitCode = override
}
if env.Error != nil {
env.Error.ExitCode = exitCode
}
env = cloneEnvelope(env)
return &commandResult{env: env, exitCode: exitCode, exitCodeOverride: hasOverride}
}
func cloneEnvelope(source Envelope) Envelope {
out := source
out.Meta = cloneMeta(source.Meta)
out.Error = cloneErrorInfo(source.Error)
out.Data = cloneResultData(source.Data)
out.Notice = cloneResultData(source.Notice)
if partial, ok := source.Data.(*PartialData); ok && partial != nil {
copyPartial := &PartialData{
Total: partial.Total,
Succeeded: make([]any, len(partial.Succeeded)),
Failed: make([]PartialFailedEntry, len(partial.Failed)),
Unknown: make([]PartialUnknownEntry, len(partial.Unknown)),
}
copy(copyPartial.Failed, partial.Failed)
copy(copyPartial.Unknown, partial.Unknown)
for i := range partial.Succeeded {
copyPartial.Succeeded[i] = cloneResultData(partial.Succeeded[i])
}
for i := range copyPartial.Failed {
copyPartial.Failed[i].Error = cloneErrorInfo(copyPartial.Failed[i].Error)
}
out.Data = copyPartial
}
return out
}
func cloneMeta(source *Meta) *Meta {
if source == nil {
return nil
}
meta := *source
if source.Count != nil {
count := *source.Count
meta.Count = &count
}
if source.Operation != nil {
op := *source.Operation
meta.Operation = &op
}
if source.Pagination != nil {
pagination := *source.Pagination
meta.Pagination = &pagination
}
return &meta
}
func cloneResultData(value any) any {
if value == nil {
return nil
}
return cloneReflectValue(reflect.ValueOf(value), make(map[cloneVisit]reflect.Value)).Interface()
}
type cloneVisit struct {
typ reflect.Type
ptr uintptr
len int
cap int
}
func cloneReflectValue(value reflect.Value, seen map[cloneVisit]reflect.Value) reflect.Value {
if !value.IsValid() {
return value
}
switch value.Kind() {
case reflect.Interface:
if value.IsNil() {
return reflect.Zero(value.Type())
}
cloned := cloneReflectValue(value.Elem(), seen)
out := reflect.New(value.Type()).Elem()
out.Set(cloned)
return out
case reflect.Pointer:
if value.IsNil() {
return reflect.Zero(value.Type())
}
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer()}
if cloned, ok := seen[visit]; ok {
return cloned
}
out := reflect.New(value.Type().Elem())
seen[visit] = out
out.Elem().Set(cloneReflectValue(value.Elem(), seen))
return out
case reflect.Map:
if value.IsNil() {
return reflect.Zero(value.Type())
}
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer()}
if cloned, ok := seen[visit]; ok {
return cloned
}
out := reflect.MakeMapWithSize(value.Type(), value.Len())
seen[visit] = out
iter := value.MapRange()
for iter.Next() {
out.SetMapIndex(cloneReflectValue(iter.Key(), seen), cloneReflectValue(iter.Value(), seen))
}
return out
case reflect.Slice:
if value.IsNil() {
return reflect.Zero(value.Type())
}
visit := cloneVisit{typ: value.Type(), ptr: value.Pointer(), len: value.Len(), cap: value.Cap()}
if cloned, ok := seen[visit]; ok {
return cloned
}
out := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
seen[visit] = out
for i := 0; i < value.Len(); i++ {
out.Index(i).Set(cloneReflectValue(value.Index(i), seen))
}
return out
case reflect.Array:
out := reflect.New(value.Type()).Elem()
for i := 0; i < value.Len(); i++ {
out.Index(i).Set(cloneReflectValue(value.Index(i), seen))
}
return out
case reflect.Struct:
out := reflect.New(value.Type()).Elem()
out.Set(value)
for i := 0; i < value.NumField(); i++ {
if value.Type().Field(i).PkgPath == "" && out.Field(i).CanSet() {
out.Field(i).Set(cloneReflectValue(value.Field(i), seen))
}
}
return out
default:
return value
}
}
func cloneErrorInfo(source *ErrorInfo) *ErrorInfo {
if source == nil {
return nil
}
out := *source
out.UpstreamCode = cloneResultData(source.UpstreamCode)
out.Params = append([]string(nil), source.Params...)
out.Actions = append([]string(nil), source.Actions...)
out.AvailableFlags = append([]string(nil), source.AvailableFlags...)
if source.ExecutionStarted != nil {
started := *source.ExecutionStarted
out.ExecutionStarted = &started
}
if source.Details != nil {
out.Details = cloneResultData(source.Details).(map[string]any)
}
out.RPCData = cloneResultData(source.RPCData)
if source.RetryAfterSeconds != nil {
seconds := *source.RetryAfterSeconds
out.RetryAfterSeconds = &seconds
}
return &out
}
// ValidateResult is the mandatory pre-emission policy boundary.
func ValidateResult(result CommandResult) error {
if result == nil {
return fmt.Errorf("output: nil command result")
}
env := result.envelope()
if err := env.Validate(); err != nil {
return err
}
if env.Outcome == OutcomePending {
if env.Meta == nil || env.Meta.Operation == nil {
return fmt.Errorf("output: pending result requires meta.operation")
}
op := env.Meta.Operation
if strings.TrimSpace(op.ID) == "" {
return fmt.Errorf("output: pending result requires operation.id")
}
if strings.TrimSpace(op.State) == "" {
return fmt.Errorf("output: pending result requires operation.state")
}
if strings.TrimSpace(op.NextCommand) == "" {
return fmt.Errorf("output: pending result requires operation.next_command")
}
}
if env.Outcome == OutcomePartialFailure {
if partial, ok := env.Data.(*PartialData); !ok || partial == nil {
return fmt.Errorf("output: partial_failure result requires typed *PartialData")
}
}
concrete, _ := result.(*commandResult)
want := ExitCodeForEnvelope(env)
if concrete == nil || !concrete.exitCodeOverride {
if got := result.ExitCode(); got != want {
return fmt.Errorf("output: result exit code=%d disagrees with outcome-derived code=%d", got, want)
}
} else if result.ExitCode() <= 0 {
return fmt.Errorf("output: explicit compatibility exit code must be positive")
}
if env.Error != nil && env.Error.ExitCode != result.ExitCode() {
return fmt.Errorf("output: error.exit_code=%d disagrees with process exit code=%d", env.Error.ExitCode, result.ExitCode())
}
return nil
}
// ResultStore transfers a successful in-memory result through Cobra without
// pretending it is an error. The root PersistentPostRunE emits the result
// before closing output sinks, so normal cleanup always runs.
type ResultStore struct {
mu sync.Mutex
result CommandResult
emitAttempted bool
bytesRisk bool
emitted bool
exitCode int
}
type resultStoreContextKey struct{}
func WithResultStore(ctx context.Context) (context.Context, *ResultStore) {
if ctx == nil {
ctx = context.Background()
}
if store, ok := resultStoreFromContext(ctx); ok {
return ctx, store
}
store := &ResultStore{}
return context.WithValue(ctx, resultStoreContextKey{}, store), store
}
func resultStoreFromContext(ctx context.Context) (*ResultStore, bool) {
if ctx == nil {
return nil, false
}
store, ok := ctx.Value(resultStoreContextKey{}).(*ResultStore)
return store, ok && store != nil
}
// StoreResult records exactly one terminal framework result for this command.
func StoreResult(ctx context.Context, result CommandResult) error {
if err := ValidateResult(result); err != nil {
return err
}
store, ok := resultStoreFromContext(ctx)
if !ok {
return fmt.Errorf("output: command context has no result store; install output.WithResultStore at the root execution boundary before calling StoreResult")
}
store.mu.Lock()
defer store.mu.Unlock()
if store.result != nil {
return fmt.Errorf("output: command produced more than one framework result")
}
store.result = result
return nil
}
// EmitStoredResult is called from PersistentPostRunE before output cleanup.
func EmitStoredResult(cmd *cobra.Command) (int, bool, error) {
if cmd == nil {
return 0, false, nil
}
store, ok := resultStoreFromContext(cmd.Context())
if !ok {
return 0, false, nil
}
store.mu.Lock()
defer store.mu.Unlock()
if store.result == nil || store.emitAttempted {
return store.exitCode, store.emitted, nil
}
if !UsesUnifiedResult(cmd) {
return 0, false, fmt.Errorf("output: legacy command %q produced a unified framework result", cmd.CommandPath())
}
store.emitAttempted = true
store.exitCode = store.result.ExitCode()
code, bytesRisk, err := emitResult(cmd, store.result)
store.bytesRisk = bytesRisk
if err != nil {
// Never report the original success code after an output failure. Do not
// attempt a second envelope: an io.Writer may have consumed bytes even
// when it reported n == 0 or returned a rendering-adjacent error.
store.exitCode = exitCodeInternal
return store.exitCode, false, err
}
store.exitCode = code
store.emitted = true
return code, true, nil
}
// StoredEmissionState reports whether result emission was attempted. Once an
// attempt starts, callers must not try another envelope because a writer may
// have accepted bytes even when it returned an error.
func StoredEmissionState(store *ResultStore) (exitCode int, attempted, emitted, bytesRisk bool) {
if store == nil {
return 0, false, false, false
}
store.mu.Lock()
defer store.mu.Unlock()
return store.exitCode, store.emitAttempted, store.emitted, store.bytesRisk
}
// StoredExitCode returns the code produced by PersistentPostRunE.
func StoredExitCode(store *ResultStore) (int, bool) {
if store == nil {
return 0, false
}
store.mu.Lock()
defer store.mu.Unlock()
return store.exitCode, store.emitted
}
// EnvelopeFromResult returns a detached envelope copy for adapters/tests.
func EnvelopeFromResult(result CommandResult) (*Envelope, error) {
if err := ValidateResult(result); err != nil {
return nil, err
}
return result.envelope(), nil
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package output
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// ContractMode is an internal renderer identity. It is deliberately not a CLI
// flag: one command has exactly one externally active contract in one release.
type ContractMode string
const (
ContractLegacy ContractMode = "legacy"
// ContractUnified is the single framework-owned result shape. It is not a
// consumer-selectable protocol version: a command either still renders its
// historical legacy shape or has been promoted to the unified result.
ContractUnified ContractMode = "unified"
)
// RolloutState is internal release metadata. Consumers never select it. A
// command advances through validation and activation; rollback is performed by
// changing the command declaration/release, not by changing Agent argv.
type RolloutState string
const (
RolloutLegacyOnly RolloutState = "legacy_only"
RolloutDualValidate RolloutState = "dual_validate"
RolloutUnifiedActive RolloutState = "unified_active"
RolloutUnifiedStable RolloutState = "unified_stable"
RolloutUnifiedOnly RolloutState = "unified_only"
)
const rolloutAnnotation = "dws.output.rollout"
func ParseRolloutState(raw string) (RolloutState, error) {
state := RolloutState(strings.TrimSpace(raw))
switch state {
case RolloutLegacyOnly, RolloutDualValidate, RolloutUnifiedActive, RolloutUnifiedStable, RolloutUnifiedOnly:
return state, nil
default:
return "", fmt.Errorf("invalid output rollout state %q", raw)
}
}
// ValidateRolloutTransition defines the release order for rollout tooling. It
// is not an end-user capability. CI/ledger enforcement is wired separately.
func ValidateRolloutTransition(from, to RolloutState, rollback bool) error {
if _, err := ParseRolloutState(string(from)); err != nil {
return err
}
if _, err := ParseRolloutState(string(to)); err != nil {
return err
}
fromRank, toRank := rolloutRank(from), rolloutRank(to)
if fromRank == toRank || toRank == fromRank+1 {
return nil
}
if rollback && toRank < fromRank {
return nil
}
if toRank < fromRank {
return fmt.Errorf("output rollout transition %s -> %s is a rollback and requires explicit rollback approval", from, to)
}
return fmt.Errorf("output rollout transition %s -> %s skips intermediate states", from, to)
}
func rolloutRank(state RolloutState) int {
switch state {
case RolloutLegacyOnly:
return 0
case RolloutDualValidate:
return 1
case RolloutUnifiedActive:
return 2
case RolloutUnifiedStable:
return 3
case RolloutUnifiedOnly:
return 4
default:
return -1
}
}
func SetCommandRollout(cmd *cobra.Command, state RolloutState) {
if cmd == nil {
return
}
if _, err := ParseRolloutState(string(state)); err != nil {
panic(err)
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[rolloutAnnotation] = string(state)
}
// CommandRollout fails closed. Merely linking Framework 2.0 into the binary
// cannot change an undeclared command's wire contract.
func CommandRollout(cmd *cobra.Command) RolloutState {
if cmd != nil && cmd.Annotations != nil {
if state, err := ParseRolloutState(cmd.Annotations[rolloutAnnotation]); err == nil {
return state
}
}
return RolloutLegacyOnly
}
// ActiveContract is the only contract exposed by a command in this release.
func ActiveContract(cmd *cobra.Command) ContractMode {
switch CommandRollout(cmd) {
case RolloutUnifiedActive, RolloutUnifiedStable, RolloutUnifiedOnly:
return ContractUnified
default:
return ContractLegacy
}
}
func UsesUnifiedResult(cmd *cobra.Command) bool { return ActiveContract(cmd) == ContractUnified }
// ValidateUnifiedFormat is retained for compatibility. All commands normalize an
// unknown presentation value to their fallback and emit a diagnostic warning.
func ValidateUnifiedFormat(cmd *cobra.Command) error {
return nil
}
+222
View File
@@ -0,0 +1,222 @@
package output
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestValidateRolloutTransition(t *testing.T) {
cases := []struct {
from, to RolloutState
rollback bool
wantErr bool
}{
{RolloutLegacyOnly, RolloutDualValidate, false, false},
{RolloutDualValidate, RolloutUnifiedActive, false, false},
{RolloutUnifiedActive, RolloutUnifiedStable, false, false},
{RolloutUnifiedStable, RolloutUnifiedOnly, false, false},
{RolloutLegacyOnly, RolloutUnifiedActive, false, true},
{RolloutUnifiedStable, RolloutUnifiedActive, false, true},
{RolloutUnifiedStable, RolloutUnifiedActive, true, false},
}
for _, tc := range cases {
if err := ValidateRolloutTransition(tc.from, tc.to, tc.rollback); (err != nil) != tc.wantErr {
t.Fatalf("ValidateRolloutTransition(%s,%s,rollback=%v) err=%v, wantErr=%v", tc.from, tc.to, tc.rollback, err, tc.wantErr)
}
}
}
func TestAdaptMCPUsesSameEnvelope(t *testing.T) {
result := Success(map[string]any{"id": "a"})
mcp, err := AdaptMCP(result)
if err != nil {
t.Fatal(err)
}
if mcp.IsError {
t.Fatal("success must not set MCP isError")
}
if _, exists := mcp.StructuredContent["contract_version"]; exists {
t.Fatalf("structured content must not expose contract_version: %#v", mcp.StructuredContent)
}
if got := mcp.StructuredContent["outcome"]; got != string(OutcomeSuccess) {
t.Fatalf("outcome=%v", got)
}
}
func TestCommandResultDetachesMutableFrameworkPayload(t *testing.T) {
payload := map[string]any{"nested": map[string]any{"value": "before"}}
result := Success(payload)
payload["nested"].(map[string]any)["value"] = "after"
env, err := EnvelopeFromResult(result)
if err != nil {
t.Fatal(err)
}
got := env.Data.(map[string]any)["nested"].(map[string]any)["value"]
if got != "before" {
t.Fatalf("detached payload value=%v, want before", got)
}
}
func TestFailureExitCodeIsFrameworkDerived(t *testing.T) {
result := Failure(&ErrorInfo{Type: "validation", ExitCode: 99, Message: "bad input"})
if result.ExitCode() != 3 {
t.Fatalf("normal failure exit code=%d, want framework validation rc=3", result.ExitCode())
}
env, err := EnvelopeFromResult(result)
if err != nil {
t.Fatal(err)
}
if env.Error == nil || env.Error.ExitCode != 3 {
t.Fatalf("wire error=%+v, want exit_code=3", env.Error)
}
}
func TestRootCompatibilityAdapterCanPreserveSignalExitCode(t *testing.T) {
result := FailureWithExitCode(&ErrorInfo{Type: "internal", Message: "cancelled"}, 130)
if err := ValidateResult(result); err != nil {
t.Fatalf("ValidateResult: %v", err)
}
if result.ExitCode() != 130 {
t.Fatalf("compatibility exit code=%d, want 130", result.ExitCode())
}
env, _ := EnvelopeFromResult(result)
if env.Error.ExitCode != 130 {
t.Fatalf("wire exit_code=%d, want 130", env.Error.ExitCode)
}
}
func TestValidateResultRejectsMalformedPendingPartialAndPagination(t *testing.T) {
cases := []struct {
name string
result CommandResult
}{
{"pending nil operation", Pending(map[string]any{"id": "x"}, nil)},
{"pending empty operation", Pending(nil, &OperationInfo{})},
{"partial nil", Partial(nil)},
{"pagination exhausted with token", Success([]any{}, WithMeta(&Meta{Pagination: &Pagination{EndpointExhausted: true, NextToken: "next"}}))},
{"pagination open without token", Success([]any{}, WithMeta(&Meta{Pagination: &Pagination{EndpointExhausted: false}}))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateResult(tc.result); err == nil {
t.Fatalf("ValidateResult(%s) succeeded", tc.name)
}
})
}
}
func TestPartialRequiresTypedPerItemError(t *testing.T) {
for _, entry := range []PartialFailedEntry{
{ID: "b"},
{ID: "b", Error: &ErrorInfo{}},
} {
if _, err := NewPartialData(2, []any{map[string]any{"id": "a"}}, []PartialFailedEntry{entry}, nil); err == nil {
t.Fatalf("NewPartialData accepted malformed failed entry: %+v", entry)
}
}
}
func TestEmitResultUnknownFormatDegradesToJSONWithWarning(t *testing.T) {
cmd := &cobra.Command{Use: "sample"}
stdout, stderr := new(bytes.Buffer), new(bytes.Buffer)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
cmd.Flags().String("format", "bogus", "")
SetCommandRollout(cmd, RolloutUnifiedActive)
code, err := EmitResult(cmd, Success(map[string]any{"id": "a"}))
if err != nil || code != 0 {
t.Fatalf("EmitResult code=%d err=%v, want successful JSON fallback", code, err)
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) {
t.Fatalf("fallback output is not a success envelope: %q", stdout.String())
}
if !strings.Contains(stderr.String(), "[WARN]") || !strings.Contains(stderr.String(), "bogus") {
t.Fatalf("fallback warning missing: %q", stderr.String())
}
}
func TestPendingWithMetaDoesNotMutateCallerMetadata(t *testing.T) {
callerOperation := &OperationInfo{ID: "original", State: "waiting", NextCommand: "dws original"}
meta := &Meta{Operation: callerOperation}
want := &OperationInfo{ID: "new", State: "processing", NextCommand: "dws status"}
result := Pending(nil, want, WithMeta(meta))
if meta.Operation != callerOperation || meta.Operation.ID != "original" {
t.Fatalf("Pending mutated caller metadata: %+v", meta.Operation)
}
env, err := EnvelopeFromResult(result)
if err != nil {
t.Fatal(err)
}
if env.Meta.Operation.ID != "new" {
t.Fatalf("result operation=%+v, want new operation", env.Meta.Operation)
}
}
type writeThenError struct {
accepted int
writes int
full bool
}
func (w *writeThenError) Write(p []byte) (int, error) {
w.writes++
n := len(p)
if !w.full {
n /= 2
}
w.accepted += n
return n, io.ErrClosedPipe
}
func TestEmitStoredResultRecordsAttemptAndByteRiskOnWriteError(t *testing.T) {
for _, full := range []bool{false, true} {
t.Run(fmt.Sprintf("full=%t", full), func(t *testing.T) {
ctx, store := WithResultStore(context.Background())
cmd := &cobra.Command{Use: "sample"}
SetCommandRollout(cmd, RolloutUnifiedActive)
writer := &writeThenError{full: full}
cmd.SetOut(writer)
cmd.SetErr(io.Discard)
cmd.SetContext(ctx)
if err := StoreResult(ctx, Success(map[string]any{"id": "a"})); err != nil {
t.Fatal(err)
}
if _, _, err := EmitStoredResult(cmd); err == nil {
t.Fatal("write error was not returned")
}
code, attempted, emitted, bytesRisk := StoredEmissionState(store)
if code != exitCodeInternal || !attempted || emitted || !bytesRisk {
t.Fatalf("state=(%d,%t,%t,%t), want (%d,true,false,true)", code, attempted, emitted, bytesRisk, exitCodeInternal)
}
if _, _, err := EmitStoredResult(cmd); err != nil {
t.Fatalf("second call should report existing state without writing: %v", err)
}
if writer.writes != 1 {
t.Fatalf("writer called %d times, want one emission attempt", writer.writes)
}
})
}
}
func TestWithResultStoreIsIdempotent(t *testing.T) {
ctx, first := WithResultStore(context.Background())
ctxAgain, second := WithResultStore(ctx)
if ctxAgain != ctx || second != first {
t.Fatal("WithResultStore replaced an existing store")
}
}
func TestStoreResultWithoutExecutionBoundaryHasActionableDiagnostic(t *testing.T) {
err := StoreResult(context.Background(), Success(map[string]any{"id": "a"}))
if err == nil || !strings.Contains(err.Error(), "output.WithResultStore") ||
!strings.Contains(err.Error(), "root execution boundary") {
t.Fatalf("StoreResult diagnostic = %v, want execution-boundary repair hint", err)
}
}