fix(cli): add reviewed command path fallbacks
This commit is contained in:
@@ -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; \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
+61
-23
@@ -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
|
||||
|
||||
@@ -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...),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+12
-2
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user