diff --git a/docs/unified-command-framework-2.0.md b/docs/unified-command-framework-2.0.md new file mode 100644 index 00000000..0b914b51 --- /dev/null +++ b/docs/unified-command-framework-2.0.md @@ -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。 diff --git a/internal/app/root.go b/internal/app/root.go index 2252ebee..d7868a24 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -15,24 +15,24 @@ package app import ( "context" + "encoding/json" stderrors "errors" "fmt" "io" "log/slog" "net/url" "os" - "os/signal" "path/filepath" "sort" "strings" "sync" - "syscall" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" 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/helpers" "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/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/plugin" "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/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -58,8 +59,11 @@ var ( rootStopAllStdioClients = StopAllStdioClients rootLoadPlugins = loadPlugins rootMkdirAll = os.MkdirAll - rootCreateFile = os.Create + rootCreateTemp = os.CreateTemp + rootSyncFile = (*os.File).Sync rootCloseFile = (*os.File).Close + rootRenameFile = os.Rename + rootRemoveFile = os.Remove rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv rootPluginLoadUser = (*plugin.Loader).LoadUser rootPluginLoadDev = (*plugin.Loader).LoadDev @@ -76,10 +80,49 @@ var ( // Execute runs the root command and returns the process exit code. func Execute() (exitCode int) { + var ( + root *cobra.Command + executed *cobra.Command + resultStore *output.ResultStore + ) defer func() { if r := recover(); r != nil { - fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r) - exitCode = 5 + target := executed + 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)) }() - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - // 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() engine := newPipelineEngine() - root := rootNewRootCommandWithEngine(ctx, engine) + root = rootNewRootCommandWithEngine(ctx, engine) timing.Record("cmd_init", time.Since(initStart)) // Run PreParse handlers on raw argv before Cobra parses flags. @@ -111,16 +156,55 @@ func Execute() (exitCode int) { // and --limit100 → --limit 100. if err := rootRunPreParse(root, engine); err != nil { 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) 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 executed == nil { 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) + 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) { executed.SetOut(os.Stderr) _ = executed.Help() @@ -129,9 +213,119 @@ func Execute() (exitCode int) { _ = printExecutionError(executed, os.Stdout, os.Stderr, err) return apperrors.ExitCode(err) } + if code, emitted := output.StoredExitCode(resultStore); emitted { + return code + } 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 // while exposing only the underlying parameter-domain error to CLI users. func newPreParseValidationError(err error) error { @@ -368,6 +562,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command { if len(ctx) > 0 && ctx[0] != nil { rootCtx = ctx[0] } + rootCtx, _ = output.WithResultStore(rootCtx) return newRootCommandWithEngine(rootCtx, nil, true, false) } @@ -390,6 +585,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command { // no pipeline processing is applied. func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command { registerSchemaRuntimeDelivery() + rootCtx, _ = output.WithResultStore(rootCtx) return newRootCommandWithEngine(rootCtx, engine, true, false) } @@ -414,6 +610,18 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, return cmd.Help() }, 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 // or command network activity can run. Header-only library callers // 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 { return err } + installOutputSinkErrorCleanup(cmd) 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 }, - 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() CloseAuditSink() - CloseFileLogger() - return closeOutputSink(cmd) + if emitErr != nil { + 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 { if local := cmd.LocalFlags().Lookup("output"); local != nil { return nil @@ -866,26 +1115,153 @@ func configureOutputSink(cmd *cobra.Command) error { if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil { 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 { - 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.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file)) + cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{ + file: file, + tempPath: file.Name(), + target: outputPath, + })) 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 { - file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File) - if !ok || file == nil { + state := outputSinkForCommand(cmd) + if state == nil { return nil } - if err := rootCloseFile(file); err != nil { - return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err)) + state.mu.Lock() + 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 } +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 { path = strings.TrimSpace(path) if path == "" { diff --git a/internal/app/root_full_coverage_test.go b/internal/app/root_full_coverage_test.go index beebbc4c..a0a0af46 100644 --- a/internal/app/root_full_coverage_test.go +++ b/internal/app/root_full_coverage_test.go @@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test }) oldMkdir := rootMkdirAll - oldCreate := rootCreateFile + oldCreate := rootCreateTemp oldClose := rootCloseFile t.Cleanup(func() { rootMkdirAll = oldMkdir - rootCreateFile = oldCreate + rootCreateTemp = oldCreate rootCloseFile = oldClose }) wantErr := errors.New("filesystem") @@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test t.Fatal("mkdir failure succeeded") } 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 { t.Fatal("create failure succeeded") } - rootCreateFile = oldCreate + rootCreateTemp = oldCreate file, err := os.CreateTemp(t.TempDir(), "close") if err != nil { t.Fatal(err) } 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 } if err := closeOutputSink(cmd); err == nil { t.Fatal("close failure succeeded") @@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test if err != nil { 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 { t.Fatalf("close success = %v", err) } diff --git a/internal/app/root_fw2_output_test.go b/internal/app/root_fw2_output_test.go new file mode 100644 index 00000000..0ee6c05c --- /dev/null +++ b/internal/app/root_fw2_output_test.go @@ -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) + } +} diff --git a/internal/app/root_lifecycle_regression_test.go b/internal/app/root_lifecycle_regression_test.go new file mode 100644 index 00000000..9c3a1905 --- /dev/null +++ b/internal/app/root_lifecycle_regression_test.go @@ -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) + } +} diff --git a/internal/app/root_output_atomic_test.go b/internal/app/root_output_atomic_test.go new file mode 100644 index 00000000..cd706850 --- /dev/null +++ b/internal/app/root_output_atomic_test.go @@ -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) + } +} diff --git a/internal/app/signal.go b/internal/app/signal.go new file mode 100644 index 00000000..e5d87528 --- /dev/null +++ b/internal/app/signal.go @@ -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 +} diff --git a/internal/app/signal_test.go b/internal/app/signal_test.go new file mode 100644 index 00000000..9c4dbc67 --- /dev/null +++ b/internal/app/signal_test.go @@ -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") } diff --git a/internal/corecmd/contract/final.go b/internal/corecmd/contract/final.go index afa44364..5988b078 100644 --- a/internal/corecmd/contract/final.go +++ b/internal/corecmd/contract/final.go @@ -30,6 +30,7 @@ type ContractFinalPayload struct { Parameters []ParamDecl Safety *SafetySpec DryRun *DryRunSpec + Result *ResultSpec Interface *InterfaceSpec Selection *SelectionSpec Identity *ToolIdentitySpec diff --git a/internal/corecmd/contract/result_test.go b/internal/corecmd/contract/result_test.go new file mode 100644 index 00000000..9b656334 --- /dev/null +++ b/internal/corecmd/contract/result_test.go @@ -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) + } + }) + } +} diff --git a/internal/corecmd/contract/types.go b/internal/corecmd/contract/types.go index f78aa28c..b3056142 100644 --- a/internal/corecmd/contract/types.go +++ b/internal/corecmd/contract/types.go @@ -14,7 +14,10 @@ package contract import ( + "bytes" + "encoding/json" "fmt" + "io" "sort" "strings" ) @@ -59,6 +62,171 @@ type DryRunSpec struct { 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), "") + 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 ( DryRunPreviewInvocation = "invocation" DryRunPreviewRequest = "request" diff --git a/internal/corecmd/contract_decl.go b/internal/corecmd/contract_decl.go index 90f34402..5ad11f77 100644 --- a/internal/corecmd/contract_decl.go +++ b/internal/corecmd/contract_decl.go @@ -42,6 +42,7 @@ type ContractDecl struct { Positionals []contract.RuntimeSchemaPositional Parameters []contract.ParamDecl DryRun *contract.DryRunSpec + Result *contract.ResultSpec Interface *contract.InterfaceSpec Selection contract.SelectionSpec Identity contract.ToolIdentitySpec @@ -144,6 +145,9 @@ func (s ContractDecl) empty() bool { if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" { return false } + if s.Result != nil { + return false + } if s.Interface != nil { iface := s.Interface if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" || diff --git a/internal/corecmd/contractfinal/final_test.go b/internal/corecmd/contractfinal/final_test.go index 31397ad1..07957103 100644 --- a/internal/corecmd/contractfinal/final_test.go +++ b/internal/corecmd/contractfinal/final_test.go @@ -24,6 +24,11 @@ import ( func TestContractFinalTypedRegistryNoJSON(t *testing.T) { cmd := &cobra.Command{Use: "x"} t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) }) + result := &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, + DataSchema: []byte(`{"type":"object"}`), + SensitivePaths: []string{"token"}, + } RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{ Title: "T", @@ -32,7 +37,11 @@ func TestContractFinalTypedRegistryNoJSON(t *testing.T) { }, Selection: &contract.SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}}, 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 _, ok := cmd.Annotations["dws.schema.final"]; ok { 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 { 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) { @@ -137,3 +154,37 @@ func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *t 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) + } +} diff --git a/internal/corecmd/contractfinal/store.go b/internal/corecmd/contractfinal/store.go index b297aff4..da2e2d83 100644 --- a/internal/corecmd/contractfinal/store.go +++ b/internal/corecmd/contractfinal/store.go @@ -14,6 +14,7 @@ package contractfinal import ( + "strings" "sync" "github.com/spf13/cobra" @@ -34,7 +35,7 @@ func RegisterRuntimeContractFinal(cmd *cobra.Command, payload contract.ContractF return } runtimeannotate.AnnotateRuntimeContract(cmd) - p := payload + p := cloneContractFinalPayload(payload) contractFinalByCommand.Store(cmd, &p) } @@ -51,7 +52,89 @@ func RuntimeContractFinal(cmd *cobra.Command) (contract.ContractFinalPayload, bo if !ok || p == nil { 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. @@ -62,3 +145,67 @@ func HasRuntimeContractFinal(cmd *cobra.Command) bool { _, ok := contractFinalByCommand.Load(cmd) 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 +} diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 56fc1b1c..4a4e33ef 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -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/runtimeannotate" 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" ) @@ -224,11 +225,12 @@ const ( // construction time. corecmd stays dispatch-agnostic and never calls a backend: // the adapters (FromLeafSpec / FromShortcut) supply the body. type Spec struct { - Use string - Short string - Long string - Example string - Hidden bool + Use string + Short string + Long string + Example string + Hidden bool + OutputRollout output.RolloutState Flags []FlagSpec Constraints []Constraint @@ -266,6 +268,8 @@ type Spec struct { RunE func(cmd *cobra.Command, args []string) error // Invoke executes a single-step command with the assembled toolArgs. 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 // it needs from the Ctx. Orchestrate func(c *Ctx) error @@ -385,6 +389,9 @@ func New(spec Spec) *cobra.Command { if spec.PostMount != nil { spec.PostMount(cmd) } + if spec.OutputRollout != "" { + output.SetCommandRollout(cmd, spec.OutputRollout) + } if spec.ConfirmFirst { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} @@ -430,6 +437,16 @@ func New(spec Spec) *cobra.Command { 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 cmd @@ -486,12 +503,15 @@ func validateDispatchDecl(spec Spec) { if spec.Invoke != nil { declared++ } + if spec.ResultInvoke != nil { + declared++ + } if spec.Orchestrate != nil { declared++ } if declared != 1 { 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)) } // ConfirmFirst only changes the ordering of a declared confirmation gate. @@ -669,7 +689,7 @@ func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error { if hint == "" { hint = fmt.Sprintf("flag --%s is required", flag.Name) } - return fmt.Errorf("%s", hint) + return apperrors.NewValidation(hint) } } return nil @@ -1348,6 +1368,13 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac d.PreviewKind = strings.TrimSpace(d.PreviewKind) 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 { iface := &contract.InterfaceSpec{ Mode: strings.TrimSpace(decl.Interface.Mode), diff --git a/internal/corecmd/result_invoke_test.go b/internal/corecmd/result_invoke_test.go new file mode 100644 index 00000000..07e7002b --- /dev/null +++ b/internal/corecmd/result_invoke_test.go @@ -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)) + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index cb24dd25..df7f8dbf 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -29,6 +29,16 @@ import ( 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. type Category string @@ -38,6 +48,41 @@ const ( CategoryValidation Category = "validation" CategoryDiscovery Category = "discovery" 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. @@ -82,6 +127,13 @@ type Option func(*Error) // ExitCodePermission and the exit-code table in docs/reference.md); // Discovery therefore uses 6 so hosts can tell "catalog lookup broke" // 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 { switch e.Category { case CategoryAPI: @@ -92,6 +144,10 @@ func (e *Error) ExitCode() int { return 3 case CategoryDiscovery: 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: return 5 } @@ -148,6 +204,11 @@ func WithRetryable(retryable bool) Option { // WithRetryAfterSeconds records the server-recommended delay before a retry. // A zero delay is meaningful and is therefore preserved; negative values are // ignored as invalid server guidance. +// +// 本通道只存原值、不钳制(B195/B199,AC-24):服务端给多少存多少,wire 上 +// retry_after_seconds 原样透传。transport 侧的 RetryMaxDelay 钳制只作用于 +// 重试延迟选择(retryDelayForAttempt),不得回写或截断本字段(B196 草案: +// 钳制上限可配置化后仍须保持「钳制延迟、不钳制透传」双通道分离)。 func WithRetryAfterSeconds(seconds int64) Option { return func(err *Error) { if seconds < 0 { @@ -322,17 +383,27 @@ func ExitCode(err error) int { } // 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 { errorPayload := map[string]any{ "code": ExitCode(err), "category": category(err), + "type": category(err), "message": err.Error(), } - var typed *Error if stderrors.As(err, &typed) { if 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 != "" { errorPayload["operation"] = typed.Operation @@ -404,7 +475,7 @@ func PrintJSON(w io.Writer, err error) error { errorPayload["cause"] = typed.Cause.Error() } } - payload := map[string]any{"error": errorPayload} + payload := map[string]any{"outcome": outcomeFailure, "error": errorPayload} data, marshalErr := marshalErrorJSON(payload, "", " ") if marshalErr != nil { @@ -555,9 +626,18 @@ func serverGuidance(diag ServerDiagnostics) (string, string) { 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 { var typed *Error if stderrors.As(err, &typed) { + if typed.Category == CategoryPartial { + return string(CategoryInternal) + } return string(typed.Category) } return string(CategoryInternal) diff --git a/internal/errors/errors_homology_test.go b/internal/errors/errors_homology_test.go new file mode 100644 index 00000000..4a48fb63 --- /dev/null +++ b/internal/errors/errors_homology_test.go @@ -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) + } + } +} diff --git a/internal/errors/errors_symmetry_test.go b/internal/errors/errors_symmetry_test.go new file mode 100644 index 00000000..0279795c --- /dev/null +++ b/internal/errors/errors_symmetry_test.go @@ -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) + } +} diff --git a/internal/errors/exitcodes.go b/internal/errors/exitcodes.go new file mode 100644 index 00000000..39a054fb --- /dev/null +++ b/internal/errors/exitcodes.go @@ -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, +} diff --git a/internal/errors/wire.go b/internal/errors/wire.go new file mode 100644 index 00000000..610fdf3b --- /dev/null +++ b/internal/errors/wire.go @@ -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", +} diff --git a/internal/helpers/leaf.go b/internal/helpers/leaf.go index 654ad79a..ca7b3f68 100644 --- a/internal/helpers/leaf.go +++ b/internal/helpers/leaf.go @@ -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/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -133,10 +134,11 @@ type LeafContract = corecmd.ContractDecl // // 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。 type LeafSpec struct { - Use string - Short string - Long string - Example string + Use string + Short string + Long string + Example string + OutputRollout output.RolloutState // Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走 // callMCPTool(按 product 路由)。Call 非空时两者都被忽略。不是 CLI 声明。 @@ -167,7 +169,8 @@ type LeafSpec struct { // Call 是执行体:非空时替代默认 MCP 派发。toolArgs 已由 Flags/ConstParams // 装配完成;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 转换用 // LeafFlag.Transform;可声明的互斥/至少一个应写 Constraints。 @@ -225,6 +228,9 @@ func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command { if spec.Call != nil { 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 { 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)) } 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" if spec.Validate == nil && !confirm { @@ -390,30 +399,40 @@ func HasContractConfirmDeferred(cmd *cobra.Command) bool { // dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product // 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。 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{ - Use: spec.Use, - Short: spec.Short, - Long: spec.Long, - Example: spec.Example, - Flags: spec.Flags, - Constraints: spec.Constraints, - Safety: spec.Safety, - ConfirmFirst: spec.ConfirmFirst, - ConstParams: spec.ConstParams, - Contract: spec.Contract, - Validate: spec.Validate, - PostMount: spec.PostMount, - RunE: spec.RunE, + Use: spec.Use, + Short: spec.Short, + Long: spec.Long, + Example: spec.Example, + OutputRollout: spec.OutputRollout, + Flags: spec.Flags, + Constraints: spec.Constraints, + Safety: spec.Safety, + ConfirmFirst: spec.ConfirmFirst, + ConstParams: spec.ConstParams, + Contract: spec.Contract, + Validate: spec.Validate, + PostMount: spec.PostMount, + RunE: spec.RunE, } if spec.RunE == nil { - 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.ResultCall != nil { + cs.ResultInvoke = func(c *corecmd.Ctx, toolArgs map[string]any) (output.CommandResult, error) { + return spec.ResultCall(c.Command(), spec.Tool, toolArgs) } - if spec.Server != "" { - return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs) + } else { + 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 diff --git a/internal/output/emitter.go b/internal/output/emitter.go new file mode 100644 index 00000000..ab17c2c9 --- /dev/null +++ b/internal/output/emitter.go @@ -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 + } +} diff --git a/internal/output/emitter_phase_b_test.go b/internal/output/emitter_phase_b_test.go new file mode 100644 index 00000000..f3ae1d72 --- /dev/null +++ b/internal/output/emitter_phase_b_test.go @@ -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") + } +} diff --git a/internal/output/emitter_phase_c_more_test.go b/internal/output/emitter_phase_c_more_test.go new file mode 100644 index 00000000..4039b7ae --- /dev/null +++ b/internal/output/emitter_phase_c_more_test.go @@ -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()) + } +} diff --git a/internal/output/emitter_phase_c_test.go b/internal/output/emitter_phase_c_test.go new file mode 100644 index 00000000..0932cde5 --- /dev/null +++ b/internal/output/emitter_phase_c_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/output/emitter_test.go b/internal/output/emitter_test.go new file mode 100644 index 00000000..f3e7a9ea --- /dev/null +++ b/internal/output/emitter_test.go @@ -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) + } +} diff --git a/internal/output/envelope.go b/internal/output/envelope.go new file mode 100644 index 00000000..a59a5351 --- /dev/null +++ b/internal/output/envelope.go @@ -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 +} diff --git a/internal/output/envelope_byte_stability_test.go b/internal/output/envelope_byte_stability_test.go new file mode 100644 index 00000000..1bd0a049 --- /dev/null +++ b/internal/output/envelope_byte_stability_test.go @@ -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) + } +} diff --git a/internal/output/envelope_invariants_test.go b/internal/output/envelope_invariants_test.go new file mode 100644 index 00000000..bbfa244c --- /dev/null +++ b/internal/output/envelope_invariants_test.go @@ -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) + } + } +} diff --git a/internal/output/envelope_phase_g_test.go b/internal/output/envelope_phase_g_test.go new file mode 100644 index 00000000..937c24a4 --- /dev/null +++ b/internal/output/envelope_phase_g_test.go @@ -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, +// 仅为场景自洽,不构成新的覆盖重复主张。 diff --git a/internal/output/envelope_phase_h_test.go b/internal/output/envelope_phase_h_test.go new file mode 100644 index 00000000..8db33e37 --- /dev/null +++ b/internal/output/envelope_phase_h_test.go @@ -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()) + } +} diff --git a/internal/output/envelope_test.go b/internal/output/envelope_test.go new file mode 100644 index 00000000..b763af7d --- /dev/null +++ b/internal/output/envelope_test.go @@ -0,0 +1,1118 @@ +// 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" + "fmt" + "reflect" + "strings" + "testing" +) + +// canonicalOutcomes 是契约规范 §1/§2.5 的四值(顺序即 wire 顺序)。 +var canonicalOutcomes = []Outcome{ + OutcomeSuccess, + OutcomePending, + OutcomePartialFailure, + OutcomeFailure, +} + +// --- B1:ParseOutcome 校验 --- + +func TestParseOutcomeAcceptsCanonicalValues(t *testing.T) { + for _, want := range canonicalOutcomes { + got, err := ParseOutcome(string(want)) + if err != nil { + t.Fatalf("ParseOutcome(%q) error = %v, want nil", want, err) + } + if got != want { + t.Fatalf("ParseOutcome(%q) = %q, want %q", want, got, want) + } + } +} + +func TestParseOutcomeRejectsNonCanonical(t *testing.T) { + rejected := []string{ + "", // 空串 + "Success", // 首字母大写 + "SUCCESS", // 全大写 + "success ", // 尾部空白 + " success", // 头部空白 + "partial-failure", // 连字符变体 + "partialfailure", // 缺下划线 + "PARTIAL_FAILURE", // 大写变体 + "Pending", // 大小写变体 + "failure ", // 尾部空白 + "ok", // 历史键名,非 outcome 值 + "error", // 历史键名,非 outcome 值 + "partial_failure\n", // 控制字符 + } + for _, s := range rejected { + got, err := ParseOutcome(s) + if err == nil { + t.Fatalf("ParseOutcome(%q) = %q, want error", s, got) + } + if got != "" { + t.Fatalf("ParseOutcome(%q) returned %q on error, want zero value", s, got) + } + } +} + +func TestParseOutcomeErrorMessageNamesAllFourValues(t *testing.T) { + _, err := ParseOutcome("Success") + if err == nil { + t.Fatal("ParseOutcome(\"Success\") error = nil, want non-nil") + } + msg := err.Error() + for _, want := range []string{`"Success"`, `"success"`, `"pending"`, `"partial_failure"`, `"failure"`} { + if !strings.Contains(msg, want) { + t.Fatalf("error message %q missing %q", msg, want) + } + } +} + +// --- B2:Outcome.String() 与 JSON 序列化恒为四小写字符串 --- + +func TestOutcomeStringIsCanonicalLowercase(t *testing.T) { + want := []string{"success", "pending", "partial_failure", "failure"} + for i, o := range canonicalOutcomes { + if got := o.String(); got != want[i] { + t.Fatalf("Outcome(%q).String() = %q, want %q", string(o), got, want[i]) + } + } +} + +func TestOutcomeJSONSerializesToExactLowercaseStrings(t *testing.T) { + want := []string{`"success"`, `"pending"`, `"partial_failure"`, `"failure"`} + for i, o := range canonicalOutcomes { + raw, err := json.Marshal(o) + if err != nil { + t.Fatalf("json.Marshal(%q) error = %v", o, err) + } + if string(raw) != want[i] { + t.Fatalf("json.Marshal(%q) = %s, want %s", o, raw, want[i]) + } + } +} + +func TestOutcomeJSONRejectsInvalidValues(t *testing.T) { + for _, invalid := range []Outcome{"", "Success", "SUCCESS", "ok", "pending "} { + if _, err := json.Marshal(invalid); err == nil { + t.Fatalf("json.Marshal(%q) error = nil, want marshal-time rejection", string(invalid)) + } + } +} + +// --- B3:Envelope 结构体 json tag 对齐契约规范 §2.5 字段总表 --- + +func TestEnvelopeJSONTagsMatchFieldTable(t *testing.T) { + // §2.5 字段总表:字段名 + 是否 omitempty(ok/outcome 恒序列化)。 + want := []struct { + name string + tag string + omitted bool // true = 契约要求 omitempty + }{ + {"OK", "ok", false}, + {"Outcome", "outcome", false}, + {"Identity", "identity", true}, + {"DryRun", "dry_run", true}, + {"Data", "data", true}, + {"Meta", "meta", true}, + {"Error", "error", true}, + {"Notice", "_notice", true}, + } + + typ := reflect.TypeOf(Envelope{}) + if typ.NumField() != len(want) { + t.Fatalf("Envelope has %d fields, want %d (§2.5 field table)", typ.NumField(), len(want)) + } + for i, w := range want { + f := typ.Field(i) + if f.Name != w.name { + t.Fatalf("Envelope field %d = %q, want %q (§2.5 order)", i, f.Name, w.name) + } + tag := f.Tag.Get("json") + if tag != w.tag && tag != w.tag+",omitempty" { + t.Fatalf("Envelope.%s json tag = %q, want %q(,omitempty)", f.Name, tag, w.tag) + } + hasOmit := strings.HasSuffix(tag, ",omitempty") + if hasOmit != w.omitted { + t.Fatalf("Envelope.%s omitempty = %v, want %v (§2.5)", f.Name, hasOmit, w.omitted) + } + } +} + +func TestEnvelopeFieldTypesMatchFieldTable(t *testing.T) { + typ := reflect.TypeOf(Envelope{}) + byName := map[string]reflect.Type{ + "OK": reflect.TypeOf(false), + "Outcome": reflect.TypeOf(OutcomeSuccess), + "Identity": reflect.TypeOf(""), + "DryRun": reflect.TypeOf(false), + "Meta": reflect.TypeOf((*Meta)(nil)), + "Error": reflect.TypeOf((*ErrorInfo)(nil)), + } + for name, want := range byName { + f, ok := typ.FieldByName(name) + if !ok { + t.Fatalf("Envelope missing field %q", name) + } + if f.Type != want { + t.Fatalf("Envelope.%s type = %v, want %v", name, f.Type, want) + } + } +} + +// --- B4:Meta 结构体与嵌套序列化 --- + +func TestMetaJSONTagsMatchFieldTable(t *testing.T) { + want := []struct { + name string + tag string + }{ + {"Count", "count,omitempty"}, + {"Operation", "operation,omitempty"}, + {"Pagination", "pagination,omitempty"}, + } + typ := reflect.TypeOf(Meta{}) + if typ.NumField() != len(want) { + t.Fatalf("Meta has %d fields, want %d (§2.5 meta.* rows)", typ.NumField(), len(want)) + } + for i, w := range want { + f := typ.Field(i) + if f.Name != w.name { + t.Fatalf("Meta field %d = %q, want %q", i, f.Name, w.name) + } + if tag := f.Tag.Get("json"); tag != w.tag { + t.Fatalf("Meta.%s json tag = %q, want %q", f.Name, tag, w.tag) + } + } +} + +func TestEnvelopeNestedMetaSerialization(t *testing.T) { + env := Envelope{ + OK: true, + Outcome: OutcomeSuccess, + Meta: &Meta{ + Count: NewCount(60), + Operation: &OperationInfo{ + ID: "task_abc", + State: "processing", + NextCommand: "dws wiki task-result --task task_abc", + }, + Pagination: &Pagination{ + EndpointExhausted: false, + Pages: 3, + Items: 60, + NextToken: "cursor_xyz", + }, + }, + } + + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("json.Marshal(envelope) error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + + meta, ok := decoded["meta"].(map[string]any) + if !ok { + t.Fatalf("meta missing or not an object in %s", raw) + } + if got, ok := meta["count"].(float64); !ok || got != 60 { + t.Fatalf("meta.count = %v, want 60", meta["count"]) + } + op, ok := meta["operation"].(map[string]any) + if !ok { + t.Fatalf("meta.operation missing or not an object in %s", raw) + } + if op["id"] != "task_abc" || op["state"] != "processing" || + op["next_command"] != "dws wiki task-result --task task_abc" { + t.Fatalf("meta.operation = %v, want id/state/next_command preserved", op) + } + pg, ok := meta["pagination"].(map[string]any) + if !ok { + t.Fatalf("meta.pagination missing or not an object in %s", raw) + } + if pg["endpoint_exhausted"] != false { + t.Fatalf("meta.pagination.endpoint_exhausted = %v, want false (explicit)", pg["endpoint_exhausted"]) + } + if got, ok := pg["pages"].(float64); !ok || got != 3 { + t.Fatalf("meta.pagination.pages = %v, want 3", pg["pages"]) + } + if pg["next_token"] != "cursor_xyz" { + t.Fatalf("meta.pagination.next_token = %v, want cursor_xyz", pg["next_token"]) + } +} + +// --- B7:OperationInfo json tag(§2.2,全部 omitempty)--- + +func TestOperationInfoJSONTagsAllOmitempty(t *testing.T) { + want := map[string]string{ + "ID": "id,omitempty", + "State": "state,omitempty", + "TimedOut": "timed_out,omitempty", + "NextCommand": "next_command,omitempty", + } + typ := reflect.TypeOf(OperationInfo{}) + if typ.NumField() != len(want) { + t.Fatalf("OperationInfo has %d fields, want %d (§2.2)", typ.NumField(), len(want)) + } + for i := range typ.NumField() { + f := typ.Field(i) + w, ok := want[f.Name] + if !ok { + t.Fatalf("OperationInfo has unexpected field %q", f.Name) + } + if tag := f.Tag.Get("json"); tag != w { + t.Fatalf("OperationInfo.%s json tag = %q, want %q", f.Name, tag, w) + } + } +} + +// --- B8:Pagination json 字段(§3)--- + +func TestPaginationEndpointExhaustedAlwaysPresent(t *testing.T) { + // endpoint_exhausted 两态都必须显式出现(无 omitempty): + // false+next_token = 可续跑;true = 观察到服务端耗尽。 + raw, err := json.Marshal(Pagination{EndpointExhausted: true}) + if err != nil { + t.Fatalf("json.Marshal(Pagination) error = %v", err) + } + if !strings.Contains(string(raw), `"endpoint_exhausted":true`) { + t.Fatalf("endpoint_exhausted:true must be explicit, got %s", raw) + } + raw, err = json.Marshal(Pagination{NextToken: "cursor_xyz"}) + if err != nil { + t.Fatalf("json.Marshal(Pagination) error = %v", err) + } + if !strings.Contains(string(raw), `"endpoint_exhausted":false`) { + t.Fatalf("endpoint_exhausted:false must be explicit, got %s", raw) + } + if !strings.Contains(string(raw), `"next_token":"cursor_xyz"`) { + t.Fatalf("next_token missing, got %s", raw) + } +} + +func TestPaginationJSONTagsMatchContract(t *testing.T) { + want := []struct { + name string + tag string + }{ + {"EndpointExhausted", "endpoint_exhausted"}, + {"Pages", "pages,omitempty"}, + {"Items", "items,omitempty"}, + {"NextToken", "next_token,omitempty"}, + } + typ := reflect.TypeOf(Pagination{}) + if typ.NumField() != len(want) { + t.Fatalf("Pagination has %d fields, want %d (§3)", typ.NumField(), len(want)) + } + for i, w := range want { + f := typ.Field(i) + if f.Name != w.name { + t.Fatalf("Pagination field %d = %q, want %q", i, f.Name, w.name) + } + if tag := f.Tag.Get("json"); tag != w.tag { + t.Fatalf("Pagination.%s json tag = %q, want %q", f.Name, tag, w.tag) + } + } +} + +// --- B9/B10:ErrorInfo 字段组序列化(§2.4)--- + +func TestErrorInfoWireStableFieldsSerialize(t *testing.T) { + retryAfter := int64(30) + info := ErrorInfo{ + Type: "api", + Subtype: "rate_limit", + Code: 90018, + Retryable: true, + RetryAfterSeconds: &retryAfter, + } + raw, err := json.Marshal(info) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + if decoded["type"] != "api" || decoded["subtype"] != "rate_limit" { + t.Fatalf("type/subtype = %v/%v, want api/rate_limit", decoded["type"], decoded["subtype"]) + } + if got, ok := decoded["code"].(float64); !ok || got != 90018 { + t.Fatalf("code = %v, want 90018", decoded["code"]) + } + if decoded["retryable"] != true { + t.Fatalf("retryable = %v, want true present", decoded["retryable"]) + } + if got, ok := decoded["retry_after_seconds"].(float64); !ok || got != 30 { + t.Fatalf("retry_after_seconds = %v, want 30", decoded["retry_after_seconds"]) + } +} + +func TestErrorInfoRetryableAbsentWhenFalse(t *testing.T) { + raw, err := json.Marshal(ErrorInfo{Type: "validation", Message: "bad flag"}) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + s := string(raw) + if strings.Contains(s, "retryable") { + t.Fatalf("retryable:false must be omitted from wire, got %s", s) + } +} + +func TestErrorInfoRetryAfterSecondsPreservesZero(t *testing.T) { + zero := int64(0) + raw, err := json.Marshal(ErrorInfo{Type: "api", Retryable: true, RetryAfterSeconds: &zero}) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + if !strings.Contains(string(raw), `"retry_after_seconds":0`) { + t.Fatalf("retry_after_seconds:0 must serialize explicitly (0s is meaningful), got %s", raw) + } + + absent, err := json.Marshal(ErrorInfo{Type: "api"}) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + if strings.Contains(string(absent), "retry_after_seconds") { + t.Fatalf("nil retry_after_seconds must be omitted, got %s", absent) + } +} + +func TestErrorInfoValidationFieldsSerialize(t *testing.T) { + info := ErrorInfo{ + Type: "validation", + Subtype: "confirmation_required", + Message: "confirmation needed", + Hint: "re-run with --yes", + Param: "--user-id", + Params: []string{"--user-id", "--dept-id"}, + Actions: []string{"dws dev app delete --app-id a1 --yes"}, + } + raw, err := json.Marshal(info) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + if decoded["param"] != "--user-id" { + t.Fatalf("param = %v, want --user-id", decoded["param"]) + } + params, ok := decoded["params"].([]any) + if !ok || len(params) != 2 { + t.Fatalf("params = %v, want 2 entries", decoded["params"]) + } + actions, ok := decoded["actions"].([]any) + if !ok || len(actions) != 1 { + t.Fatalf("actions = %v, want 1 entry", decoded["actions"]) + } +} + +func TestMetaCountDistinguishesZeroFromAbsent(t *testing.T) { + withZero, err := json.Marshal(Envelope{OK: true, Outcome: OutcomeSuccess, Meta: &Meta{Count: NewCount(0)}}) + if err != nil { + t.Fatalf("json.Marshal(count:0) error = %v", err) + } + if !strings.Contains(string(withZero), `"count":0`) { + t.Fatalf("count:0 must serialize explicitly, got %s", withZero) + } + + absent, err := json.Marshal(Envelope{OK: true, Outcome: OutcomeSuccess, Meta: &Meta{}}) + if err != nil { + t.Fatalf("json.Marshal(empty meta) error = %v", err) + } + if strings.Contains(string(absent), "count") { + t.Fatalf("unset count must be omitted, got %s", absent) + } +} + +// --- B11:omitempty 语义——空值一律省略、不输出 null(契约规范 §2.5 末段)--- + +// marshalEnvelope 序列化信封并在失败时终止测试。 +func marshalEnvelope(t *testing.T, env Envelope) []byte { + t.Helper() + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("json.Marshal(envelope) error = %v", err) + } + return raw +} + +func TestEnvelopeOmitEmptyNeverEmitsNull(t *testing.T) { + // 全零值信封:除恒序列化的 ok/outcome 外,所有可选字段缺席, + // wire 中不得出现任何 null(token 效率 + 对齐 lark)。 + raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess}) + if s := string(raw); s != `{"ok":true,"outcome":"success"}` { + t.Fatalf("empty-value envelope = %s, want exactly {\"ok\":true,\"outcome\":\"success\"}", s) + } + if strings.Contains(string(raw), "null") { + t.Fatalf("empty values must be omitted, never null: %s", raw) + } +} + +func TestEnvelopeOmitEmptyPerFieldTable(t *testing.T) { + raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomePending}) + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + for _, key := range []string{"identity", "dry_run", "data", "meta", "error", "_notice"} { + if v, present := decoded[key]; present { + t.Fatalf("empty %s must be omitted (§2.5 omitempty), got %v in %s", key, v, raw) + } + } + if strings.Contains(string(raw), "null") { + t.Fatalf("no null values allowed on empty fields: %s", raw) + } +} + +func TestEnvelopeMetaOmitEmptyNeverEmitsNull(t *testing.T) { + // meta 非空但内部字段全部未设置:count/operation/pagination 一律缺席。 + raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess, Meta: &Meta{}}) + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + meta, ok := decoded["meta"].(map[string]any) + if !ok { + t.Fatalf("meta missing in %s", raw) + } + if len(meta) != 0 { + t.Fatalf("empty meta must serialize as {} with no null entries, got %s", raw) + } + if strings.Contains(string(raw), "null") { + t.Fatalf("empty meta fields must be omitted, never null: %s", raw) + } +} + +func TestEnvelopeEmptySliceDataIsPreservedNotOmitted(t *testing.T) { + // 空数组是合法业务载荷(空结果 data:[] + count:0,AC-06): + // omitempty 对非 nil 空切片不生效,必须原样输出 [],且不是 null。 + raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess, Data: []any{}}) + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + data, ok := decoded["data"].([]any) + if !ok { + t.Fatalf("data:[] must be present as an empty array, got %s", raw) + } + if len(data) != 0 { + t.Fatalf("data = %v, want empty array", decoded["data"]) + } + if !strings.Contains(string(raw), `"data":[]`) { + t.Fatalf("empty-array data must serialize as [], got %s", raw) + } +} + +// --- B12:Envelope JSON 顶层键集合 golden 断言(契约规范 §2 / §2.5)--- + +func envelopeTopLevelKeys(t *testing.T, raw []byte) []string { + t.Helper() + // json.Decoder 保序读入对象键,顺序即 Go 声明序(golden 键序前提)。 + dec := json.NewDecoder(bytes.NewReader(raw)) + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + t.Fatalf("envelope JSON must be an object, got %v (err %v) from %s", tok, err, raw) + } + var keys []string + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + t.Fatalf("read key token from %s: %v", raw, err) + } + key, ok := keyTok.(string) + if !ok { + t.Fatalf("non-string key token %v in %s", keyTok, raw) + } + keys = append(keys, key) + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + t.Fatalf("decode value for key %q in %s: %v", key, raw, err) + } + } + return keys +} + +func TestEnvelopeTopLevelKeySetGoldenFullEnvelope(t *testing.T) { + env := Envelope{ + OK: true, + Outcome: OutcomeSuccess, + Identity: "user", + DryRun: true, + Data: map[string]any{"todoId": "t_123"}, + Meta: &Meta{Count: NewCount(1)}, + Error: nil, + Notice: map[string]any{}, + } + raw := marshalEnvelope(t, env) + + // golden 键集合(§2.5 字段总表);含 error 键的完整集合在失败信封测试断言。 + want := []string{"ok", "outcome", "identity", "dry_run", "data", "meta", "_notice"} + got := envelopeTopLevelKeys(t, raw) + if !reflect.DeepEqual(got, want) { + t.Fatalf("top-level keys = %v, want golden %v (declaration order, §2.5)", got, want) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + if len(decoded) != len(want) { + t.Fatalf("decoded key count = %d, want %d", len(decoded), len(want)) + } +} + +func TestEnvelopeTopLevelKeySetGoldenFailureEnvelope(t *testing.T) { + env := Envelope{ + OK: false, + Outcome: OutcomeFailure, + Error: &ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018}, + } + raw := marshalEnvelope(t, env) + want := []string{"ok", "outcome", "error"} + if got := envelopeTopLevelKeys(t, raw); !reflect.DeepEqual(got, want) { + t.Fatalf("top-level keys = %v, want golden %v", got, want) + } +} + +func TestEnvelopeTopLevelKeySetIsClosedWhitelist(t *testing.T) { + // 顶层键只允许 §2.5 字段总表白名单;任何非白名单键(含历史键名)都不得出现。 + allowed := map[string]bool{ + "ok": true, "outcome": true, "identity": true, "dry_run": true, + "data": true, "meta": true, "error": true, "_notice": true, + } + retryAfter := int64(30) + env := Envelope{ + OK: false, + Outcome: OutcomeFailure, + Identity: "app", + DryRun: true, + Data: []any{}, + Meta: &Meta{ + Count: NewCount(0), + Operation: &OperationInfo{ID: "task_abc", State: "processing"}, + Pagination: &Pagination{ + EndpointExhausted: false, + NextToken: "cursor_xyz", + Pages: 3, + Items: 60, + }, + }, + Error: &ErrorInfo{ + Type: "api", Subtype: "rate_limit", Code: 90018, Message: "too many requests", + Hint: "retry", Retryable: true, RetryAfterSeconds: &retryAfter, RequestID: "req_xxx", + Param: "--user-id", Params: []string{"--user-id"}, Actions: []string{"dws dev app list --yes"}, + }, + Notice: map[string]any{}, + } + raw := marshalEnvelope(t, env) + for _, key := range envelopeTopLevelKeys(t, raw) { + if !allowed[key] { + t.Fatalf("unexpected top-level key %q outside §2.5 whitelist in %s", key, raw) + } + } +} + +// --- B13:ok 恒为 JSON bool(AC-02,禁止字符串布尔)--- + +func TestEnvelopeOKSerializesAsJSONBoolean(t *testing.T) { + for _, ok := range []bool{true, false} { + raw := marshalEnvelope(t, Envelope{OK: ok, Outcome: OutcomeSuccess}) + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + got, present := decoded["ok"] + if !present { + t.Fatalf("ok must always be present (no omitempty), got %s", raw) + } + boolVal, isBool := got.(bool) + if !isBool { + t.Fatalf("ok = %v (%T), want JSON boolean (§2.5 禁止字符串布尔)", got, got) + } + if boolVal != ok { + t.Fatalf("ok = %v, want %v", boolVal, ok) + } + } +} + +func TestEnvelopeOKWireBytesAreBooleanLiterals(t *testing.T) { + // wire 层断言:ok 只能以 true/false 字面量出现,绝不出现 "true"/"false"。 + if raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess}); !strings.Contains(string(raw), `"ok":true`) { + t.Fatalf("ok:true must be a bare boolean, got %s", raw) + } + if raw := marshalEnvelope(t, Envelope{OK: false, Outcome: OutcomeFailure}); !strings.Contains(string(raw), `"ok":false`) { + t.Fatalf("ok:false must be a bare boolean, got %s", raw) + } + for _, banned := range []string{`"ok":"true"`, `"ok":"false"`} { + for _, ok := range []bool{true, false} { + if raw := marshalEnvelope(t, Envelope{OK: ok, Outcome: OutcomeSuccess}); strings.Contains(string(raw), banned) { + t.Fatalf("string boolean %s must never appear, got %s", banned, raw) + } + } + } +} + +func TestEnvelopeOKFieldGoTypeIsBool(t *testing.T) { + // 类型层断言:Go 字段类型为 bool,字符串布尔在编译期即不可表达。 + f, ok := reflect.TypeOf(Envelope{}).FieldByName("OK") + if !ok { + t.Fatal("Envelope missing field OK") + } + if f.Type.Kind() != reflect.Bool { + t.Fatalf("Envelope.OK kind = %v, want bool (AC-02)", f.Type.Kind()) + } + if f.Tag.Get("json") != "ok" { + t.Fatalf("Envelope.OK json tag = %q, want \"ok\" (恒序列化,无 omitempty)", f.Tag.Get("json")) + } +} + +func TestEnvelopeDecoderRejectsStringBooleanOK(t *testing.T) { + // wire 守卫断言:若外部产出字符串布尔信封(契约禁止形态), + // 严格解码进 Envelope 必须失败——证明信封类型无法吞下该违规形态。 + for _, wire := range []string{ + `{"ok":"true","outcome":"success"}`, + `{"ok":"false","outcome":"failure"}`, + } { + var env Envelope + if err := json.Unmarshal([]byte(wire), &env); err == nil { + t.Fatalf("json.Unmarshal(%s) into Envelope error = nil, want strict rejection of string boolean", wire) + } + } +} + +// --- B14:ParseOutcome 非法值拒绝与错误信息(契约规范 §1)--- + +func TestParseOutcomeRejectsNonCanonicalWithDiagnosticMessage(t *testing.T) { + for _, invalid := range []string{"", "Success", "SUCCESS", "Pending", "PARTIAL_FAILURE", "success ", " partial_failure", "ok"} { + outcome, err := ParseOutcome(invalid) + if err == nil { + t.Fatalf("ParseOutcome(%q) = %q, want rejection (§1 仅四值)", invalid, outcome) + } + msg := err.Error() + // 错误信息必须回显非法输入(含空串),Agent/开发者可直接定位。 + if !strings.Contains(msg, fmt.Sprintf("%q", invalid)) { + t.Fatalf("error %q must echo the invalid input %q", msg, invalid) + } + // 错误信息必须列全四个合法值,避免二次试错。 + for _, canonical := range []string{`"success"`, `"pending"`, `"partial_failure"`, `"failure"`} { + if !strings.Contains(msg, canonical) { + t.Fatalf("error %q for input %q must list legal value %s", msg, invalid, canonical) + } + } + } +} + +func TestParseOutcomeCanonicalValuesRoundTripWithNoError(t *testing.T) { + for _, canonical := range canonicalOutcomes { + got, err := ParseOutcome(canonical.String()) + if err != nil { + t.Fatalf("ParseOutcome(%q) error = %v, want acceptance", canonical, err) + } + if got != canonical { + t.Fatalf("ParseOutcome(%q) = %q, want %q", canonical, got, canonical) + } + } +} + +// --- B15:ErrorInfo.Retryable 仅 true 时出现(契约规范 §2.4;重试设计 §2.1)--- + +func TestErrorInfoRetryableAppearsOnlyWhenTrue(t *testing.T) { + // Retryable:false(零值)必须缺席:omitempty 语义,wire 上不出现 false。 + raw, err := json.Marshal(ErrorInfo{Type: "api", Subtype: "not_found", Retryable: false}) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + if v, present := decoded["retryable"]; present { + t.Fatalf("retryable:false must be absent from wire (§2.4), got %v in %s", v, raw) + } + if strings.Contains(string(raw), "retryable") { + t.Fatalf("retryable must be omitted when false, got %s", raw) + } + + // Retryable:true 必须显式出现,且恰为布尔 true。 + raw, err = json.Marshal(ErrorInfo{Type: "api", Subtype: "rate_limit", Retryable: true}) + if err != nil { + t.Fatalf("json.Marshal(ErrorInfo) error = %v", err) + } + if !strings.Contains(string(raw), `"retryable":true`) { + t.Fatalf("retryable:true must appear exactly, got %s", raw) + } + if strings.Contains(string(raw), `"retryable":false`) { + t.Fatalf("retryable:false must never serialize, got %s", raw) + } +} + +func TestErrorInfoRetryableTagIsOmitempty(t *testing.T) { + f, ok := reflect.TypeOf(ErrorInfo{}).FieldByName("Retryable") + if !ok { + t.Fatal("ErrorInfo missing field Retryable") + } + if tag := f.Tag.Get("json"); tag != "retryable,omitempty" { + t.Fatalf("ErrorInfo.Retryable json tag = %q, want \"retryable,omitempty\" (§2.4)", tag) + } +} + +func TestEnvelopeFailureEnvelopeOmitsAbsentRetryable(t *testing.T) { + // 完整失败信封视角:error 未标可重试时,整个 wire 不出现 retryable 键。 + env := Envelope{ + OK: false, + Outcome: OutcomeFailure, + Error: &ErrorInfo{Type: "validation", Message: "bad flag"}, + } + raw := marshalEnvelope(t, env) + if strings.Contains(string(raw), "retryable") { + t.Fatalf("retryable must not leak into wire when false, got %s", raw) + } +} + +// --- B16:data/error 互斥校验(契约规范 §1 不变量 I3)--- + +func TestEnvelopeDataErrorExclusivityRejectsBothPresent(t *testing.T) { + // error 非空时 data 必须缺席:同时出现即违反 I3 字段级推论。 + env := &Envelope{ + OK: false, + Outcome: OutcomeFailure, + Data: map[string]any{"id": "a"}, + Error: &ErrorInfo{Type: "api", Code: 1}, + } + if err := env.ValidateDataErrorExclusivity(); err == nil { + t.Fatal("ValidateDataErrorExclusivity() = nil, want I3 violation error for data+error") + } else if !strings.Contains(err.Error(), "I3") { + t.Fatalf("error %q must reference invariant I3", err.Error()) + } +} + +func TestEnvelopeDataErrorExclusivityAcceptsFailureShape(t *testing.T) { + // §2.4 失败形态:error 非空、data 缺席,合法。 + env := &Envelope{ + OK: false, + Outcome: OutcomeFailure, + Error: &ErrorInfo{Type: "api", Subtype: "rate_limit"}, + } + if err := env.ValidateDataErrorExclusivity(); err != nil { + t.Fatalf("ValidateDataErrorExclusivity() = %v, want nil for error-only envelope", err) + } +} + +func TestEnvelopeDataErrorExclusivityAcceptsDataShapes(t *testing.T) { + cases := map[string]*Envelope{ + "success with data": {OK: true, Outcome: OutcomeSuccess, Data: map[string]any{"todoId": "t_1"}}, + "success empty-array data": {OK: true, Outcome: OutcomeSuccess, Data: []any{}}, + "partial_failure channels": {OK: false, Outcome: OutcomePartialFailure, Data: map[string]any{"total": 3}}, + "pending with operation": {OK: true, Outcome: OutcomePending, Meta: &Meta{Operation: &OperationInfo{ID: "task_abc"}}}, + "nil envelope": nil, + } + for name, env := range cases { + if err := env.ValidateDataErrorExclusivity(); err != nil { + t.Fatalf("%s: ValidateDataErrorExclusivity() = %v, want nil", name, err) + } + } +} + +func TestEnvelopeDataErrorExclusivityEmptyErrorStillViolates(t *testing.T) { + // 只要 Error 指针非 nil 即视为 error 非空(零值 ErrorInfo 也算), + // 与 I3「error 字段非空 ⇔ outcome==failure」的指针语义一致。 + env := &Envelope{OK: false, Outcome: OutcomeFailure, Data: []any{}, Error: &ErrorInfo{}} + if err := env.ValidateDataErrorExclusivity(); err == nil { + t.Fatal("ValidateDataErrorExclusivity() = nil, want error when Error pointer is non-nil alongside data") + } +} + +// --- B17:_notice 系统级通知注入位(契约规范 §2.5)--- + +func TestEnvelopeNoticeInjectionSlotSerializesAsObject(t *testing.T) { + // _notice 是系统级通知注入位:设置时以 object 形态出现在 wire 上。 + notice := map[string]any{"upgrade": map[string]any{"available": true}} + raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess, Notice: notice}) + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + got, present := decoded["_notice"] + if !present { + t.Fatalf("_notice injection slot missing when Notice set: %s", raw) + } + obj, ok := got.(map[string]any) + if !ok { + t.Fatalf("_notice = %v (%T), want JSON object (§2.5 注入位)", got, got) + } + if _, ok := obj["upgrade"]; !ok { + t.Fatalf("_notice payload lost in %s", raw) + } + // 注入位是顶层最后一个键(§2.5 表行序,声明序=wire 序)。 + keys := envelopeTopLevelKeys(t, raw) + if keys[len(keys)-1] != "_notice" { + t.Fatalf("top-level keys = %v, want _notice last (§2.5 field table order)", keys) + } +} + +func TestEnvelopeNoticeOmittedWhenUnset(t *testing.T) { + // 未注入通知时 _notice 缺席(omitempty),绝不输出 null。 + raw := marshalEnvelope(t, Envelope{OK: true, Outcome: OutcomeSuccess}) + if strings.Contains(string(raw), "_notice") { + t.Fatalf("unset _notice must be omitted, got %s", raw) + } + if strings.Contains(string(raw), "null") { + t.Fatalf("_notice must never serialize as null, got %s", raw) + } +} + +// --- B18:Envelope.Validate() 聚合校验入口(契约规范 §1)--- + +func TestEnvelopeValidateAcceptsCanonicalShapes(t *testing.T) { + cases := map[string]*Envelope{ + "success": NewSuccessEnvelope(map[string]any{"todoId": "t_123"}), + "success empty": NewSuccessEnvelope([]any{}), + "pending": NewPendingEnvelope(&OperationInfo{ID: "task_abc", State: "processing"}), + "pending no meta": NewPendingEnvelope(nil), + "failure": NewFailureEnvelope(&ErrorInfo{Type: "api", Subtype: "rate_limit"}), + "partial": NewPartialEnvelope(map[string]any{"total": 3}), + "nil envelope": nil, + } + for name, env := range cases { + if err := env.Validate(); err != nil { + t.Fatalf("%s: Validate() = %v, want nil", name, err) + } + } +} + +func TestEnvelopeValidateRejectsInvalidOutcome(t *testing.T) { + for _, invalid := range []Outcome{"", "Success", "ok"} { + env := &Envelope{OK: true, Outcome: invalid} + err := env.Validate() + if err == nil { + t.Fatalf("Validate() = nil for outcome %q, want rejection (§1 仅四值)", invalid) + } + if !strings.Contains(err.Error(), fmt.Sprintf("%q", string(invalid))) { + t.Fatalf("error %q must echo invalid outcome %q", err.Error(), invalid) + } + } +} + +func TestEnvelopeValidateRejectsI1Violations(t *testing.T) { + cases := map[string]*Envelope{ + "failure with ok=true": {OK: true, Outcome: OutcomeFailure}, + "partial with ok=true": {OK: true, Outcome: OutcomePartialFailure}, + "success with ok=false": {OK: false, Outcome: OutcomeSuccess, Data: map[string]any{}}, + "pending with ok=false": {OK: false, Outcome: OutcomePending}, + } + for name, env := range cases { + err := env.Validate() + if err == nil { + t.Fatalf("%s: Validate() = nil, want I1 violation", name) + } + if !strings.Contains(err.Error(), "I1") { + t.Fatalf("%s: error %q must reference invariant I1", name, err.Error()) + } + } +} + +func TestEnvelopeValidateRejectsI3Violations(t *testing.T) { + cases := map[string]*Envelope{ + "failure without error": {OK: false, Outcome: OutcomeFailure}, + "success with error": {OK: true, Outcome: OutcomeSuccess, Error: &ErrorInfo{Type: "api"}}, + "pending with error": {OK: true, Outcome: OutcomePending, Error: &ErrorInfo{}}, + "partial with error": {OK: false, Outcome: OutcomePartialFailure, Error: &ErrorInfo{Type: "api"}}, + } + for name, env := range cases { + err := env.Validate() + if err == nil { + t.Fatalf("%s: Validate() = nil, want I3 violation", name) + } + if !strings.Contains(err.Error(), "I3") { + t.Fatalf("%s: error %q must reference invariant I3", name, err.Error()) + } + } +} + +func TestEnvelopeValidateAggregatesMultipleViolations(t *testing.T) { + // 聚合入口:一次报全,不因首个违反项提前返回。 + // ok=true 配 failure(I1)+ data 与 error 并存(I3 互斥)+ error 配 failure 合法, + // 此处构造 I1 + I3 互斥两项同时违反。 + env := &Envelope{ + OK: true, + Outcome: OutcomeFailure, + Data: map[string]any{"id": "a"}, + Error: &ErrorInfo{Type: "api"}, + } + err := env.Validate() + if err == nil { + t.Fatal("Validate() = nil, want aggregated violations") + } + msg := err.Error() + if !strings.Contains(msg, "I1") { + t.Fatalf("aggregated error %q must include I1 violation", msg) + } + if !strings.Contains(msg, "I3") { + t.Fatalf("aggregated error %q must include I3 exclusivity violation", msg) + } +} + +func TestEnvelopeValidateFailureConstructorRequiresErrorInfo(t *testing.T) { + // 构造器不做前置拦截(保持简单装配),但 nil error 的失败信封 + // 无法通过 Validate():I3「error 非空 ⇔ failure」双向约束。 + env := NewFailureEnvelope(nil) + if err := env.Validate(); err == nil { + t.Fatal("Validate() = nil for failure envelope without error, want I3 violation") + } +} + +// --- B19/B20:四类信封构造函数(契约规范 §2.1/§2.2/§2.3/§2.4)--- + +func TestNewSuccessEnvelopeShape(t *testing.T) { + env := NewSuccessEnvelope(map[string]any{"todoId": "t_123"}) + if !env.OK || env.Outcome != OutcomeSuccess { + t.Fatalf("NewSuccessEnvelope = ok:%v outcome:%q, want true/success (§2.1)", env.OK, env.Outcome) + } + if env.Error != nil || env.Meta != nil { + t.Fatalf("success envelope must not carry error/meta by default: %+v", env) + } + raw := marshalEnvelope(t, *env) + if s := string(raw); s != `{"ok":true,"outcome":"success","data":{"todoId":"t_123"}}` { + t.Fatalf("success envelope wire = %s, want ok/outcome/data only", s) + } +} + +func TestNewPendingEnvelopeShape(t *testing.T) { + op := &OperationInfo{ + ID: "task_abc", + State: "processing", + NextCommand: "dws wiki task-result --task task_abc", + } + env := NewPendingEnvelope(op) + if !env.OK || env.Outcome != OutcomePending { + t.Fatalf("NewPendingEnvelope = ok:%v outcome:%q, want true/pending (§2.2)", env.OK, env.Outcome) + } + if env.Meta == nil || env.Meta.Operation != op { + t.Fatalf("pending envelope must carry meta.operation: %+v", env.Meta) + } + raw := marshalEnvelope(t, *env) + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("json.Unmarshal(%s) error = %v", raw, err) + } + meta, ok := decoded["meta"].(map[string]any) + if !ok { + t.Fatalf("meta missing in %s", raw) + } + operation, ok := meta["operation"].(map[string]any) + if !ok { + t.Fatalf("meta.operation missing in %s", raw) + } + if operation["next_command"] != "dws wiki task-result --task task_abc" { + t.Fatalf("next_command = %v, want preserved", operation["next_command"]) + } +} + +func TestNewPendingEnvelopeNilOperationOmitsMeta(t *testing.T) { + env := NewPendingEnvelope(nil) + if env.Meta != nil { + t.Fatalf("nil operation must leave Meta unset, got %+v", env.Meta) + } + raw := marshalEnvelope(t, *env) + if strings.Contains(string(raw), "meta") || strings.Contains(string(raw), "null") { + t.Fatalf("pending envelope without operation must omit meta and null: %s", raw) + } + if err := env.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for bare pending envelope", err) + } +} + +func TestNewFailureEnvelopeShape(t *testing.T) { + info := &ErrorInfo{Type: "api", Subtype: "rate_limit", Code: 90018} + env := NewFailureEnvelope(info) + if env.OK || env.Outcome != OutcomeFailure { + t.Fatalf("NewFailureEnvelope = ok:%v outcome:%q, want false/failure (§2.4)", env.OK, env.Outcome) + } + if env.Error != info || env.Data != nil { + t.Fatalf("failure envelope must carry error only: %+v", env) + } + if err := env.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for §2.4 shape", err) + } + raw := marshalEnvelope(t, *env) + if got := envelopeTopLevelKeys(t, raw); !reflect.DeepEqual(got, []string{"ok", "outcome", "error"}) { + t.Fatalf("failure envelope keys = %v, want [ok outcome error]", got) + } +} + +func TestErrorInfoValidateRejectsUnknownWireType(t *testing.T) { + for _, errorType := range []string{"network", "confirmation", "mystery"} { + info := &ErrorInfo{Type: errorType, Message: "must use a governed type"} + if err := info.Validate(); err == nil || !strings.Contains(err.Error(), "unsupported failure error.type") { + t.Fatalf("ErrorInfo.Validate(%q) = %v, want unsupported type error", errorType, err) + } + } + for _, errorType := range []string{"api", "auth", "validation", "permission", "discovery", "internal"} { + if err := (&ErrorInfo{Type: errorType}).Validate(); err != nil { + t.Fatalf("ErrorInfo.Validate(%q) = %v, want governed type accepted", errorType, err) + } + } +} + +func TestNewPartialEnvelopeShape(t *testing.T) { + data := map[string]any{ + "total": 3, + "succeeded": []any{map[string]any{"id": "a"}}, + "failed": []any{map[string]any{"id": "b"}}, + "unknown": []any{map[string]any{"id": "c", "reason": "timeout after submit"}}, + } + env := NewPartialEnvelope(data) + if env.OK || env.Outcome != OutcomePartialFailure { + t.Fatalf("NewPartialEnvelope = ok:%v outcome:%q, want false/partial_failure (§2.3)", env.OK, env.Outcome) + } + if env.Error != nil { + t.Fatalf("partial envelope must not carry top-level error (§2.3/I3): %+v", env) + } + if err := env.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for §2.3 shape", err) + } +} + +func TestEnvelopeConstructorsComputeOKFromOutcome(t *testing.T) { + // I1 框架计算:四类构造器的 OK 值与 outcome 映射恒一致,命令层不可写。 + want := []struct { + env *Envelope + ok bool + }{ + {NewSuccessEnvelope(nil), true}, + {NewPendingEnvelope(nil), true}, + {NewPartialEnvelope(nil), false}, + {NewFailureEnvelope(&ErrorInfo{Type: "internal"}), false}, + } + for i, c := range want { + if c.env.OK != c.ok { + t.Fatalf("constructor %d OK = %v, want %v (I1)", i, c.env.OK, c.ok) + } + } +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 8500d17e..b98625b5 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -64,17 +64,116 @@ var ( ) 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 { return fallback } for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} { - if format, ok := formatFromFlagSet(flags, fallback); ok { - return format + value, ok := formatValueFromFlagSet(flags) + if !ok { + continue + } + if strings.TrimSpace(value) != "" { + return normalizeFormat(value, fallback) } } + if jsonShorthandActive(cmd) { + return FormatJSON + } 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 { if cmd == nil { return Write(io.Discard, fallback, payload) @@ -127,15 +226,8 @@ func unwrapCompatRuntimePayload(payload any) any { } func formatFromFlagSet(flags *pflag.FlagSet, fallback Format) (Format, bool) { - if flags == nil { - return fallback, false - } - flag := flags.Lookup("format") - if flag == nil { - return fallback, false - } - value, err := flags.GetString("format") - if err != nil { + value, ok := formatValueFromFlagSet(flags) + if !ok { return fallback, false } return normalizeFormat(value, fallback), true diff --git a/internal/output/mcp.go b/internal/output/mcp.go new file mode 100644 index 00000000..b3b3569c --- /dev/null +++ b/internal/output/mcp.go @@ -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 +} diff --git a/internal/output/redaction.go b/internal/output/redaction.go new file mode 100644 index 00000000..a1d95099 --- /dev/null +++ b/internal/output/redaction.go @@ -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") +} diff --git a/internal/output/redaction_test.go b/internal/output/redaction_test.go new file mode 100644 index 00000000..16f0978f --- /dev/null +++ b/internal/output/redaction_test.go @@ -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) + } +} diff --git a/internal/output/result.go b/internal/output/result.go new file mode 100644 index 00000000..423a3288 --- /dev/null +++ b/internal/output/result.go @@ -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 © +} + +// 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 +} diff --git a/internal/output/rollout.go b/internal/output/rollout.go new file mode 100644 index 00000000..9327ca5b --- /dev/null +++ b/internal/output/rollout.go @@ -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 +} diff --git a/internal/output/rollout_unified_test.go b/internal/output/rollout_unified_test.go new file mode 100644 index 00000000..9ec2f64c --- /dev/null +++ b/internal/output/rollout_unified_test.go @@ -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) + } +}