diff --git a/Makefile b/Makefile index d1c9d942..f7d0b643 100644 --- a/Makefile +++ b/Makefile @@ -140,12 +140,16 @@ generate-schema: registry_guard=$$(mktemp -d); \ concepts_guard=$$(mktemp); \ concepts_schema_guard=$$(mktemp); \ + command_fallbacks_guard=$$(mktemp); \ + command_fallbacks_schema_guard=$$(mktemp); \ metadata_guard=$$(mktemp -d); \ selection_guard=$$(mktemp -d); \ - trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \ + trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \ cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \ cp internal/cli/param_concepts.json "$$concepts_guard"; \ cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \ + cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \ + cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \ cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \ cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \ $(GO) generate ./internal/cli; \ @@ -161,6 +165,14 @@ generate-schema: printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \ exit 1; \ }; \ + cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \ + printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \ + exit 1; \ + }; \ + cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \ + printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \ + exit 1; \ + }; \ cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \ printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \ exit 1; \ diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go new file mode 100644 index 00000000..70027a1a --- /dev/null +++ b/internal/app/command_path_fallback_e2e_test.go @@ -0,0 +1,264 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "bytes" + stderrors "errors" + "io" + "reflect" + "strings" + "testing" + + "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/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func TestReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) { + _, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t, + "chat", "+chat-search", "--query", "project", "--dry-run", + ) + if err != nil || len(canonicalQueryAttempts) != 0 { + t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err) + } + _, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t, + "chat", "+chat-search", "--keyword", "project", "--dry-run", + ) + if err != nil || len(canonicalKeywordAttempts) != 0 { + t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err) + } + _, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t, + "chat", "search", "--query", "project", "--dry-run", + ) + if err != nil || len(nativeCanonicalAttempts) != 0 { + t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err) + } + + tests := []struct { + name string + args []string + wantCommand string + wantPreview paramAliasDryRunPreview + }{ + { + name: "search group keyword", + args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"}, + wantCommand: "dws chat +chat-search", + wantPreview: canonicalKeyword, + }, + { + name: "group search query", + args: []string{"chat", "+group-search", "--query", "project", "--dry-run"}, + wantCommand: "dws chat +chat-search", + wantPreview: canonicalQuery, + }, + { + name: "chat group search query", + args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"}, + wantCommand: "dws chat +chat-search", + wantPreview: canonicalQuery, + }, + { + name: "native group search", + args: []string{"chat", "group", "search", "--query", "project", "--dry-run"}, + wantCommand: "dws chat search", + wantPreview: nativeCanonical, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...) + if executeErr != nil { + t.Fatalf("fallback execute error = %v", executeErr) + } + if len(attempts) != 0 { + t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts) + } + if !reflect.DeepEqual(preview, test.wantPreview) { + t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview) + } + if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 { + t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand) + } + correction := ctx.Corrections[0] + if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" { + t.Fatalf("fallback correction = %#v", correction) + } + }) + } +} + +func TestReviewedMemberFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + args := []string{ + "chat", "+chat-group-members", + "--conversation-id", "cid-fixture", + "--member-types", "user", + } + root.SetArgs(args) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatal(err) + } + if ctx == nil || ctx.Command != "dws chat +chat-members-list" || len(ctx.Corrections) == 0 { + t.Fatalf("member fallback context = %#v", ctx) + } + wantPrefix := []string{"chat", "+chat-members-list"} + if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) { + t.Fatalf("member fallback args = %v", ctx.Args) + } +} + +func TestReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasE2E(t, caller, "chat", "+chat-list", "--format", "json") + if ctx == nil { + t.Fatal("ambiguous command fallback returned nil context") + } + var structured *apperrors.Error + if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 { + t.Fatalf("ambiguous error = %T %#v", err, err) + } + if len(structured.Actions) != 4 || len(caller.calls) != 0 { + t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls) + } +} + +func TestCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"} + root.SetArgs(args) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatalf("preparse error = %v", err) + } + if ctx == nil || ctx.Command != "dws chat +chat-search" { + t.Fatalf("canonical context = %#v", ctx) + } + for _, correction := range ctx.Corrections { + if correction.Handler == "command-path-fallback" { + t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections) + } + } + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("canonical bad flag error = %v", err) + } +} + +func TestRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing required query", + args: []string{"chat", "+search-group", "--dry-run"}, + want: "请至少指定 --query、--keyword 之一", + }, + { + name: "unknown canonical flag", + args: []string{"chat", "+search-group", "--definitely-not-a-real-flag", "project"}, + want: "unknown flag", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(test.args) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args) + if err != nil { + t.Fatalf("preparse error = %v", err) + } + if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 || + ctx.Corrections[0].Handler != "command-path-fallback" { + t.Fatalf("rewritten context = %#v", ctx) + } + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) { + invalidShortcuts := map[string]bool{ + "+search-group": true, + "+group-search": true, + "+chat-group-search": true, + "+chat-group-members": true, + "+chat-list": true, + } + root := NewSchemaSourceRootCommand() + chat := exactAppCommand(root, "chat") + if chat == nil { + t.Fatal("chat command missing") + } + for _, child := range chat.Commands() { + if invalidShortcuts[child.Name()] { + t.Fatalf("fallback name %q became a real command", child.Name()) + } + for _, alias := range child.Aliases { + if invalidShortcuts[alias] { + t.Fatalf("fallback name %q became a Cobra alias", alias) + } + } + } + for path := range invalidShortcuts { + if _, ok := cli.ResolveMeta("chat " + path); ok { + t.Fatalf("fallback path %q leaked into embedded Schema", path) + } + } + for _, declared := range shortcut.All() { + if declared.Service == "chat" && invalidShortcuts[declared.Command] { + t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command) + } + } + + group := exactAppCommand(root, "chat group") + searchHint := exactAppCommand(root, "chat group search") + if group == nil || searchHint == nil || !searchHint.Hidden || !cmdutil.IsHintOnlyCommand(searchHint) { + t.Fatalf("chat group search must remain a hidden hint-only node: group=%v search=%v", group, searchHint) + } + var help bytes.Buffer + group.SetOut(&help) + if err := group.Help(); err != nil { + t.Fatal(err) + } + if strings.Contains(help.String(), "\n search ") { + t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String()) + } +} + +func exactAppCommand(root *cobra.Command, path string) *cobra.Command { + current := root + parts := strings.Fields(path) + if len(parts) > 0 && parts[0] == root.Name() { + parts = parts[1:] + } + for _, part := range parts { + var next *cobra.Command + for _, child := range current.Commands() { + if child.Name() == part { + next = child + break + } + } + if next == nil { + return nil + } + current = next + } + return current +} diff --git a/internal/app/preparse_presentation_test.go b/internal/app/preparse_presentation_test.go index 361d8d11..d2b9efca 100644 --- a/internal/app/preparse_presentation_test.go +++ b/internal/app/preparse_presentation_test.go @@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) { t.Fatalf("--debug details missing from early error:\n%s", rendered) } } + +func TestCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) { + tests := []struct { + name string + args []string + wantReason string + wantCommand string + }{ + { + name: "unknown shortcut", + args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"}, + wantReason: "unknown_shortcut", + wantCommand: "dws chat", + }, + { + name: "unknown dev app subcommand", + args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"}, + wantReason: "unknown_subcommand", + wantCommand: "dws dev app", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := NewSchemaSourceRootCommand() + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args) + if ctx == nil || ctx.Command != test.wantCommand { + t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand) + } + err = newPreParseValidationError(err) + var structured *apperrors.Error + if !stderrors.As(err, &structured) { + t.Fatalf("error = %T %v", err, err) + } + if structured.Reason != test.wantReason || structured.ExitCode() != 3 { + t.Fatalf("structured error = %#v", structured) + } + if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") { + t.Fatalf("command error leaked flag classification: %#v", structured) + } + }) + } +} + +func TestResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"} + if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil { + t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err) + } + root.SetArgs(args) + err := root.Execute() + var structured *apperrors.Error + if !stderrors.As(err, &structured) { + t.Fatalf("error = %T %v", err, err) + } + if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") { + t.Fatalf("resolved shortcut flag error = %#v", structured) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index a437d01c..39096815 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -144,6 +144,9 @@ func Execute() (exitCode int) { // newPreParseValidationError keeps pipeline handler identity in internal logs // while exposing only the underlying parameter-domain error to CLI users. func newPreParseValidationError(err error) error { + if structured, ok := err.(*apperrors.Error); ok { + return structured + } userErr := err var handlerErr *pipeline.HandlerError if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil { @@ -1339,6 +1342,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) { // - PostResponse: after transport returns, before stdout (canonical RunE) func newPipelineEngine() *pipeline.Engine { engine := pipeline.NewEngine() + engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) { + entry, ok := cli.LookupCommandPathFallback(path) + if !ok { + return pipeline.CommandPathFallback{}, false + } + return pipeline.CommandPathFallback{ + From: entry.From, + Mode: string(entry.Mode), + To: entry.To, + Candidates: append([]string(nil), entry.Candidates...), + }, true + }) engine.RegisterAll( // Register handler runs during command tree building. handlers.RegisterHandler{}, diff --git a/internal/cli/command_path_fallbacks.go b/internal/cli/command_path_fallbacks.go new file mode 100644 index 00000000..351672b3 --- /dev/null +++ b/internal/cli/command_path_fallbacks.go @@ -0,0 +1,286 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cli + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "io" + "regexp" + "sort" + "strings" + "sync" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json" + +// command_path_fallbacks.json is a reviewed recovery policy for invalid paths +// observed in evaluation. It is deliberately not a command identity source: +// Help, Skill, Schema, and normal navigation continue to publish canonical +// paths from the CommandRegistry and the real Cobra tree only. + +//go:embed command_path_fallbacks.json +var embeddedCommandPathFallbacksJSON []byte + +//go:embed command_path_fallbacks.schema.json +var embeddedCommandPathFallbacksSchemaJSON []byte + +var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`) + +// CommandPathFallbackMode determines whether an invalid reviewed path can be +// normalized exactly or must stop and surface reviewed candidates. +type CommandPathFallbackMode string + +const ( + CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite" + CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous" +) + +// CommandPathFallback is one validated, generated runtime recovery record. +// From is never advertised as a stable alias. To and Candidates are canonical +// real Cobra paths validated at generation time. +type CommandPathFallback struct { + From string `json:"from"` + Mode CommandPathFallbackMode `json:"mode"` + To string `json:"to,omitempty"` + Candidates []string `json:"candidates,omitempty"` + Reviewed bool `json:"reviewed"` + ReviewReason string `json:"review_reason"` +} + +type commandPathFallbackSnapshot struct { + Schema string `json:"$schema"` + Version int `json:"version"` + Entries []commandPathFallbackEntrySpec `json:"entries"` +} + +type commandPathFallbackEntrySpec struct { + From string `json:"from"` + Mode CommandPathFallbackMode `json:"mode"` + To string `json:"to,omitempty"` + Candidates []string `json:"candidates,omitempty"` + Reviewed bool `json:"reviewed"` + ReviewReason string `json:"review_reason"` +} + +var ( + embeddedCommandPathFallbacksOnce sync.Once + embeddedCommandPathFallbacksData []CommandPathFallback + embeddedCommandPathFallbacksErr error + loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks +) + +// LoadCommandPathFallbacks decodes and validates the authored recovery table. +// Callers receive a clone so generation and tests cannot mutate shared data. +func LoadCommandPathFallbacks() ([]CommandPathFallback, error) { + return loadReviewedCommandPathFallbacks() +} + +func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) { + embeddedCommandPathFallbacksOnce.Do(func() { + embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON) + }) + return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr +} + +func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) { + var snapshot commandPathFallbackSnapshot + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&snapshot); err != nil { + return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple JSON values") + } + return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err) + } + if snapshot.Version != 1 { + return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version) + } + if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef { + return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef) + } + if len(snapshot.Entries) == 0 { + return nil, fmt.Errorf("command path fallbacks declares no entries") + } + + entries := make([]CommandPathFallback, 0, len(snapshot.Entries)) + seenFrom := make(map[string]bool, len(snapshot.Entries)) + for index, spec := range snapshot.Entries { + from, err := validateAuthoredCommandFallbackPath(spec.From) + if err != nil { + return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err) + } + if seenFrom[from] { + return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from) + } + seenFrom[from] = true + reason := strings.TrimSpace(spec.ReviewReason) + if !spec.Reviewed || reason == "" { + return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from) + } + + entry := CommandPathFallback{ + From: from, + Mode: spec.Mode, + Reviewed: true, + ReviewReason: reason, + } + switch spec.Mode { + case CommandPathFallbackRewrite: + if len(spec.Candidates) > 0 { + return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from) + } + to, pathErr := validateAuthoredCommandFallbackPath(spec.To) + if pathErr != nil { + return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr) + } + entry.To = to + case CommandPathFallbackAmbiguous: + if strings.TrimSpace(spec.To) != "" { + return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from) + } + if len(spec.Candidates) < 2 { + return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from) + } + seenCandidates := make(map[string]bool, len(spec.Candidates)) + for _, rawCandidate := range spec.Candidates { + candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate) + if pathErr != nil { + return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr) + } + if seenCandidates[candidate] { + return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate) + } + seenCandidates[candidate] = true + entry.Candidates = append(entry.Candidates, candidate) + } + default: + return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode) + } + entries = append(entries, entry) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From }) + return entries, nil +} + +func validateAuthoredCommandFallbackPath(raw string) (string, error) { + path := strings.TrimSpace(raw) + if path == "" { + return "", fmt.Errorf("path is empty") + } + if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) { + return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw) + } + return path, nil +} + +// ReduceCommandPathFallbacks validates the reviewed recovery table against the +// live distribution-owned Cobra tree. It refuses to turn an existing command +// or stable alias into a hidden rewrite. The sole exception is an explicitly +// annotated hint-only compatibility node, which contains no business action. +func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) { + if root == nil { + return nil, fmt.Errorf("command path fallback source root is nil") + } + entries, err := LoadCommandPathFallbacks() + if err != nil { + return nil, err + } + + byFrom := make(map[string]CommandPathFallback, len(entries)) + for _, entry := range entries { + byFrom[entry.From] = entry + } + var problems []string + for _, entry := range entries { + match, resolveErr := resolveExactCobraPath(root, entry.From) + if resolveErr != nil { + problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr)) + } else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) { + problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From)) + } + + switch entry.Mode { + case CommandPathFallbackRewrite: + if _, chained := byFrom[entry.To]; chained { + problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To)) + } + problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To)...) + case CommandPathFallbackAmbiguous: + for _, candidate := range entry.Candidates { + if _, chained := byFrom[candidate]; chained { + problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate)) + } + problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate)...) + } + default: + problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode)) + } + } + + if len(problems) > 0 { + sort.Strings(problems) + return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - ")) + } + return cloneCommandPathFallbacks(entries), nil +} + +func validateCommandFallbackTarget(root *cobra.Command, from, target string) []string { + var problems []string + if commandFallbackService(from) != commandFallbackService(target) { + problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target)) + } + if commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) { + problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target)) + } + match, err := resolveExactCobraPath(root, target) + if err != nil { + return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err)) + } + if match.Command == nil { + return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target)) + } + if match.UsedAlias { + problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target)) + } + if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() { + problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target)) + } + return problems +} + +func commandFallbackService(path string) string { + parts := strings.Fields(path) + if len(parts) == 0 { + return "" + } + return parts[0] +} + +func commandFallbackHasShortcut(path string) bool { + for _, part := range strings.Fields(path) { + if strings.HasPrefix(part, "+") { + return true + } + } + return false +} + +func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback { + cloned := make([]CommandPathFallback, len(entries)) + for index, entry := range entries { + cloned[index] = entry + cloned[index].Candidates = append([]string(nil), entry.Candidates...) + } + return cloned +} diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json new file mode 100644 index 00000000..6661d9b2 --- /dev/null +++ b/internal/cli/command_path_fallbacks.json @@ -0,0 +1,53 @@ +{ + "$schema": "./command_path_fallbacks.schema.json", + "version": 1, + "entries": [ + { + "from": "chat +search-group", + "mode": "rewrite", + "to": "chat +chat-search", + "reviewed": true, + "review_reason": "0803 evaluation badcase: the model emitted +search-group for group-name search; +chat-search is the exact reviewed shortcut and accepts the observed --keyword spelling." + }, + { + "from": "chat +group-search", + "mode": "rewrite", + "to": "chat +chat-search", + "reviewed": true, + "review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation." + }, + { + "from": "chat +chat-group-search", + "mode": "rewrite", + "to": "chat +chat-search", + "reviewed": true, + "review_reason": "H12 evaluation badcase: +chat-group-search was invented while resolving a group; +chat-search is the unique equivalent shortcut." + }, + { + "from": "chat +chat-group-members", + "mode": "rewrite", + "to": "chat +chat-members-list", + "reviewed": true, + "review_reason": "H10/H19 evaluation badcases repeatedly emitted +chat-group-members; +chat-members-list is the reviewed full group-member shortcut." + }, + { + "from": "chat +chat-list", + "mode": "ambiguous", + "candidates": [ + "chat +my-groups", + "chat +chat-list-mine", + "chat +chat-list-all", + "chat +conversation-list" + ], + "reviewed": true, + "review_reason": "H12 evaluation badcase: chat-list does not identify whether the user wants joined groups, managed groups, every group, or all group/direct conversations." + }, + { + "from": "chat group search", + "mode": "rewrite", + "to": "chat search", + "reviewed": true, + "review_reason": "0803 evaluation report observed this non-runnable hint path 60 times; chat search is the exact native group-name search leaf." + } + ] +} diff --git a/internal/cli/command_path_fallbacks.schema.json b/internal/cli/command_path_fallbacks.schema.json new file mode 100644 index 00000000..f82156b4 --- /dev/null +++ b/internal/cli/command_path_fallbacks.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json", + "title": "DWS Reviewed Command Path Fallbacks", + "description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "version", + "entries" + ], + "properties": { + "$schema": { + "const": "./command_path_fallbacks.schema.json" + }, + "version": { + "const": 1 + }, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/entry" + } + } + }, + "$defs": { + "commandPath": { + "type": "string", + "pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$", + "description": "Exact CLI path without a leading dws or flags." + }, + "entry": { + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "mode", + "reviewed", + "review_reason" + ], + "properties": { + "from": { + "$ref": "#/$defs/commandPath" + }, + "mode": { + "type": "string", + "enum": [ + "rewrite", + "ambiguous" + ] + }, + "to": { + "$ref": "#/$defs/commandPath" + }, + "candidates": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/commandPath" + } + }, + "reviewed": { + "const": true + }, + "review_reason": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "rewrite" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "required": [ + "to" + ], + "not": { + "required": [ + "candidates" + ] + } + } + }, + { + "if": { + "properties": { + "mode": { + "const": "ambiguous" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "required": [ + "candidates" + ], + "not": { + "required": [ + "to" + ] + } + } + } + ] + } + } +} diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go new file mode 100644 index 00000000..d9e0cef2 --- /dev/null +++ b/internal/cli/command_path_fallbacks_generated.go @@ -0,0 +1,57 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Code generated by cmd_command_path_fallbacks; DO NOT EDIT. +// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree. +// Regenerate with `make generate-schema` (or `go generate ./internal/cli`). + +package cli + +var generatedCommandPathFallbacks = []CommandPathFallback{ + { + From: "chat +chat-group-members", + Mode: "rewrite", + To: "chat +chat-members-list", + Reviewed: true, + ReviewReason: "H10/H19 evaluation badcases repeatedly emitted +chat-group-members; +chat-members-list is the reviewed full group-member shortcut.", + }, + { + From: "chat +chat-group-search", + Mode: "rewrite", + To: "chat +chat-search", + Reviewed: true, + ReviewReason: "H12 evaluation badcase: +chat-group-search was invented while resolving a group; +chat-search is the unique equivalent shortcut.", + }, + { + From: "chat +chat-list", + Mode: "ambiguous", + Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list-all", "chat +conversation-list"}, + Reviewed: true, + ReviewReason: "H12 evaluation badcase: chat-list does not identify whether the user wants joined groups, managed groups, every group, or all group/direct conversations.", + }, + { + From: "chat +group-search", + Mode: "rewrite", + To: "chat +chat-search", + Reviewed: true, + ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.", + }, + { + From: "chat +search-group", + Mode: "rewrite", + To: "chat +chat-search", + Reviewed: true, + ReviewReason: "0803 evaluation badcase: the model emitted +search-group for group-name search; +chat-search is the exact reviewed shortcut and accepts the observed --keyword spelling.", + }, + { + From: "chat group search", + Mode: "rewrite", + To: "chat search", + Reviewed: true, + ReviewReason: "0803 evaluation report observed this non-runnable hint path 60 times; chat search is the exact native group-name search leaf.", + }, +} + +func loadGeneratedCommandPathFallbacks() []CommandPathFallback { + return generatedCommandPathFallbacks +} diff --git a/internal/cli/command_path_fallbacks_lookup.go b/internal/cli/command_path_fallbacks_lookup.go new file mode 100644 index 00000000..f35c0e0e --- /dev/null +++ b/internal/cli/command_path_fallbacks_lookup.go @@ -0,0 +1,32 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cli + +import "sync" + +var ( + commandPathFallbackIndexOnce sync.Once + commandPathFallbackIndex map[string]CommandPathFallback +) + +func buildCommandPathFallbackIndex() { + entries := loadGeneratedCommandPathFallbacks() + commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries)) + for _, entry := range entries { + commandPathFallbackIndex[entry.From] = entry + } +} + +// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed +// recovery-only path. Normalization only removes a leading dws and folds +// whitespace; it does not apply prefix, typo, or semantic matching. +func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) { + commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex) + entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)] + if !ok { + return CommandPathFallback{}, false + } + entry.Candidates = append([]string(nil), entry.Candidates...) + return entry, true +} diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go new file mode 100644 index 00000000..b1d5e0df --- /dev/null +++ b/internal/cli/command_path_fallbacks_test.go @@ -0,0 +1,269 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cli + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func commandFallbackJSON(entries string) []byte { + return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`) +} + +func validCommandFallbackEntry(from, to string) string { + return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}` +} + +func TestDecodeCommandPathFallbacksStrictContract(t *testing.T) { + valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`) + entries, err := decodeCommandPathFallbacks(valid) + if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" { + t.Fatalf("decode valid = %#v, %v", entries, err) + } + entries[0].Candidates = append(entries[0].Candidates, "mutated") + cloned := cloneCommandPathFallbacks(entries) + cloned[0].Candidates[0] = "clone" + if entries[0].Candidates[0] != "mutated" { + t.Fatal("cloneCommandPathFallbacks aliases candidate storage") + } + + tests := map[string]struct { + data []byte + want string + }{ + "unknown field": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`), + want: "unknown field", + }, + "wrong schema": { + data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`), + want: "$schema", + }, + "wrong version": { + data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`), + want: "unsupported", + }, + "empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"}, + "unnormalized path": { + data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`), + want: "not a normalized", + }, + "unreviewed": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`), + want: "reviewed=true", + }, + "empty reason": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`), + want: "reviewed=true", + }, + "invalid mode": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`), + want: "invalid mode", + }, + "rewrite candidates": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`), + want: "must not declare candidates", + }, + "ambiguous target": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`), + want: "must not declare to", + }, + "ambiguous too small": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`), + want: "at least two", + }, + "duplicate candidate": { + data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`), + want: "repeats candidate", + }, + "duplicate from": { + data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`), + want: "duplicate from", + }, + "multiple documents": { + data: append(valid, []byte(` {}`)...), + want: "multiple JSON values", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("decode error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) { + var schema map[string]any + if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil { + t.Fatalf("decode embedded schema: %v", err) + } + if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false { + t.Fatalf("schema header = %#v", schema) + } + entries, err := LoadCommandPathFallbacks() + if err != nil || len(entries) != 6 { + t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err) + } + if got, ok := LookupCommandPathFallback("dws chat +search-group"); !ok || got.To != "chat +chat-search" { + t.Fatalf("generated lookup = %#v, %v", got, ok) + } + if _, ok := LookupCommandPathFallback("chat +missing"); ok { + t.Fatal("generated lookup accepted unknown path") + } +} + +func TestReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) { + root := commandFallbackTestRoot() + entries := []CommandPathFallback{ + {From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"}, + {From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"}, + {From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"}, + } + withCommandFallbackSource(t, entries) + got, err := ReduceCommandPathFallbacks(root) + if err != nil || len(got) != len(entries) { + t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err) + } + got[2].Candidates[0] = "mutated" + again, err := ReduceCommandPathFallbacks(root) + if err != nil || again[2].Candidates[0] == "mutated" { + t.Fatalf("reduction did not clone candidates: %#v, %v", again, err) + } +} + +func TestReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) { + baseRoot := commandFallbackTestRoot + tests := map[string]struct { + entries []CommandPathFallback + mutate func(*cobra.Command) + want string + }{ + "missing target": { + entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}}, + want: "does not exist", + }, + "real source collision": { + entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}}, + want: "collides with a real Cobra command or alias", + }, + "cobra alias collision": { + entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}}, + want: "collides with a real Cobra command or alias", + }, + "cross service": { + entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}}, + want: "crosses service boundary", + }, + "shortcut mismatch": { + entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}}, + want: "disagree on +shortcut identity", + }, + "chained rewrite": { + entries: []CommandPathFallback{ + {From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"}, + {From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"}, + }, + want: "chained fallbacks are forbidden", + }, + "missing ambiguous candidate": { + entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}}, + want: "does not exist", + }, + "hidden target": { + entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}}, + mutate: func(root *cobra.Command) { + exactSchemaCommand(root, "chat +good").Hidden = true + }, + want: "not a public runnable", + }, + "target is alias": { + entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}}, + want: "must use canonical Cobra names", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := baseRoot() + if test.mutate != nil { + test.mutate(root) + } + for index := range test.entries { + test.entries[index].Reviewed = true + test.entries[index].ReviewReason = "fixture" + } + withCommandFallbackSource(t, test.entries) + if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("reduction error = %v, want containing %q", err, test.want) + } + }) + } + + withCommandFallbackLoadError(t, errors.New("fixture load")) + if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") { + t.Fatalf("load error = %v", err) + } + if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") { + t.Fatalf("nil root error = %v", err) + } +} + +func commandFallbackTestRoot() *cobra.Command { + root := &cobra.Command{Use: "dws"} + chat := &cobra.Command{Use: "chat"} + good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}} + chat.AddCommand( + good, + &cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}}, + ) + group := &cobra.Command{Use: "group"} + group.AddCommand(cmdutil.HintSubCmd("search", "use chat search")) + chat.AddCommand(group) + mail := &cobra.Command{Use: "mail"} + mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(chat, mail) + return root +} + +func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) { + t.Helper() + old := loadReviewedCommandPathFallbacks + loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { + return cloneCommandPathFallbacks(entries), nil + } + t.Cleanup(func() { loadReviewedCommandPathFallbacks = old }) +} + +func withCommandFallbackLoadError(t *testing.T, want error) { + t.Helper() + old := loadReviewedCommandPathFallbacks + loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want } + t.Cleanup(func() { loadReviewedCommandPathFallbacks = old }) +} + +func TestCommandPathFallbackGeneratedTableWellFormed(t *testing.T) { + entries := loadGeneratedCommandPathFallbacks() + if len(entries) == 0 { + t.Fatal("generated command path fallback table is empty") + } + seen := map[string]bool{} + for _, entry := range entries { + if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" { + t.Fatalf("invalid generated entry: %#v", entry) + } + seen[entry.From] = true + } + if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) { + t.Fatal("generated table clone changed values") + } +} diff --git a/internal/cli/gen.go b/internal/cli/gen.go index c7309f7e..c91ce45d 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -24,12 +24,14 @@ // 4. schema_mcp_metadata.json MCP server tool definitions // 5. schema_parameter_bindings.json parameter type/property mappings // 6. param_concepts.json + schema reviewed parameter synonym policy -// 7. cobra command tree (Go runtime) flags/usage/required (reflected) +// 7. command_path_fallbacks.json reviewed recovery-only invalid paths +// 8. cobra command tree (Go runtime) flags/usage/required (reflected) // // Generation outputs (embedded at build): // - schema_agent_metadata/*.json per-product agent metadata // - schema_catalog/ per-product catalog shards // - param_aliases_generated.go per-command parameter normalization +// - command_path_fallbacks_generated.go invalid-path recovery normalization package cli @@ -39,3 +41,4 @@ package cli // JSON files. //go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog //go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go +//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go diff --git a/internal/cli/generate_entrypoint_contract_test.go b/internal/cli/generate_entrypoint_contract_test.go index cc86e1f8..5d32d5b5 100644 --- a/internal/cli/generate_entrypoint_contract_test.go +++ b/internal/cli/generate_entrypoint_contract_test.go @@ -51,6 +51,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) { "cmd_schema_agent_metadata", "cmd_schema_catalog", "cmd_param_aliases", + "cmd_command_path_fallbacks", } { if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) { t.Errorf("gen.go does not register %s", generator) diff --git a/internal/generator/cmd_command_path_fallbacks/main.go b/internal/generator/cmd_command_path_fallbacks/main.go new file mode 100644 index 00000000..d900242a --- /dev/null +++ b/internal/generator/cmd_command_path_fallbacks/main.go @@ -0,0 +1,122 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Command cmd_command_path_fallbacks validates the reviewed recovery-only +// command path table against the live distribution Cobra tree and writes the +// deterministic runtime lookup source. It does not add paths to Cobra, Help, +// Skill, Schema, or the stable CommandRegistry identity surface. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard" +) + +const defaultOutput = "internal/cli/command_path_fallbacks_generated.go" + +var ( + newCommandFallbackRoot = app.NewSchemaSourceRootCommand + reduceCommandFallbackEntries = cli.ReduceCommandPathFallbacks + formatCommandFallbackSource = format.Source + writeCommandFallbackFile = os.WriteFile + exitCommandFallbackProcess = os.Exit +) + +func main() { + var rootPath string + var outputPath string + flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs") + flag.StringVar(&outputPath, "output", defaultOutput, "Output generated command-path fallback table") + flag.Parse() + + if err := generateCommandPathFallbacks(rootPath, outputPath); err != nil { + fail(err) + } +} + +func generateCommandPathFallbacks(rootPath, outputPath string) error { + if err := validateOutputIsolation(rootPath, outputPath); err != nil { + return err + } + entries, err := reduceCommandFallbackEntries(newCommandFallbackRoot()) + if err != nil { + return err + } + source, err := renderCommandPathFallbacks(entries) + if err != nil { + return err + } + if err := writeCommandFallbackFile(outputPath, source, 0o644); err != nil { + return fmt.Errorf("write generated command path fallbacks: %w", err) + } + _, _ = fmt.Fprintf(os.Stderr, "generated command path fallbacks: output=%s entries=%d\n", outputPath, len(entries)) + return nil +} + +func validateOutputIsolation(rootPath, outputPath string) error { + inputs := []outputguard.Input{ + {Name: "reviewed command path fallbacks", Path: "internal/cli/command_path_fallbacks.json"}, + {Name: "reviewed command path fallback schema", Path: "internal/cli/command_path_fallbacks.schema.json"}, + } + target := outputguard.Target{Name: "--output", Path: outputPath} + if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil { + return err + } + return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput) +} + +func renderCommandPathFallbacks(entries []cli.CommandPathFallback) ([]byte, error) { + var b bytes.Buffer + b.WriteString("// Copyright 2026 Alibaba Group\n") + b.WriteString("// Licensed under the Apache License, Version 2.0\n\n") + b.WriteString("// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.\n") + b.WriteString("// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.\n") + b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n") + b.WriteString("package cli\n\n") + b.WriteString("var generatedCommandPathFallbacks = []CommandPathFallback{\n") + for _, entry := range entries { + b.WriteString("\t{\n") + fmt.Fprintf(&b, "\t\tFrom: %q,\n", entry.From) + fmt.Fprintf(&b, "\t\tMode: %q,\n", entry.Mode) + if entry.To != "" { + fmt.Fprintf(&b, "\t\tTo: %q,\n", entry.To) + } + if len(entry.Candidates) > 0 { + fmt.Fprintf(&b, "\t\tCandidates: %s,\n", renderStringSlice(entry.Candidates)) + } + b.WriteString("\t\tReviewed: true,\n") + fmt.Fprintf(&b, "\t\tReviewReason: %q,\n", entry.ReviewReason) + b.WriteString("\t},\n") + } + b.WriteString("}\n\n") + b.WriteString("func loadGeneratedCommandPathFallbacks() []CommandPathFallback {\n") + b.WriteString("\treturn generatedCommandPathFallbacks\n") + b.WriteString("}\n") + + formatted, err := formatCommandFallbackSource(b.Bytes()) + if err != nil { + return nil, fmt.Errorf("format generated command path fallbacks: %w", err) + } + return formatted, nil +} + +func renderStringSlice(values []string) string { + quoted := make([]string, len(values)) + for index, value := range values { + quoted[index] = fmt.Sprintf("%q", value) + } + return "[]string{" + strings.Join(quoted, ", ") + "}" +} + +func fail(err error) { + _, _ = fmt.Fprintln(os.Stderr, "error:", err) + exitCommandFallbackProcess(1) +} diff --git a/internal/generator/cmd_command_path_fallbacks/main_test.go b/internal/generator/cmd_command_path_fallbacks/main_test.go new file mode 100644 index 00000000..591ebb52 --- /dev/null +++ b/internal/generator/cmd_command_path_fallbacks/main_test.go @@ -0,0 +1,184 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package main + +import ( + "context" + "errors" + "flag" + "go/format" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/spf13/cobra" +) + +func TestCommandFallbackGeneratorUsesDistributionOwnedCommandTree(t *testing.T) { + got := reflect.ValueOf(newCommandFallbackRoot).Pointer() + want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer() + if got != want { + t.Fatal("command fallback generator must use the distribution-owned Schema source command tree") + } +} + +func preserveCommandFallbackGeneratorGlobals(t *testing.T) { + t.Helper() + oldArgs, oldFlags := os.Args, flag.CommandLine + oldRoot := newCommandFallbackRoot + oldReduce := reduceCommandFallbackEntries + oldFormat := formatCommandFallbackSource + oldWrite := writeCommandFallbackFile + oldExit := exitCommandFallbackProcess + t.Cleanup(func() { + os.Args, flag.CommandLine = oldArgs, oldFlags + newCommandFallbackRoot = oldRoot + reduceCommandFallbackEntries = oldReduce + formatCommandFallbackSource = oldFormat + writeCommandFallbackFile = oldWrite + exitCommandFallbackProcess = oldExit + }) +} + +func commandFallbackRepositoryRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +func TestCommandFallbackMainWritesGeneratedFile(t *testing.T) { + preserveCommandFallbackGeneratorGlobals(t) + output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go") + flag.CommandLine = flag.NewFlagSet("command-fallback-success", flag.ContinueOnError) + os.Args = []string{"cmd_command_path_fallbacks", "-root", commandFallbackRepositoryRoot(t), "-output", output} + newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} } + reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { + return []cli.CommandPathFallback{{ + From: "demo +bad", + Mode: cli.CommandPathFallbackRewrite, + To: "demo +good", + Reviewed: true, + ReviewReason: "fixture", + }}, nil + } + + main() + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, want := range []string{ + "func loadGeneratedCommandPathFallbacks() []CommandPathFallback", + `From: "demo +bad"`, + `To: "demo +good"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("generated source missing %q:\n%s", want, text) + } + } +} + +func TestGenerateCommandPathFallbackFailurePaths(t *testing.T) { + preserveCommandFallbackGeneratorGlobals(t) + root := commandFallbackRepositoryRoot(t) + output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go") + newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} } + + if err := generateCommandPathFallbacks(root, filepath.Join(root, "internal", "cli", "command_path_fallbacks.json")); err == nil { + t.Fatal("generator accepted output overlapping reviewed input") + } + reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { + return nil, errors.New("reduce") + } + if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "reduce") { + t.Fatalf("reduction error = %v", err) + } + reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { return nil, nil } + formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("format") } + if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "format generated") { + t.Fatalf("format error = %v", err) + } + formatCommandFallbackSource = format.Source + writeCommandFallbackFile = func(string, []byte, os.FileMode) error { return errors.New("write") } + if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "write generated") { + t.Fatalf("write error = %v", err) + } +} + +func TestValidateCommandFallbackOutputIsolation(t *testing.T) { + root := commandFallbackRepositoryRoot(t) + if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "fallbacks.go")); err != nil { + t.Fatalf("temporary output rejected: %v", err) + } + if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil || + !strings.Contains(err.Error(), "not a canonical generated delivery target") { + t.Fatalf("non-canonical repository output error = %v", err) + } +} + +func TestRenderCommandPathFallbacksShape(t *testing.T) { + preserveCommandFallbackGeneratorGlobals(t) + entries := []cli.CommandPathFallback{ + { + From: "demo +bad", + Mode: cli.CommandPathFallbackRewrite, + To: "demo +good", + Reviewed: true, + ReviewReason: "rewrite fixture", + }, + { + From: "demo +choose", + Mode: cli.CommandPathFallbackAmbiguous, + Candidates: []string{"demo +one", "demo +two"}, + Reviewed: true, + ReviewReason: "ambiguous fixture", + }, + } + data, err := renderCommandPathFallbacks(entries) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, want := range []string{ + `Mode: "rewrite"`, + `Candidates: []string{"demo +one", "demo +two"}`, + `ReviewReason: "ambiguous fixture"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("render missing %q:\n%s", want, text) + } + } + if got := renderStringSlice(nil); got != "[]string{}" { + t.Fatalf("renderStringSlice(nil) = %q", got) + } + formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") } + if _, err := renderCommandPathFallbacks(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") { + t.Fatalf("formatter error = %v", err) + } +} + +func TestCommandFallbackMainReportsFailure(t *testing.T) { + preserveCommandFallbackGeneratorGlobals(t) + flag.CommandLine = flag.NewFlagSet("command-fallback-failure", flag.ContinueOnError) + os.Args = []string{ + "cmd_command_path_fallbacks", + "-root", commandFallbackRepositoryRoot(t), + "-output", filepath.Join(commandFallbackRepositoryRoot(t), "internal", "cli", "command_path_fallbacks.json"), + } + exitCommandFallbackProcess = func(code int) { panic(code) } + defer func() { + if recovered := recover(); recovered != 1 { + t.Fatalf("main exit = %#v, want 1", recovered) + } + }() + main() +} diff --git a/internal/helpers/dev.go b/internal/helpers/dev.go index 3814c1e0..2aa2751a 100644 --- a/internal/helpers/dev.go +++ b/internal/helpers/dev.go @@ -15,6 +15,7 @@ package helpers import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -55,6 +56,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(root) doc := &cobra.Command{ Use: "doc", @@ -66,6 +68,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(doc) doc.AddCommand(newDevdocArticleSearchCommand()) root.AddCommand( diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 66b98a46..9cb417f0 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -21,6 +21,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" 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/pkg/cmdutil" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -94,6 +95,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(root) webapp := &cobra.Command{ Use: "webapp", @@ -105,6 +107,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(webapp) webapp.AddCommand( newDevAppWebappGetCommand(runner), newDevAppWebappConfigCommand(runner), @@ -120,6 +123,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(permission) permission.AddCommand( newDevAppPermissionListCommand(runner), newDevAppPermissionAddCommand(runner), @@ -136,6 +140,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(credentials) credentials.AddCommand(newDevAppCredentialsGetCommand(runner)) member := &cobra.Command{ @@ -148,6 +153,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(member) member.AddCommand( newDevAppMemberListCommand(runner), newDevAppMemberAddCommand(runner), @@ -164,6 +170,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(security) security.AddCommand(newDevAppSecurityConfigCommand(runner)) robot := &cobra.Command{ @@ -176,6 +183,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(robot) robot.AddCommand( newDevAppRobotSubmitCommand(runner), newDevAppRobotResultCommand(runner), @@ -195,6 +203,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(version) version.AddCommand( newDevAppVersionCreateCommand(runner), newDevAppVersionListCommand(runner), @@ -214,6 +223,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(event) event.AddCommand( newDevAppEventListCommand(runner), newDevAppEventSubscribeCommand(runner), diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index de6a788f..0cd35e4b 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -32,9 +32,10 @@ import ( // SetArgs so that Cobra's subsequent ExecuteC uses the corrected // values. // -// If the target command cannot be resolved (e.g. the user typed a -// non-existent command), PreParse is skipped silently and Cobra will -// handle the error. +// Explicit +shortcut tokens and annotated group subcommands are validated +// before flag parsing so an unknown command cannot be misreported as an +// unknown flag on its nearest parent. Other unresolved paths remain Cobra's +// responsibility. func RunPreParse(root *cobra.Command, engine *Engine) error { _, err := RunPreParseArgs(root, engine, os.Args[1:]) return err @@ -45,13 +46,17 @@ func RunPreParse(root *cobra.Command, engine *Engine) error { // exact same command traversal, FlagInfo extraction, handler chain, and // root.SetArgs delivery path. func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) { - if engine == nil || !engine.HasHandlers(PreParse) { - return nil, nil - } - if len(rawArgs) == 0 { return nil, nil } + normalizedArgs, commandCorrection, fallbackErr := normalizeCommandPathFallback(root, engine, rawArgs) + if fallbackErr != nil { + ctx := &Context{Args: append([]string{}, rawArgs...)} + if presentationErr := primeEarlyErrorPresentation(root, root, rawArgs); presentationErr != nil { + slog.Debug("pipeline command-fallback presentation flags", "error", presentationErr) + } + return ctx, fallbackErr + } // Cobra's Traverse does not merge root persistent flags before deciding // whether a leading flag consumes the next token. For example, it can treat @@ -59,37 +64,52 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co // the unrelated root command `event list`. Remove only known root-persistent // flags from the traversal copy; the original argv remains intact for the // handlers and Cobra's real parse. - target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs)) + target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs)) if err != nil { return nil, nil } + if err := validateUnresolvedCommand(target, remaining); err != nil { + ctx := &Context{ + Args: append([]string{}, normalizedArgs...), + Command: target.CommandPath(), + } + if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil { + slog.Debug("pipeline command-resolution presentation flags", "error", presentationErr) + } + return ctx, err + } // Build FlagInfo from the target command's registered flags. flagInfos := FlagInfoFromCommand(target) - if len(flagInfos) == 0 { + if (engine == nil || !engine.HasHandlers(PreParse) || len(flagInfos) == 0) && commandCorrection == nil { return nil, nil } ctx := &Context{ - Args: append([]string{}, rawArgs...), + Args: append([]string{}, normalizedArgs...), // target.CommandPath() still carries the "dws" prefix; PreParse // handlers that key off a command (e.g. the semantic-alias table) // normalize it themselves, so the runtime key matches the build key. Command: target.CommandPath(), FlagSpecs: flagInfos, } + if commandCorrection != nil { + ctx.Corrections = append(ctx.Corrections, *commandCorrection) + } - if err := engine.RunPhase(PreParse, ctx); err != nil { - // Cobra has not parsed persistent flags yet, but this error is rendered - // immediately by app.Execute. Prime only the presentation controls so - // --format/--debug/--verbose affect this early error exactly as they do - // errors returned after Cobra parsing. No credentials, profiles, output - // paths, or execution controls are applied here. - if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil { - slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr) + if engine != nil && engine.HasHandlers(PreParse) && len(flagInfos) > 0 { + if err := engine.RunPhase(PreParse, ctx); err != nil { + // Cobra has not parsed persistent flags yet, but this error is rendered + // immediately by app.Execute. Prime only the presentation controls so + // --format/--debug/--verbose affect this early error exactly as they do + // errors returned after Cobra parsing. No credentials, profiles, output + // paths, or execution controls are applied here. + if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil { + slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr) + } + slog.Debug("pipeline pre-parse", "error", err) + return ctx, err } - slog.Debug("pipeline pre-parse", "error", err) - return ctx, err } // Only set corrected args if PreParse actually changed something. @@ -109,25 +129,43 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co } func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { + args, _ := argsForCommandTraversalWithPositions(root, rawArgs) + return args +} + +func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) { if root == nil || len(rawArgs) == 0 { - return rawArgs + positions := make([]int, len(rawArgs)) + for index := range positions { + positions[index] = index + } + return rawArgs, positions } flags := root.PersistentFlags() if flags == nil || !flags.HasFlags() { - return rawArgs + positions := make([]int, len(rawArgs)) + for index := range positions { + positions[index] = index + } + return rawArgs, positions } matcher := newFlagTokenMatcher(flags) out := make([]string, 0, len(rawArgs)) + positions := make([]int, 0, len(rawArgs)) for index := 0; index < len(rawArgs); index++ { argument := rawArgs[index] if argument == "--" { out = append(out, rawArgs[index:]...) + for position := index; position < len(rawArgs); position++ { + positions = append(positions, position) + } break } flag, inlineValue, matched := matcher.matchTraversalToken(argument) if !matched { out = append(out, argument) + positions = append(positions, index) continue } if index+1 < len(rawArgs) { @@ -140,7 +178,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { index++ } } - return out + return out, positions } // separatedBoolValue recognises model-friendly `--flag false` and exact diff --git a/internal/pipeline/command_fallback.go b/internal/pipeline/command_fallback.go new file mode 100644 index 00000000..b4982bf8 --- /dev/null +++ b/internal/pipeline/command_fallback.go @@ -0,0 +1,113 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pipeline + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +const ( + commandPathFallbackModeRewrite = "rewrite" + commandPathFallbackModeAmbiguous = "ambiguous" +) + +// normalizeCommandPathFallback applies only an exact reviewed path match. It +// runs before Cobra resolves a leaf, then the canonical argv continues through +// the ordinary parameter alias, validation, safety, and execution pipeline. +func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, *Correction, error) { + if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 { + return rawArgs, nil, nil + } + traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs) + entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs) + if !ok { + return rawArgs, nil, nil + } + + switch entry.Mode { + case commandPathFallbackModeRewrite: + if strings.TrimSpace(entry.To) == "" { + return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has no rewrite target", entry.From)) + } + rewritten := rewriteCommandPathTokens(rawArgs, positions[:tokenCount], strings.Fields(entry.To)) + correction := &Correction{ + Handler: "command-path-fallback", + Phase: PreParse, + Field: "command_path", + Original: entry.From, + Corrected: entry.To, + Kind: "reviewed-fallback", + } + return rewritten, correction, nil + case commandPathFallbackModeAmbiguous: + return rawArgs, nil, ambiguousCommandPathFallbackError(root, entry) + default: + return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has invalid mode %q", entry.From, entry.Mode)) + } +} + +func longestCommandPathFallback(engine *Engine, traversalArgs []string) (CommandPathFallback, int, bool) { + var selected CommandPathFallback + selectedCount := 0 + parts := make([]string, 0, len(traversalArgs)) + for index, argument := range traversalArgs { + argument = strings.TrimSpace(argument) + if argument == "" || argument == "--" || strings.HasPrefix(argument, "-") { + break + } + parts = append(parts, argument) + if candidate, ok := engine.lookupCommandPathFallback(strings.Join(parts, " ")); ok { + selected = candidate + selectedCount = index + 1 + } + } + return selected, selectedCount, selectedCount > 0 +} + +func rewriteCommandPathTokens(rawArgs []string, commandPositions []int, canonicalTokens []string) []string { + if len(commandPositions) == 0 || len(canonicalTokens) == 0 { + return append([]string(nil), rawArgs...) + } + remove := make(map[int]bool, len(commandPositions)) + for _, position := range commandPositions { + remove[position] = true + } + insertAt := commandPositions[0] + out := make([]string, 0, len(rawArgs)-len(commandPositions)+len(canonicalTokens)) + for index, argument := range rawArgs { + if index == insertAt { + out = append(out, canonicalTokens...) + } + if remove[index] { + continue + } + out = append(out, argument) + } + return out +} + +func ambiguousCommandPathFallbackError(root *cobra.Command, entry CommandPathFallback) error { + rootName := "dws" + if root != nil && strings.TrimSpace(root.Name()) != "" { + rootName = root.Name() + } + paths := make([]string, 0, len(entry.Candidates)) + actions := make([]string, 0, len(entry.Candidates)) + for _, candidate := range entry.Candidates { + path := strings.TrimSpace(rootName + " " + candidate) + paths = append(paths, fmt.Sprintf("%q", path)) + actions = append(actions, fmt.Sprintf("Run '%s --help' to inspect this candidate", path)) + } + hint := "Inspect the reviewed candidates and choose by the original user intent: " + strings.Join(paths, ", ") + return apperrors.NewValidation( + fmt.Sprintf("command path %q is ambiguous", strings.TrimSpace(rootName+" "+entry.From)), + apperrors.WithReason("ambiguous_command_fallback"), + apperrors.WithHint(hint), + apperrors.WithActions(actions...), + ) +} diff --git a/internal/pipeline/command_fallback_test.go b/internal/pipeline/command_fallback_test.go new file mode 100644 index 00000000..6a4e518c --- /dev/null +++ b/internal/pipeline/command_fallback_test.go @@ -0,0 +1,206 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pipeline + +import ( + "errors" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func TestRunPreParseArgsRewritesReviewedCommandBeforeFlags(t *testing.T) { + root, executed := commandFallbackPipelineRoot() + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"}, + }) + raw := []string{"--format", "json", "chat", "+bad", "--query", "project"} + ctx, err := RunPreParseArgs(root, engine, raw) + if err != nil { + t.Fatal(err) + } + wantArgs := []string{"--format", "json", "chat", "+good", "--query", "project"} + if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) || ctx.Command != "dws chat +good" { + t.Fatalf("context = %#v, want args=%v command=dws chat +good", ctx, wantArgs) + } + if len(ctx.Corrections) != 1 || ctx.Corrections[0].Handler != "command-path-fallback" || + ctx.Corrections[0].Original != "chat +bad" || ctx.Corrections[0].Corrected != "chat +good" { + t.Fatalf("corrections = %#v", ctx.Corrections) + } + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + if *executed != "dws chat +good:project" { + t.Fatalf("executed = %q", *executed) + } +} + +func TestRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) { + root, executed := commandFallbackPipelineRoot() + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat group search": {From: "chat group search", Mode: "rewrite", To: "chat search"}, + }) + raw := []string{"chat", "--format", "json", "group", "search", "--query", "project"} + ctx, err := RunPreParseArgs(root, engine, raw) + if err != nil { + t.Fatal(err) + } + wantArgs := []string{"chat", "search", "--format", "json", "--query", "project"} + if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) { + t.Fatalf("rewritten args = %#v, want %v", ctx, wantArgs) + } + if _, err := root.ExecuteC(); err != nil { + t.Fatal(err) + } + if *executed != "dws chat search:project" { + t.Fatalf("executed = %q", *executed) + } +} + +func TestRunPreParseArgsRejectsAmbiguousCommandFallbackWithoutDispatch(t *testing.T) { + root, executed := commandFallbackPipelineRoot() + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat +choose": { + From: "chat +choose", + Mode: "ambiguous", + Candidates: []string{"chat +good", "chat +other"}, + }, + }) + ctx, err := RunPreParseArgs(root, engine, []string{"--format", "json", "chat", "+choose", "--query", "project"}) + if ctx == nil { + t.Fatal("ambiguous fallback returned nil context") + } + structured := requireCommandResolutionError(t, err, "ambiguous_command_fallback") + if len(structured.Actions) != 2 || !strings.Contains(structured.Hint, "dws chat +good") || !strings.Contains(structured.Hint, "dws chat +other") { + t.Fatalf("ambiguous recovery = %#v", structured) + } + if *executed != "" { + t.Fatalf("ambiguous fallback dispatched %q", *executed) + } + if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" { + t.Fatalf("format = %q, %v; want primed json", format, getErr) + } +} + +func TestCommandPathFallbackLeavesUnknownAndCanonicalFlagErrorsDistinct(t *testing.T) { + root, _ := commandFallbackPipelineRoot() + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"}, + }) + _, err := RunPreParseArgs(root, engine, []string{"chat", "+missing", "--query", "project"}) + requireCommandResolutionError(t, err, "unknown_shortcut") + + validRoot, _ := commandFallbackPipelineRoot() + validRaw := []string{"chat", "+good", "--not-a-flag", "value"} + if ctx, err := RunPreParseArgs(validRoot, engine, validRaw); ctx != nil || err != nil { + t.Fatalf("valid command preparse = %#v, %v", ctx, err) + } + validRoot.SetArgs(validRaw) + _, executeErr := validRoot.ExecuteC() + if executeErr == nil || !strings.Contains(executeErr.Error(), "unknown flag") { + t.Fatalf("canonical bad flag error = %v", executeErr) + } +} + +func TestCommandPathFallbackCannotBypassCanonicalConfirmation(t *testing.T) { + for _, test := range []struct { + name string + args []string + wantDispatch int + wantErr bool + }{ + {name: "confirmation missing", args: []string{"chat", "+danger"}, wantErr: true}, + {name: "confirmed", args: []string{"chat", "+danger", "--yes"}, wantDispatch: 1}, + } { + t.Run(test.name, func(t *testing.T) { + dispatches := 0 + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + chat := &cobra.Command{Use: "chat"} + write := &cobra.Command{ + Use: "+write", + RunE: func(cmd *cobra.Command, _ []string) error { + confirmed, _ := cmd.Flags().GetBool("yes") + if !confirmed { + return apperrors.NewValidation("confirmation required", apperrors.WithReason("confirmation_required")) + } + dispatches++ + return nil + }, + } + write.Flags().Bool("yes", false, "") + chat.AddCommand(write) + root.AddCommand(chat) + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat +danger": {From: "chat +danger", Mode: "rewrite", To: "chat +write"}, + }) + if _, err := RunPreParseArgs(root, engine, test.args); err != nil { + t.Fatal(err) + } + _, err := root.ExecuteC() + if (err != nil) != test.wantErr || dispatches != test.wantDispatch { + t.Fatalf("execute error=%v dispatches=%d, want error=%v dispatches=%d", err, dispatches, test.wantErr, test.wantDispatch) + } + }) + } +} + +func TestCommandPathFallbackRejectsInvalidGeneratedMode(t *testing.T) { + root, _ := commandFallbackPipelineRoot() + for _, entry := range []CommandPathFallback{ + {From: "chat +bad", Mode: "rewrite"}, + {From: "chat +bad", Mode: "invalid", To: "chat +good"}, + } { + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{"chat +bad": entry}) + _, err := RunPreParseArgs(root, engine, []string{"chat", "+bad"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Category != apperrors.CategoryInternal { + t.Fatalf("invalid entry error = %T %v", err, err) + } + } +} + +func commandFallbackPipelineEngine(entries map[string]CommandPathFallback) *Engine { + engine := NewEngine() + engine.SetCommandPathFallbackLookup(func(path string) (CommandPathFallback, bool) { + entry, ok := entries[path] + return entry, ok + }) + return engine +} + +func commandFallbackPipelineRoot() (*cobra.Command, *string) { + executed := new(string) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().String("format", "table", "") + chat := &cobra.Command{Use: "chat"} + good := &cobra.Command{ + Use: "+good", + RunE: func(cmd *cobra.Command, _ []string) error { + query, _ := cmd.Flags().GetString("query") + *executed = cmd.CommandPath() + ":" + query + return nil + }, + } + good.Flags().String("query", "", "") + good.Flags().String("keyword", "", "") + other := &cobra.Command{Use: "+other", Run: func(cmd *cobra.Command, _ []string) { *executed = cmd.CommandPath() }} + search := &cobra.Command{ + Use: "search", + RunE: func(cmd *cobra.Command, _ []string) error { + query, _ := cmd.Flags().GetString("query") + *executed = cmd.CommandPath() + ":" + query + return nil + }, + } + search.Flags().String("query", "", "") + group := &cobra.Command{Use: "group"} + group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search")) + chat.AddCommand(good, other, search, group) + root.AddCommand(chat) + return root, executed +} diff --git a/internal/pipeline/command_resolution.go b/internal/pipeline/command_resolution.go new file mode 100644 index 00000000..6f15137d --- /dev/null +++ b/internal/pipeline/command_resolution.go @@ -0,0 +1,143 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pipeline + +import ( + "fmt" + "sort" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +const maxCommandSuggestions = 3 + +// validateUnresolvedCommand gives command identity precedence over flag +// parsing. Cobra otherwise falls back to the nearest parent when a child is +// unknown, so a later flag can incorrectly turn an invented command into an +// "unknown flag" error on that parent. +// +// Only two unambiguous command containers are handled here: +// - a top-level service followed by an explicit +shortcut token; and +// - a command explicitly annotated as a group container. +// +// Other unresolved positionals remain Cobra's responsibility so commands that +// legitimately accept positional arguments are not reclassified. +func validateUnresolvedCommand(target *cobra.Command, remaining []string) error { + if target == nil || len(remaining) == 0 { + return nil + } + candidate := strings.TrimSpace(remaining[0]) + if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") { + return nil + } + + if isExplicitShortcutCandidate(target, candidate) { + return unknownShortcutError(target, candidate) + } + if cmdutil.IsGroup(target) { + return unknownSubcommandError(target, candidate) + } + return nil +} + +func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool { + if target == nil || !strings.HasPrefix(candidate, "+") { + return false + } + root := target.Root() + if root == nil || target == root || target.Parent() != root { + return false + } + for _, child := range target.Commands() { + if strings.HasPrefix(child.Name(), "+") { + return true + } + } + return false +} + +func unknownShortcutError(parent *cobra.Command, candidate string) error { + action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name()) + return apperrors.NewValidation( + fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()), + apperrors.WithReason("unknown_shortcut"), + apperrors.WithHint(commandSuggestionHint(parent, candidate, action)), + apperrors.WithActions(action), + ) +} + +func unknownSubcommandError(parent *cobra.Command, candidate string) error { + action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath()) + return apperrors.NewValidation( + fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()), + apperrors.WithReason("unknown_subcommand"), + apperrors.WithHint(commandSuggestionHint(parent, candidate, action)), + apperrors.WithActions(action), + ) +} + +func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string { + suggestions := commandSuggestions(parent, candidate) + if len(suggestions) > maxCommandSuggestions { + suggestions = suggestions[:maxCommandSuggestions] + } + paths := make([]string, 0, len(suggestions)) + for _, suggestion := range suggestions { + paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion)) + } + switch len(paths) { + case 0: + return fallback + case 1: + return "Did you mean " + paths[0] + "?" + default: + return "Did you mean one of: " + strings.Join(paths, ", ") + "?" + } +} + +// commandSuggestions mirrors Cobra's suggestion rules without mutating +// SuggestionsMinimumDistance. Cobra only installs its default distance inside +// the final error renderer; this guard runs earlier and must remain safe for +// command trees shared by concurrent tests and callers. +func commandSuggestions(parent *cobra.Command, candidate string) []string { + if parent == nil { + return nil + } + minimumDistance := parent.SuggestionsMinimumDistance + if minimumDistance <= 0 { + minimumDistance = 2 + } + lowerCandidate := strings.ToLower(candidate) + shortcutOnly := strings.HasPrefix(candidate, "+") + seen := make(map[string]bool) + var suggestions []string + for _, child := range parent.Commands() { + if !child.IsAvailableCommand() { + continue + } + name := child.Name() + if shortcutOnly && !strings.HasPrefix(name, "+") { + continue + } + matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance || + strings.HasPrefix(strings.ToLower(name), lowerCandidate) + if !matched { + for _, explicit := range child.SuggestFor { + if strings.EqualFold(candidate, explicit) { + matched = true + break + } + } + } + if matched && !seen[name] { + seen[name] = true + suggestions = append(suggestions, name) + } + } + sort.Strings(suggestions) + return suggestions +} diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go new file mode 100644 index 00000000..d6246c15 --- /dev/null +++ b/internal/pipeline/command_resolution_test.go @@ -0,0 +1,136 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pipeline + +import ( + stderrors "errors" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func TestValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) { + root := &cobra.Command{Use: "dws"} + chat := &cobra.Command{Use: "chat"} + chat.AddCommand( + &cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}}, + ) + root.AddCommand(chat) + + err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"}) + structured := requireCommandResolutionError(t, err, "unknown_shortcut") + if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` { + t.Fatalf("Message = %q", structured.Message) + } + if structured.Hint != `Did you mean "dws chat +chat-messages"?` { + t.Fatalf("Hint = %q", structured.Hint) + } + if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" { + t.Fatalf("Actions = %#v", structured.Actions) + } + if len(structured.AvailableFlags) != 0 { + t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags) + } +} + +func TestValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) { + root := &cobra.Command{Use: "dws"} + dev := &cobra.Command{Use: "dev"} + app := &cobra.Command{Use: "app"} + cmdutil.MarkGroup(app) + app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + dev.AddCommand(app) + root.AddCommand(dev) + + err := validateUnresolvedCommand(app, []string{"search", "--keyword", "x"}) + structured := requireCommandResolutionError(t, err, "unknown_subcommand") + if structured.Message != `unknown subcommand "search" for "dws dev app"` { + t.Fatalf("Message = %q", structured.Message) + } + if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" { + t.Fatalf("Actions = %#v", structured.Actions) + } + + positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}} + root.AddCommand(positional) + for name, test := range map[string]struct { + target *cobra.Command + remaining []string + }{ + "legitimate positional": {target: positional, remaining: []string{"+chat-messages"}}, + "flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}}, + "dash terminator": {target: app, remaining: []string{"--", "search"}}, + "nil target": {target: nil, remaining: []string{"search"}}, + "empty remaining": {target: app}, + } { + t.Run(name, func(t *testing.T) { + if err := validateUnresolvedCommand(test.target, test.remaining); err != nil { + t.Fatalf("validateUnresolvedCommand() error = %v", err) + } + }) + } +} + +func TestRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "table", "") + chat := &cobra.Command{Use: "chat"} + chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(chat) + + ctx, err := RunPreParseArgs(root, nil, []string{ + "--format", "json", "chat", "+chat-mesages", "--keyword", "x", + }) + if ctx == nil || ctx.Command != "dws chat" { + t.Fatalf("Context = %#v", ctx) + } + requireCommandResolutionError(t, err, "unknown_shortcut") + if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" { + t.Fatalf("format = %q, %v; want primed json presentation", format, getErr) + } + + validRoot := &cobra.Command{Use: "dws"} + validChat := &cobra.Command{Use: "chat"} + validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}) + validRoot.AddCommand(validChat) + if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil { + t.Fatalf("valid shortcut = %#v, %v", ctx, err) + } +} + +func TestCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + root.AddCommand(group) + for _, name := range []string{"alpha", "beta", "delta", "gamma"} { + group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}}) + } + hint := commandSuggestionHint(group, "missing", "fallback") + if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") { + t.Fatalf("bounded hint = %q", hint) + } + + empty := &cobra.Command{Use: "empty"} + root.AddCommand(empty) + if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" { + t.Fatalf("fallback hint = %q", got) + } +} + +func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error { + t.Helper() + var structured *apperrors.Error + if !stderrors.As(err, &structured) { + t.Fatalf("error = %T %v, want *errors.Error", err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != reason || structured.ExitCode() != 3 { + t.Fatalf("structured error = %#v", structured) + } + return structured +} diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go index ad8ad9a4..fe5d050b 100644 --- a/internal/pipeline/engine.go +++ b/internal/pipeline/engine.go @@ -21,9 +21,23 @@ import "fmt" // for reads after all handlers have been registered; registration // itself is not concurrent-safe and should be done at startup. type Engine struct { - handlers map[Phase][]Handler + handlers map[Phase][]Handler + commandPathFallbackLookup CommandPathFallbackLookup } +// CommandPathFallback is the pipeline-local projection of one generated +// recovery record. The app injects the cli lookup so pipeline stays independent +// from the authored/generator package and can be tested with small fixtures. +type CommandPathFallback struct { + From string + Mode string + To string + Candidates []string +} + +// CommandPathFallbackLookup resolves an exact normalized path. +type CommandPathFallbackLookup func(path string) (CommandPathFallback, bool) + // HandlerError preserves the pipeline location of a handler failure for logs // and diagnostics while keeping the underlying domain error available to // user-facing adapters through Unwrap. @@ -62,6 +76,24 @@ func (e *Engine) RegisterAll(handlers ...Handler) { } } +// SetCommandPathFallbackLookup installs the build-time generated command-path +// recovery lookup. It must be configured before the engine is used. +func (e *Engine) SetCommandPathFallbackLookup(lookup CommandPathFallbackLookup) { + if e == nil { + return + } + e.commandPathFallbackLookup = lookup +} + +func (e *Engine) lookupCommandPathFallback(path string) (CommandPathFallback, bool) { + if e == nil || e.commandPathFallbackLookup == nil { + return CommandPathFallback{}, false + } + entry, ok := e.commandPathFallbackLookup(path) + entry.Candidates = append([]string(nil), entry.Candidates...) + return entry, ok +} + // Handlers returns the registered handlers for a given phase, in // registration order. The returned slice must not be modified. func (e *Engine) Handlers(phase Phase) []Handler { diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index 316c167c..07b9fbc5 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -25,6 +25,8 @@ import ( "github.com/spf13/cobra" ) +const hintOnlyCommandAnnotation = "dws.command.hint_only" + // GroupRunE is a reusable RunE for parent (group) commands that have no // business logic of their own. With args it returns an error listing available // subcommands; without args it shows help. @@ -45,8 +47,9 @@ func GroupRunE(cmd *cobra.Command, args []string) error { // HintSubCmd creates a hidden subcommand that only prints a disambiguation hint. func HintSubCmd(use, hint string) *cobra.Command { return &cobra.Command{ - Use: use, - Hidden: true, + Use: use, + Hidden: true, + Annotations: map[string]string{hintOnlyCommandAnnotation: "true"}, RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("ambiguous command %q for %q\n hint: %s", use, cmd.Parent().CommandPath(), hint) @@ -54,6 +57,13 @@ func HintSubCmd(use, hint string) *cobra.Command { } } +// IsHintOnlyCommand reports whether cmd is a hidden compatibility prompt that +// has no business execution of its own. Reviewed command-path fallbacks may +// supersede these nodes, but must still reject collisions with real commands. +func IsHintOnlyCommand(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations[hintOnlyCommandAnnotation] == "true" +} + // MustGetFlag retrieves a string flag value, checking both local and inherited flags. func MustGetFlag(cmd *cobra.Command, name string) string { val, _ := cmd.Flags().GetString(name) diff --git a/scripts/policy/check-generated-drift.sh b/scripts/policy/check-generated-drift.sh index f42fceef..e0e9d107 100755 --- a/scripts/policy/check-generated-drift.sh +++ b/scripts/policy/check-generated-drift.sh @@ -15,11 +15,13 @@ exec_tmp="$(policy_runtime_mktemp_dir dws-generated-drift)" metadata_generator="$exec_tmp/schema-agent-metadata" catalog_generator="$exec_tmp/schema-catalog" param_aliases_generator="$exec_tmp/param-aliases" +command_fallbacks_generator="$exec_tmp/command-path-fallbacks" trap 'rm -rf "$tmp" "$exec_tmp"' EXIT HUP INT TERM go build -o "$metadata_generator" ./internal/generator/cmd_schema_agent_metadata go build -a -o "$catalog_generator" ./internal/generator/cmd_schema_catalog go build -o "$param_aliases_generator" ./internal/generator/cmd_param_aliases +go build -o "$command_fallbacks_generator" ./internal/generator/cmd_command_path_fallbacks # CommandRegistry is a reviewed input, never a generated artifact. Keep an # independent byte-for-byte guard around the ordinary downstream generators. @@ -30,6 +32,11 @@ concepts_guard="$tmp/param_concepts.json" concepts_schema_guard="$tmp/param_concepts.schema.json" cp internal/cli/param_concepts.json "$concepts_guard" cp internal/cli/param_concepts.schema.json "$concepts_schema_guard" +# Command path fallbacks and their editor schema are reviewed inputs too. +command_fallbacks_guard="$tmp/command_path_fallbacks.json" +command_fallbacks_schema_guard="$tmp/command_path_fallbacks.schema.json" +cp internal/cli/command_path_fallbacks.json "$command_fallbacks_guard" +cp internal/cli/command_path_fallbacks.schema.json "$command_fallbacks_schema_guard" # Also guard human-authored metadata + selection hint trees. metadata_guard="$tmp/metadata-hints" selection_guard="$tmp/selection-hints" @@ -42,6 +49,8 @@ catalog_tmp="$tmp/schema_catalog" catalog_tmp_second="$tmp/schema_catalog-second" param_aliases_tmp="$tmp/param_aliases_generated.go" param_aliases_tmp_second="$tmp/param_aliases_generated-second.go" +command_fallbacks_tmp="$tmp/command_path_fallbacks_generated.go" +command_fallbacks_tmp_second="$tmp/command_path_fallbacks_generated-second.go" "$metadata_generator" \ -root . \ @@ -79,6 +88,14 @@ fi -root . \ -output "$param_aliases_tmp_second" +"$command_fallbacks_generator" \ + -root . \ + -output "$command_fallbacks_tmp" + +"$command_fallbacks_generator" \ + -root . \ + -output "$command_fallbacks_tmp_second" + if ! diff -qr internal/cli/schema_command_registry "$registry_guard" >/dev/null; then printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2 exit 1 @@ -94,6 +111,16 @@ if ! cmp -s internal/cli/param_concepts.schema.json "$concepts_schema_guard"; th exit 1 fi +if ! cmp -s internal/cli/command_path_fallbacks.json "$command_fallbacks_guard"; then + printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2 + exit 1 +fi + +if ! cmp -s internal/cli/command_path_fallbacks.schema.json "$command_fallbacks_schema_guard"; then + printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2 + exit 1 +fi + if ! diff -qr internal/cli/schema_hints/metadata "$metadata_guard" >/dev/null; then printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2 exit 1 @@ -130,4 +157,17 @@ if ! cmp -s internal/cli/param_aliases_generated.go "$param_aliases_tmp"; then exit 1 fi +if ! cmp -s "$command_fallbacks_tmp" "$command_fallbacks_tmp_second"; then + printf '%s\n' 'generated drift: consecutive command-path fallback generations are not byte-identical' >&2 + diff -u "$command_fallbacks_tmp" "$command_fallbacks_tmp_second" || true + exit 1 +fi + +if ! cmp -s internal/cli/command_path_fallbacks_generated.go "$command_fallbacks_tmp"; then + printf '%s\n' 'generated drift: internal/cli/command_path_fallbacks_generated.go is stale' >&2 + printf '%s\n' 'run: make generate-schema' >&2 + diff -u internal/cli/command_path_fallbacks_generated.go "$command_fallbacks_tmp" || true + exit 1 +fi + printf 'generated drift check: ok\n'