fix(cli): add reviewed command path fallbacks

This commit is contained in:
克谨
2026-08-04 09:35:48 +08:00
parent 9ce95db08e
commit cfaf161fc7
24 changed files with 2240 additions and 28 deletions
+13 -1
View File
@@ -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 := &paramAliasCaptureCaller{}
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)
}
}
+15
View File
@@ -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{},
+286
View File
@@ -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
}
+53
View File
@@ -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
}
+269
View File
@@ -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
View File
@@ -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()
}
+3
View File
@@ -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(
+10
View File
@@ -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
View File
@@ -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
+113
View File
@@ -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...),
)
}
+206
View File
@@ -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
}
+143
View File
@@ -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
}
+33 -1
View File
@@ -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
View File
@@ -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)
+40
View File
@@ -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'