diff --git a/.gitignore b/.gitignore index 5551f325..2f5bbe69 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ dwsbin # Local coverage artifacts coverage-shortcut.txt coverage-*.txt + +# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/) +/cmd_param_aliases diff --git a/AGENTS.md b/AGENTS.md index 3786cf5c..0d7632b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,11 +95,12 @@ The Schema system has two physically separated processes: **Generation** (build-time, slow, reviewed, one-way): - Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here, not in business code). -- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` - (standalone Go mains). -- Inputs: 6 authored sources (registry + hints metadata + hints selection + - MCP metadata + parameter bindings + cobra tree). -- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`. +- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` + + `cmd_param_aliases` (standalone Go mains). +- Inputs: 7 authored source groups (registry + hints metadata + hints selection + + MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree). +- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` + + `param_aliases_generated.go`. - Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server endpoints, merges with previous data for cross-server interface_ref). - Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`, diff --git a/Makefile b/Makefile index 805210c5..4bf9081e 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,9 @@ policy: test-auth-legacy-compat @$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh @$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict @$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh + @$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh + @$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh + @$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$' @$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh @$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh @$(POLICY_ENV) $(MAKE) test-schema-agent-examples @@ -130,10 +133,14 @@ test-schema-agent-examples: generate-schema: @set -e; \ registry_guard=$$(mktemp -d); \ + concepts_guard=$$(mktemp); \ + concepts_schema_guard=$$(mktemp); \ metadata_guard=$$(mktemp -d); \ selection_guard=$$(mktemp -d); \ - trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \ + trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_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 -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \ cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \ $(GO) generate ./internal/cli; \ @@ -141,6 +148,22 @@ generate-schema: printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&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; \ + }; \ + cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \ + printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.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; \ + }; \ + cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \ + printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \ + exit 1; \ + }; \ diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \ printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \ exit 1; \ diff --git a/internal/app/boolean_flag_contract_test.go b/internal/app/boolean_flag_contract_test.go new file mode 100644 index 00000000..203011f4 --- /dev/null +++ b/internal/app/boolean_flag_contract_test.go @@ -0,0 +1,228 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + stderrors "errors" + "reflect" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func TestAllDistributionBooleanFlagTypesNormalizeDetachedLiterals(t *testing.T) { + root := NewSchemaSourceRootCommand() + unique := make(map[string]pipeline.FlagInfo) + var visit func(*cobra.Command) + visit = func(command *cobra.Command) { + for _, spec := range pipeline.FlagInfoFromCommand(command) { + if spec.Type != "bool" && spec.Type != "boolean" { + continue + } + key := strings.Join([]string{spec.Name, spec.Shorthand, spec.Type}, "\x00") + unique[key] = spec + } + for _, child := range command.Commands() { + visit(child) + } + } + visit(root) + + keys := make([]string, 0, len(unique)) + for key := range unique { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) < 80 { + t.Fatalf("boolean flag contract coverage is unexpectedly small: %d", len(keys)) + } + + for _, key := range keys { + spec := unique[key] + for _, value := range []string{"true", "false"} { + t.Run(spec.Name+"/"+value, func(t *testing.T) { + ctx := &pipeline.Context{ + Command: "dws contract probe", + Args: []string{"--" + spec.Name, value}, + FlagSpecs: []pipeline.FlagInfo{spec}, + } + if err := (handlers.BoolValueHandler{}).Handle(ctx); err != nil { + t.Fatalf("BoolValueHandler.Handle() error = %v", err) + } + want := []string{"--" + spec.Name + "=" + value} + if !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("normalized args = %v, want %v", ctx.Args, want) + } + + flags := pflag.NewFlagSet(spec.Name, pflag.ContinueOnError) + flags.Bool(spec.Name, false, "") + if err := flags.Parse(ctx.Args); err != nil { + t.Fatalf("pflag rejected normalized args %v: %v", ctx.Args, err) + } + got, err := flags.GetBool(spec.Name) + if err != nil || got != (value == "true") || !flags.Changed(spec.Name) { + t.Fatalf("parsed %s = %v, changed=%v, error=%v", spec.Name, got, flags.Changed(spec.Name), err) + } + }) + } + } + t.Logf("verified detached boolean syntax for %d distinct distribution flag contracts", len(keys)) +} + +func TestBooleanSyntaxPreservesDefaultsRequiredAndChangedContracts(t *testing.T) { + tests := []struct { + name string + path string + flag string + value string + wantDefault string + wantValue string + }{ + {name: "root default false", path: "chat bot find", flag: "dry-run", value: "false", wantDefault: "false", wantValue: "false"}, + {name: "root mock default false", path: "chat bot find", flag: "mock", value: "true", wantDefault: "false", wantValue: "true"}, + {name: "local force default false", path: "upgrade", flag: "force", value: "false", wantDefault: "false", wantValue: "false"}, + {name: "local default true", path: "sheet find", flag: "match-case", value: "false", wantDefault: "true", wantValue: "false"}, + {name: "required explicit false", path: "contact dept create", flag: "create-dept-group", value: "false", wantDefault: "false", wantValue: "false"}, + {name: "changed false remains explicit", path: "sheet csv-put", flag: "allow-overwrite", value: "false", wantDefault: "false", wantValue: "false"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := NewSchemaSourceRootCommand() + leaf := resolveParamLeaf(root, test.path) + if leaf == nil { + t.Fatalf("command %q is not runnable", test.path) + } + flag := booleanContractFlag(leaf, test.flag) + if flag == nil || flag.DefValue != test.wantDefault || flag.Changed { + t.Fatalf("initial --%s contract = %#v, want default %q and unchanged", test.flag, flag, test.wantDefault) + } + + pathArgs := strings.Fields(test.path) + rawArgs := append(append([]string(nil), pathArgs...), "--"+test.flag, test.value) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(pathArgs):] + if err := leaf.ParseFlags(flagArgs); err != nil { + t.Fatalf("ParseFlags(%v) error = %v", flagArgs, err) + } + flag = booleanContractFlag(leaf, test.flag) + if flag == nil || flag.Value.String() != test.wantValue || !flag.Changed { + t.Fatalf("final --%s contract = %#v, want value %q and changed", test.flag, flag, test.wantValue) + } + }) + } +} + +func TestDetachedDryRunValuesReachTheExpectedFinalDispatchBoundary(t *testing.T) { + base := []string{ + "mail", "folder", "update", + "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", + } + + bareArgs := append(append([]string(nil), base...), "--dry-run") + _, barePreview, bareAttempts, bareErr := executeParamAliasDryRunE2E(t, bareArgs...) + if bareErr != nil || !barePreview.DryRun || barePreview.Executed || len(bareAttempts) != 0 { + t.Fatalf("bare dry-run = preview:%#v attempts:%#v error:%v", barePreview, bareAttempts, bareErr) + } + + trueArgs := append(append([]string(nil), base...), "--dry-run", "TRUE") + trueCtx, truePreview, trueAttempts, trueErr := executeParamAliasDryRunE2E(t, trueArgs...) + if trueErr != nil || !reflect.DeepEqual(truePreview, barePreview) || len(trueAttempts) != 0 { + t.Fatalf("detached true = context:%#v preview:%#v attempts:%#v error:%v", trueCtx, truePreview, trueAttempts, trueErr) + } + if !hasBooleanCorrection(trueCtx, "--dry-run TRUE", "--dry-run=true") { + t.Fatalf("detached true correction = %#v", trueCtx) + } + + falseCases := []struct { + name string + args []string + }{ + {name: "detached", args: append(append([]string(nil), base...), "--dry-run", "false")}, + {name: "explicit", args: append(append([]string(nil), base...), "--dry-run=false")}, + } + var wantAttempts []any + for _, test := range falseCases { + t.Run(test.name, func(t *testing.T) { + ctx, _, attempts, err := executeParamAliasDryRunE2E(t, test.args...) + if err == nil || !strings.Contains(err.Error(), "dry-run reached the injected command runner") { + t.Fatalf("dry-run=false dispatch error = %v", err) + } + if len(attempts) != 1 || attempts[0].DryRun { + t.Fatalf("dry-run=false attempts = %#v", attempts) + } + if test.name == "detached" && !hasBooleanCorrection(ctx, "--dry-run false", "--dry-run=false") { + t.Fatalf("detached false correction = %#v", ctx) + } + serialized := []any{attempts[0].CanonicalProduct, attempts[0].Tool, attempts[0].Params, attempts[0].DryRun} + if wantAttempts == nil { + wantAttempts = serialized + } else if !reflect.DeepEqual(serialized, wantAttempts) { + t.Fatalf("detached and explicit false dispatch differ\nwant=%#v\ngot=%#v", wantAttempts, serialized) + } + }) + } +} + +func TestContradictoryBooleanValuesFailBeforeDestructiveDispatch(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasE2E(t, caller, + "mail", "thread", "trash", + "--email", "user@example.com", "--id", "conversation-1", + "--yes", "true", "--yes=false", + ) + var conflict *pipeline.BoolValueConflictError + if !stderrors.As(err, &conflict) { + t.Fatalf("conflicting confirmation error = %v, want BoolValueConflictError (ctx=%#v)", err, ctx) + } + if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) { + t.Fatalf("conflict = %#v", conflict) + } + if len(caller.calls) != 0 { + t.Fatalf("conflicting confirmation reached destructive dispatch: %#v", caller.calls) + } +} + +func booleanContractFlag(command *cobra.Command, name string) *pflag.Flag { + if command == nil { + return nil + } + if flag := command.Flags().Lookup(name); flag != nil { + return flag + } + return command.InheritedFlags().Lookup(name) +} + +func hasBooleanCorrection(ctx *pipeline.Context, original, corrected string) bool { + if ctx == nil { + return false + } + for _, correction := range ctx.Corrections { + if correction.Handler == "boolvalue" && correction.Original == original && correction.Corrected == corrected { + return true + } + } + return false +} diff --git a/internal/app/flag_error_test.go b/internal/app/flag_error_test.go index 38d8fef6..c3580e24 100644 --- a/internal/app/flag_error_test.go +++ b/internal/app/flag_error_test.go @@ -103,3 +103,65 @@ func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) { t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg) } } + +func TestFlagErrorWithSuggestionsReviewedProtectionRoutes(t *testing.T) { + root := NewRootCommand() + for _, tc := range []struct { + path []string + flag string + wantReason string + wantHint string + }{ + {path: []string{"chat", "message", "list-by-sender"}, flag: "time", wantReason: "blocked_flag", wantHint: "blocked"}, + {path: []string{"drive", "list"}, flag: "space", wantReason: "ambiguous_flag", wantHint: "ambiguous"}, + } { + t.Run(strings.Join(tc.path, "/"), func(t *testing.T) { + cmd := mustFindCommand(t, root, tc.path...) + err := flagErrorWithSuggestions(cmd, fmt.Errorf("unknown flag: --%s", tc.flag)) + var ae *apperrors.Error + if !stderrors.As(err, &ae) { + t.Fatalf("want *apperrors.Error, got %T", err) + } + if ae.Reason != tc.wantReason || !strings.Contains(ae.Hint, tc.wantHint) || !strings.Contains(ae.Hint, "--help") { + t.Fatalf("protected error = reason %q hint %q", ae.Reason, ae.Hint) + } + }) + } +} + +func TestReviewedFlagProtectionAndInstallerEdges(t *testing.T) { + if flag, protection, ok := reviewedFlagProtection(nil, "unknown flag: --time"); ok || flag != "" || protection != "" { + t.Fatalf("nil command protection = %q, %q, %v", flag, protection, ok) + } + installReviewedFlagProtectionHandlers(nil) + + root := NewRootCommand() + cmd := mustFindCommand(t, root, "chat", "message", "list-by-sender") + flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --time=value") + if !ok || flag != "time" || protection != "blocked" { + t.Fatalf("delimited protected flag = %q, %q, %v", flag, protection, ok) + } + if flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --not-reviewed"); ok || flag != "" || protection != "" { + t.Fatalf("unreviewed flag protection = %q, %q, %v", flag, protection, ok) + } +} + +func TestReviewedFlagProtectionInstallerPreservesLocalHandler(t *testing.T) { + root := NewRootCommand() + cmd := mustFindCommand(t, root, "contact", "dept", "list-children") + handler := cmd.FlagErrorFunc() + + unreviewed := handler(cmd, fmt.Errorf("unknown flag: --not-reviewed")) + var structured *apperrors.Error + if stderrors.As(unreviewed, &structured) { + t.Fatalf("unreviewed error bypassed the command's local handler: %#v", structured) + } + if !strings.HasSuffix(unreviewed.Error(), "See 'dws contact dept list-children --help' for usage.") { + t.Fatalf("local handler output = %q", unreviewed) + } + + guarded := handler(cmd, fmt.Errorf("unknown flag: --name")) + if !stderrors.As(guarded, &structured) || structured.Reason != "blocked_flag" { + t.Fatalf("reviewed guard did not use the central handler: %#v", guarded) + } +} diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go new file mode 100644 index 00000000..4a3ed2c4 --- /dev/null +++ b/internal/app/param_alias_e2e_test.go @@ -0,0 +1,826 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "context" + "encoding/json" + stderrors "errors" + "io" + "os" + "reflect" + "sort" + "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/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type paramAliasToolCall struct { + server string + tool string + args map[string]any +} + +type paramAliasCaptureCaller struct { + calls []paramAliasToolCall +} + +func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + copyArgs := make(map[string]any, len(args)) + for key, value := range args { + copyArgs[key] = value + } + c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs}) + text := paramAliasResponseForTool(tool) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +// paramAliasResponseForTool supplies deterministic, business-shape-valid +// responses for the complete-command equivalence matrix. Most commands only +// print the transport result and need an empty object; smart shortcuts that +// inspect a read response receive the smallest shape that lets their full RunE +// complete without falling back to a validation error. +func paramAliasResponseForTool(tool string) string { + switch tool { + case "list_calendar_events": + return `{"result":{"events":[]}}` + case "search_mail_users": + return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}` + case "search_dept_by_keyword": + return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}` + case "search_groups": + return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}` + default: + return `{}` + } +} + +func (*paramAliasCaptureCaller) Format() string { return "json" } +func (*paramAliasCaptureCaller) DryRun() bool { return false } +func (*paramAliasCaptureCaller) Fields() string { return "" } +func (*paramAliasCaptureCaller) JQ() string { return "" } + +// paramAliasCaptureRunner covers helpers (currently dev app) that dispatch +// through executor.Runner instead of edition.ToolCaller. Keeping both capture +// boundaries in one call list lets the matrix compare the final request shape +// without knowing which transport adapter a command uses. +type paramAliasCaptureRunner struct { + caller *paramAliasCaptureCaller +} + +func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + copyArgs := make(map[string]any, len(invocation.Params)) + for key, value := range invocation.Params { + copyArgs[key] = value + } + r.caller.calls = append(r.caller.calls, paramAliasToolCall{ + server: invocation.CanonicalProduct, + tool: invocation.Tool, + args: copyArgs, + }) + invocation.Implemented = true + return executor.Result{Invocation: invocation, Response: map[string]any{}}, nil +} + +type paramAliasDryRunRejectRunner struct { + attempts []executor.Invocation +} + +func (r *paramAliasDryRunRejectRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + r.attempts = append(r.attempts, invocation) + return executor.Result{}, stderrors.New("dry-run reached the injected command runner") +} + +type paramAliasDryRunPreview struct { + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments"` +} + +// executeParamAliasDryRunE2E uses the existing root --dry-run barrier as a +// parameter-normalization probe. These commands do not publish command-owned +// dry-run capabilities in Schema; the test deliberately makes no such claim. +// A reject runner proves the preview stops before endpoint resolution, +// authentication, or transport execution. +func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context, paramAliasDryRunPreview, []executor.Invocation, error) { + t.Helper() + + originalArgs := os.Args + os.Args = append([]string{"dws"}, args...) + defer func() { os.Args = originalArgs }() + + captureFile, err := os.CreateTemp(t.TempDir(), "param-alias-dry-run-*.json") + if err != nil { + t.Fatalf("create dry-run output capture: %v", err) + } + defer captureFile.Close() + originalStdout := os.Stdout + originalCaller := helpers.GetCaller() + os.Stdout = captureFile + defer func() { + os.Stdout = originalStdout + helpers.InitDeps(originalCaller) + }() + rejectRunner := ¶mAliasDryRunRejectRunner{} + originalRunnerFactory := rootNewCommandRunnerWithFlags + rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner { + return rejectRunner + } + root := NewRootCommand() + rootNewCommandRunnerWithFlags = originalRunnerFactory + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + + ctx, executeErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if executeErr == nil { + executeErr = root.Execute() + } + + if err := captureFile.Sync(); err != nil { + t.Fatalf("sync dry-run output capture: %v", err) + } + if _, err := captureFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("rewind dry-run output capture: %v", err) + } + output, err := io.ReadAll(captureFile) + if err != nil { + t.Fatalf("read dry-run output capture: %v", err) + } + var preview paramAliasDryRunPreview + if executeErr == nil { + if err := json.Unmarshal(output, &preview); err != nil { + t.Fatalf("decode dry-run preview: %v\noutput=%s", err, output) + } + } + return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr +} + +func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) { + t.Helper() + originalArgs := os.Args + os.Args = append([]string{"dws"}, args...) + defer func() { os.Args = originalArgs }() + + originalRunnerFactory := rootNewCommandRunnerWithFlags + rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner { + return ¶mAliasCaptureRunner{caller: caller} + } + root := NewRootCommand() + rootNewCommandRunnerWithFlags = originalRunnerFactory + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + originalCaller := helpers.GetCaller() + helpers.InitDeps(caller) + defer helpers.InitDeps(originalCaller) + + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + return ctx, err + } + return ctx, root.Execute() +} + +func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) { + tests := []struct { + name string + confirmation []string + wantError string + wantCalls int + wantOriginal string + wantCorrection string + }{ + {name: "bare yes confirms", confirmation: []string{"--yes"}, wantCalls: 1}, + {name: "glued false stays unconfirmed", confirmation: []string{"--yesfalse"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yesfalse", wantCorrection: "--yes=false"}, + {name: "glued true confirms", confirmation: []string{"--yestrue"}, wantCalls: 1, wantOriginal: "--yestrue", wantCorrection: "--yes=true"}, + {name: "detached false stays unconfirmed", confirmation: []string{"--yes", "false"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes false", wantCorrection: "--yes=false"}, + {name: "detached no stays unconfirmed", confirmation: []string{"--yes", "no"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes no", wantCorrection: "--yes=false"}, + {name: "detached zero stays unconfirmed", confirmation: []string{"--yes", "0"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes 0", wantCorrection: "--yes=false"}, + {name: "detached true confirms", confirmation: []string{"--yes", "true"}, wantCalls: 1, wantOriginal: "--yes true", wantCorrection: "--yes=true"}, + {name: "detached yes confirms", confirmation: []string{"--yes", "yes"}, wantCalls: 1, wantOriginal: "--yes yes", wantCorrection: "--yes=true"}, + {name: "detached one confirms", confirmation: []string{"--yes", "1"}, wantCalls: 1, wantOriginal: "--yes 1", wantCorrection: "--yes=true"}, + {name: "explicit false remains unconfirmed", confirmation: []string{"--yes=false"}, wantError: "请添加 --yes 确认执行"}, + {name: "explicit true confirms", confirmation: []string{"--yes=true"}, wantCalls: 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + args := []string{ + "mail", "thread", "trash", + "--email", "user@example.com", + "--id", "conversation-1", + } + args = append(args, test.confirmation...) + ctx, err := executeParamAliasE2E(t, caller, args...) + if test.wantError == "" { + if err != nil { + t.Fatalf("confirmed command error = %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("command error = %v, want substring %q", err, test.wantError) + } + if test.wantCorrection == "" { + if ctx != nil && len(ctx.Corrections) != 0 { + t.Fatalf("confirmation spelling received corrections: %#v", ctx.Corrections) + } + } else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != test.wantOriginal || ctx.Corrections[0].Corrected != test.wantCorrection { + t.Fatalf("confirmation corrections = %#v, want %q -> %q", ctx, test.wantOriginal, test.wantCorrection) + } + if len(caller.calls) != test.wantCalls { + t.Fatalf("destructive calls = %#v, want %d", caller.calls, test.wantCalls) + } + }) + } +} + +func TestParamAliasReadCommandFinalPayload(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + start := "2026-03-10T14:00:00+08:00" + end := "2026-03-10T18:00:00+08:00" + ctx, err := executeParamAliasE2E(t, caller, + "calendar", "event", "list", + "--date", start, + "--end-time", end, + "--calendar", "primary", + "--max-results", "7", + "--next-cursor", "cursor-1", + ) + if err != nil { + t.Fatalf("calendar alias E2E error = %v", err) + } + if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--date" || ctx.Corrections[0].Corrected != "--start" { + t.Fatalf("calendar corrections = %#v, want only --date to be normalized centrally", ctx.Corrections) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "list_calendar_events" { + t.Fatalf("calendar calls = %#v", caller.calls) + } + startMS, _ := cmdutil.ParseISOTimeToMillis("start", start) + endMS, _ := cmdutil.ParseISOTimeToMillis("end", end) + want := map[string]any{ + "startTime": startMS, + "endTime": endMS, + "calendarId": "primary", + "limit": 7, + "cursor": "cursor-1", + } + if !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("calendar payload = %#v, want %#v", caller.calls[0].args, want) + } +} + +func TestParamAliasWriteCommandFinalPayload(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasE2E(t, caller, + "chat", "message", "send", + "--to-user", "D-recipient", + "--text", "hello alias", + "--uuid", "alias-e2e", + ) + if err != nil { + t.Fatalf("chat write alias E2E error = %v", err) + } + if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--to-user" || ctx.Corrections[0].Corrected != "--user" { + t.Fatalf("chat corrections = %#v", ctx.Corrections) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" { + t.Fatalf("chat calls = %#v", caller.calls) + } + payload := caller.calls[0].args + if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" { + t.Fatalf("chat payload identity fields = %#v", payload) + } + content, _ := payload["content"].(string) + if !strings.Contains(content, "hello alias") { + t.Fatalf("chat payload content = %q", content) + } + for _, forbidden := range []string{"user", "to-user", "userId"} { + if _, exists := payload[forbidden]; exists { + t.Fatalf("chat payload leaked pre-normalization field %q: %#v", forbidden, payload) + } + } +} + +func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) { + tests := []struct { + name string + command []string + tool string + required []string + }{ + { + name: "add emoji", + command: []string{"chat", "message", "add-emoji"}, + tool: "add_emoji_reaction", + required: []string{"--msg-id", "message-1", "--emoji", "like"}, + }, + { + name: "remove emoji", + command: []string{"chat", "message", "remove-emoji"}, + tool: "remove_emoji_reaction", + required: []string{"--msg-id", "message-1", "--emoji", "like"}, + }, + { + name: "add text emotion", + command: []string{"chat", "message", "add-text-emotion"}, + tool: "add_text_emotion", + required: []string{ + "--msg-id", "message-1", "--emotion-id", "emotion-1", + "--emotion-name", "like", "--text", "nice", "--background-id", "background-1", + }, + }, + { + name: "remove text emotion", + command: []string{"chat", "message", "remove-text-emotion"}, + tool: "remove_text_emotion", + required: []string{ + "--msg-id", "message-1", "--emotion-id", "emotion-1", + "--emotion-name", "like", "--text", "nice", "--background-id", "background-1", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + canonicalArgs := append([]string(nil), test.command...) + canonicalArgs = append(canonicalArgs, "--conversation-id", "conversation-1") + canonicalArgs = append(canonicalArgs, test.required...) + canonicalCaller := ¶mAliasCaptureCaller{} + if _, err := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...); err != nil { + t.Fatalf("canonical execution failed: %v", err) + } + if len(canonicalCaller.calls) != 1 || canonicalCaller.calls[0].tool != test.tool { + t.Fatalf("canonical calls = %#v, want one %s call", canonicalCaller.calls, test.tool) + } + if canonicalCaller.calls[0].args["openConversationId"] != "conversation-1" { + t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args) + } + + // Numeric --group-id is a different identifier domain and is covered + // by TestAllReviewedParamAliasGuardsReachRuntimeContract. + for _, alias := range []string{"chat-id", "open-conversation-id"} { + t.Run(alias, func(t *testing.T) { + aliasArgs := append([]string(nil), test.command...) + aliasArgs = append(aliasArgs, "--"+alias, "conversation-1") + aliasArgs = append(aliasArgs, test.required...) + aliasCaller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasE2E(t, aliasCaller, aliasArgs...) + if err != nil { + t.Fatalf("alias execution failed: %v", err) + } + if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" { + t.Fatalf("alias corrections = %#v", ctx) + } + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final calls differ\ncanonical=%#v\nalias=%#v", canonicalCaller.calls, aliasCaller.calls) + } + }) + } + }) + } +} + +func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) { + root := NewRootCommand() + engine := newPipelineEngine() + entries, err := cli.ReduceParamAliases(root) + if err != nil { + t.Fatalf("ReduceParamAliases() error = %v", err) + } + + chatEntries := 0 + aliasCases := 0 + guardCases := map[pipeline.FlagProtection]int{} + for _, entry := range entries { + if !strings.HasPrefix(entry.CLIPath, "chat ") { + continue + } + chatEntries++ + leaf := resolveParamLeaf(root, entry.CLIPath) + if leaf == nil { + t.Fatalf("generated chat parameter path %q is not runnable", entry.CLIPath) + } + + aliases := make([]string, 0, len(entry.Aliases)) + for emitted := range entry.Aliases { + aliases = append(aliases, emitted) + } + sort.Strings(aliases) + for _, emitted := range aliases { + emitted := emitted + canonical := entry.Aliases[emitted] + aliasCases++ + t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) { + value := paramFixtureValue(leaf, emitted, canonical) + rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value) + ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs) + if runErr != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):] + if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value { + t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args) + } + if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil { + t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr) + } + }) + } + + for _, guard := range []struct { + protection pipeline.FlagProtection + emitted []string + }{ + {protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked}, + {protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous}, + } { + for _, emitted := range guard.emitted { + emitted := emitted + protection := guard.protection + guardCases[protection]++ + t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) { + value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection)) + rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value) + ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs) + if runErr != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr) + } + morphed := cmdutil.Morph(emitted) + if ctx == nil || ctx.ProtectedFlags[morphed] != protection { + t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection) + } + assertLeftUnchanged(t, ctx, emitted, value) + flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):] + if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") { + t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr) + } + }) + } + } + } + + if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 { + t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous]) + } + t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous]) +} + +func TestIMUserIDHallucinationRoutes(t *testing.T) { + tests := []struct { + command string + want string + }{ + // These paths are reduced by the reviewed user_id concept. + {command: "chat +chat-role-query-user", want: "user"}, + {command: "chat +chat-role-set-user", want: "user"}, + {command: "chat +messages-list-direct", want: "user"}, + {command: "chat chmod", want: "user"}, + {command: "chat message list", want: "user"}, + {command: "chat message send", want: "user"}, + + // These commands already own a hidden --userId compatibility flag. + // The format/spelling handler rewrites --user-id to that real flag, and + // the command's existing flagOrFallback wiring preserves its semantics. + {command: "chat conversation-info", want: "userId"}, + {command: "chat group transfer-owner", want: "userId"}, + {command: "chat group-role query-user", want: "userId"}, + {command: "chat group-role remove-user", want: "userId"}, + {command: "chat group-role set-user", want: "userId"}, + {command: "chat group set-admin", want: "userId"}, + {command: "chat group-mute-member", want: "userId"}, + {command: "chat message read-status", want: "userId"}, + {command: "chat message search-advanced", want: "userId"}, + } + + for _, test := range tests { + t.Run(test.command, func(t *testing.T) { + root := NewRootCommand() + leaf := resolveParamLeaf(root, test.command) + if leaf == nil { + t.Fatalf("IM command %q is not runnable", test.command) + } + rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user") + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(strings.Fields(test.command)):] + if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" { + t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want) + } + if err := leaf.ParseFlags(flagArgs); err != nil { + t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err) + } + }) + } +} + +func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) { + const command = "chat message list-direct" + if _, ok := cli.LookupParamAlias(command); ok { + t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command) + } + + root := NewRootCommand() + leaf := resolveParamLeaf(root, command) + if leaf == nil || !leaf.Hidden { + t.Fatalf("%q must remain a live hidden compatibility command", command) + } + rawArgs := append(strings.Fields(command), "--user-id", "fixture-user") + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(strings.Fields(command)):] + if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err) + } +} + +func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) { + tests := []struct { + name string + tool string + canonicalArgs []string + aliasArgs []string + wantCorrections int + wantArgKeys []string + }{ + { + name: "calendar read with multiple aliases", + tool: "list_calendar_events", + canonicalArgs: []string{ + "--dry-run", "calendar", "event", "list", + "--start", "2026-03-10T14:00:00+08:00", + "--end", "2026-03-10T18:00:00+08:00", + "--calendar-id", "primary", "--limit", "7", "--cursor", "cursor-1", + }, + aliasArgs: []string{ + "--dry-run", "calendar", "event", "list", + "--date", "2026-03-10T14:00:00+08:00", + "--end-time", "2026-03-10T18:00:00+08:00", + "--calendar", "primary", "--max-results", "7", "--next-cursor", "cursor-1", + }, + wantCorrections: 1, + wantArgKeys: []string{"calendarId", "cursor", "endTime", "limit", "startTime"}, + }, + { + name: "chat write scoped recipient alias", + tool: "send_personal_message", + canonicalArgs: []string{ + "--dry-run", "chat", "message", "send", + "--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run", + }, + aliasArgs: []string{ + "--dry-run", "chat", "message", "send", + "--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run", + }, + wantCorrections: 1, + wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"}, + }, + { + name: "mail write folder id concept alias", + tool: "update_mail_folder", + canonicalArgs: []string{ + "--dry-run", "mail", "folder", "update", + "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", + }, + aliasArgs: []string{ + "--dry-run", "mail", "folder", "update", + "--email", "fixture@example.com", "--folder-id", "folder-1", "--name", "Fixture Folder", + }, + wantCorrections: 1, + wantArgKeys: []string{"email", "id", "name"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, canonical, canonicalAttempts, canonicalErr := executeParamAliasDryRunE2E(t, test.canonicalArgs...) + if canonicalErr != nil { + t.Fatalf("canonical dry-run failed: %v", canonicalErr) + } + ctx, alias, aliasAttempts, aliasErr := executeParamAliasDryRunE2E(t, test.aliasArgs...) + if aliasErr != nil { + t.Fatalf("alias dry-run failed: %v\ncontext=%#v", aliasErr, ctx) + } + + if ctx == nil || len(ctx.Corrections) != test.wantCorrections { + t.Fatalf("alias dry-run corrections = %#v, want %d", ctx, test.wantCorrections) + } + if len(canonicalAttempts) != 0 || len(aliasAttempts) != 0 { + t.Fatalf("dry-run reached command runner\ncanonical=%#v\nalias=%#v", canonicalAttempts, aliasAttempts) + } + for label, preview := range map[string]paramAliasDryRunPreview{"canonical": canonical, "alias": alias} { + if !preview.DryRun || preview.Executed { + t.Fatalf("%s preview execution state = %#v", label, preview) + } + if preview.Tool != test.tool { + t.Fatalf("%s preview tool = %q, want %q", label, preview.Tool, test.tool) + } + keys := make([]string, 0, len(preview.Arguments)) + for key := range preview.Arguments { + keys = append(keys, key) + } + sort.Strings(keys) + if !reflect.DeepEqual(keys, test.wantArgKeys) { + t.Fatalf("%s preview argument keys = %v, want %v", label, keys, test.wantArgKeys) + } + } + if !reflect.DeepEqual(alias, canonical) { + t.Fatalf("dry-run previews differ\ncanonical=%#v\nalias=%#v", canonical, alias) + } + }) + } +} + +func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) { + caller := ¶mAliasCaptureCaller{} + for _, args := range [][]string{ + {"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"}, + {"calendar", "event", "list", "--start", "2026-03-11", "--date", "2026-03-10"}, + } { + root := NewRootCommand() + root.SetArgs(args) + originalCaller := helpers.GetCaller() + helpers.InitDeps(caller) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + helpers.InitDeps(originalCaller) + var conflict *pipeline.FlagConflictError + if !stderrors.As(err, &conflict) { + t.Fatalf("RunPreParseArgs(%v) error = %v, want FlagConflictError (ctx=%#v)", args, err, ctx) + } + if conflict.Canonical != "start" || !reflect.DeepEqual(conflict.Spellings, []string{"date", "start"}) { + t.Fatalf("conflict = %#v", conflict) + } + } + if len(caller.calls) != 0 { + t.Fatalf("conflicting argv reached RunE/tool dispatch: %#v", caller.calls) + } +} + +func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) { + concepts, err := cli.LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + + paths := make(map[string]bool) + for _, concept := range concepts.Concepts { + for _, path := range concept.Commands { + paths[path] = true + } + } + sourceGuards := make(map[string]pipeline.FlagProtection) + for _, override := range concepts.Overrides { + paths[override.CommandPath] = true + for _, emitted := range override.Block { + sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionBlocked + } + for _, emitted := range override.Ambiguous { + sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionAmbiguous + } + } + orderedPaths := make([]string, 0, len(paths)) + for path := range paths { + orderedPaths = append(orderedPaths, path) + } + sort.Strings(orderedPaths) + + root := NewRootCommand() + engine := newPipelineEngine() + guardCounts := map[pipeline.FlagProtection]int{} + testedGuards := make(map[string]pipeline.FlagProtection) + for _, path := range orderedPaths { + entry, ok := cli.LookupParamAlias(path) + if !ok { + continue + } + leaf := resolveParamLeaf(root, path) + if leaf == nil { + t.Fatalf("generated guard path %q is not runnable", path) + } + + for _, protectionCase := range []struct { + protection pipeline.FlagProtection + emitted []string + }{ + {protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked}, + {protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous}, + } { + for _, emitted := range protectionCase.emitted { + protectionCase := protectionCase + emitted := emitted + key := path + "\x00" + cmdutil.Morph(emitted) + if previous, duplicate := testedGuards[key]; duplicate { + t.Fatalf("generated guard %q/%q is classified twice: %s and %s", path, emitted, previous, protectionCase.protection) + } + testedGuards[key] = protectionCase.protection + guardCounts[protectionCase.protection]++ + + t.Run(path+"/"+emitted, func(t *testing.T) { + value := "FIXTURE_VALUE" + pathArgs := strings.Fields(path) + args := append(append([]string(nil), pathArgs...), "--"+emitted, value) + ctx, runErr := pipeline.RunPreParseArgs(root, engine, args) + if runErr != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", args, runErr) + } + + morphed := cmdutil.Morph(emitted) + if ctx == nil || ctx.ProtectedFlags[morphed] != protectionCase.protection { + t.Fatalf("guard protection = %#v, want %s for %q", ctx, protectionCase.protection, morphed) + } + assertLeftUnchanged(t, ctx, emitted, value) + flagArgs := ctx.Args[len(pathArgs):] + if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") { + t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr) + } + }) + } + } + } + + for key, want := range sourceGuards { + if got, ok := testedGuards[key]; !ok || got != want { + t.Fatalf("reviewed source guard %q delivered as %s (present=%t), want %s", key, got, ok, want) + } + } + if guardCounts[pipeline.FlagProtectionBlocked] == 0 || guardCounts[pipeline.FlagProtectionAmbiguous] == 0 { + t.Fatalf("reviewed guard coverage is vacuous: blocked %d ambiguous %d", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous]) + } +} + +func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) { + for _, test := range []struct { + path string + emitted string + protection pipeline.FlagProtection + reason string + }{ + {path: "chat message list-by-sender", emitted: "time", protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag"}, + {path: "drive list", emitted: "space", protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag"}, + } { + test := test + t.Run(test.path+"/"+test.emitted, func(t *testing.T) { + value := "FIXTURE_VALUE" + args := append(strings.Fields(test.path), "--"+test.emitted, value) + caller := ¶mAliasCaptureCaller{} + ctx, executeErr := executeParamAliasE2E(t, caller, args...) + + morphed := cmdutil.Morph(test.emitted) + if ctx == nil || ctx.ProtectedFlags[morphed] != test.protection { + t.Fatalf("guard protection = %#v, want %s for %q", ctx, test.protection, morphed) + } + assertLeftUnchanged(t, ctx, test.emitted, value) + + var appErr *apperrors.Error + if !stderrors.As(executeErr, &appErr) { + t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr) + } + if appErr.Category != apperrors.CategoryValidation || appErr.Reason != test.reason || apperrors.ExitCode(executeErr) != 3 { + t.Fatalf("final error contract = category %q reason %q exit %d, want validation/%s/3", appErr.Category, appErr.Reason, apperrors.ExitCode(executeErr), test.reason) + } + if !strings.Contains(appErr.Message, "unknown flag: --"+test.emitted) || !strings.Contains(appErr.Message, "See 'dws "+test.path+" --help' for usage.") { + t.Fatalf("final error message = %q", appErr.Message) + } + if !strings.Contains(appErr.Hint, "--"+test.emitted) || !strings.Contains(appErr.Hint, "--help") { + t.Fatalf("final error hint = %q", appErr.Hint) + } + wantAction := "Run 'dws " + test.path + " --help' for valid flags" + if !reflect.DeepEqual(appErr.Actions, []string{wantAction}) || len(appErr.AvailableFlags) == 0 || appErr.Cause == nil { + t.Fatalf("final recovery fields = actions %v flags %v cause %v", appErr.Actions, appErr.AvailableFlags, appErr.Cause) + } + if len(caller.calls) != 0 { + t.Fatalf("guarded flag reached RunE/tool dispatch: %#v", caller.calls) + } + }) + } +} + +func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) { + err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error() + want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling` + if err != want { + t.Fatalf("FlagConflictError = %q, want %q", err, want) + } +} diff --git a/internal/app/param_alias_fixture_test.go b/internal/app/param_alias_fixture_test.go new file mode 100644 index 00000000..f6f732f1 --- /dev/null +++ b/internal/app/param_alias_fixture_test.go @@ -0,0 +1,220 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// TestParamAliasFixtureThroughEmbeddedDeliveryPath is the ⑥ regression gate. +// It reads the reviewed validation_fixture straight from the embedded concept +// dictionary and asserts every reviewed bad case through the REAL delivery +// path — not a generator unit test and not a reimplementation of the reduction +// logic: +// +// - the runtime PreParse engine built by newPipelineEngine() (the exact +// handler chain root.go installs, whose SemanticAliasHandler is wired to +// cli.LookupParamAlias over the embedded generated table), +// - one distribution-owned Cobra tree, reused because PreParse reads command +// and flag metadata but does not parse or mutate individual flag values, +// and +// - the embedded cli.LookupParamAlias query used to prove that a +// did-you-mean case is an intentional block/ambiguous guard rather than a +// name that merely happens to be absent from the table. +// +// Fixture expect semantics (see spec §⑥): +// - expect= : emitted must reduce to that canonical flag. +// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten. +// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten. +func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) { + concepts, err := cli.LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + if len(concepts.Fixture) == 0 { + t.Fatal("validation_fixture declares no cases; ⑥ gate would be vacuous") + } + + // The exact runtime handler chain (alias → semantic → sticky → + // paramname), with the semantic table sourced from the embedded generated + // snapshot. Build the distribution-owned tree once: constructing the full + // 800+ command tree for every fixture made the macOS race package exceed its + // 10-minute budget, while RunPreParseArgs itself only reads this tree. + engine := newPipelineEngine() + root := NewSchemaSourceRootCommand() + for _, c := range concepts.Fixture { + t.Run(c.Command+"/"+c.Emitted, func(t *testing.T) { + leaf := resolveParamLeaf(root, c.Command) + if leaf == nil { + t.Fatalf("fixture command %q is not a live Cobra command", c.Command) + } + // Fixture command paths carry no "dws" prefix; LookupParamAlias + // normalizes to the same key the generator used, so the runtime + // lookup is byte-identical to the build-time key. + entry, hasEntry := cli.LookupParamAlias(c.Command) + fixtureValue := paramFixtureValue(leaf, c.Emitted, c.Expect) + rawArgs := append(strings.Fields(c.Command), "--"+c.Emitted, fixtureValue) + root.SetArgs(rawArgs) + ctx, err := pipeline.RunPreParseArgs(root, engine, rawArgs) + if err != nil { + t.Fatalf("RunPreParseArgs error = %v", err) + } + if ctx == nil { + t.Fatal("RunPreParseArgs skipped a fixture command with real flags") + } + morphed := cmdutil.Morph(c.Emitted) + + switch c.Expect { + case "did-you-mean:ambiguous": + if !hasEntry || !entry.IsAmbiguous(morphed) { + t.Fatalf("%q on %q: expected co-occurrence guard (ambiguous) but embedded entry does not classify it; ambiguous=%v", c.Emitted, c.Command, entry.Ambiguous) + } + if commandHasRealFlagByMorph(leaf, morphed) { + t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command) + } + assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue) + case "did-you-mean:blocked": + if !hasEntry || !entry.IsBlocked(morphed) { + t.Fatalf("%q on %q: expected block guard but embedded entry does not classify it; blocked=%v", c.Emitted, c.Command, entry.Blocked) + } + if commandHasRealFlagByMorph(leaf, morphed) { + t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command) + } + assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue) + default: + // Real-flag expect: the reviewed canonical outcome is delivered + // one of two equally valid ways, and the gate accepts either + // (failing only on a genuine unknown-flag hallucination): + // 1. semantic rewrite — the emitted synonym is not a real flag, + // so the embedded table rewrites it to the canonical flag; or + // 2. native acceptance — the emitted synonym is still a genuine + // (usually hidden) real flag the command accepts directly and + // maps to the same entity via its fallback wiring. Native + // compatibility flags intentionally remain command-owned. + if !commandHasRealFlagByMorph(leaf, cmdutil.Morph(c.Expect)) { + t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command) + } + flagArgs := ctx.Args[len(strings.Fields(c.Command)):] + if len(flagArgs) < 2 || flagArgs[1] != fixtureValue { + t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args) + } + got := flagArgs[0] + gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0] + switch { + case got == "--"+c.Expect: + // (1) rewritten; the embedded table must agree. + if !hasEntry { + t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command) + } + if canon, hit := entry.ResolveAlias(morphed); !hit || canon != c.Expect { + t.Fatalf("embedded table ResolveAlias(%q) on %q = %q (hit=%v), want %q", morphed, c.Command, canon, hit, c.Expect) + } + case cmdutil.Morph(gotBare) == morphed && commandHasRealFlagByMorph(leaf, morphed): + // (2) not rewritten — only valid if the command natively + // accepts the emitted synonym as a real flag. + default: + t.Fatalf("%q on %q reduced to unexpected %q, want --%s or native --%s (args=%v)", c.Emitted, c.Command, got, c.Expect, c.Emitted, ctx.Args) + } + } + }) + } +} + +// assertLeftUnchanged verifies a guarded (blocked/ambiguous) synonym is never +// silently rewritten: the flag token and its value survive verbatim so the +// unknown-flag did-you-mean path can surface the reviewed candidates. +func assertLeftUnchanged(t *testing.T, ctx *pipeline.Context, emitted, value string) { + t.Helper() + flagIndex := -1 + for i, arg := range ctx.Args { + if arg == "--"+emitted || strings.HasPrefix(arg, "--"+emitted+"=") { + flagIndex = i + break + } + } + if flagIndex < 0 { + t.Fatalf("guarded synonym --%s disappeared: args=%v", emitted, ctx.Args) + } + if got := ctx.Args[flagIndex]; got != "--"+emitted { + t.Fatalf("guarded synonym --%s was rewritten to %q (must be left for did-you-mean): args=%v", emitted, got, ctx.Args) + } + if len(ctx.Args) <= flagIndex+1 || ctx.Args[flagIndex+1] != value { + t.Fatalf("guarded synonym --%s lost its value: args=%v", emitted, ctx.Args) + } + for _, corr := range ctx.Corrections { + if corr.Handler == "semantic-alias" && corr.Original == "--"+emitted { + t.Fatalf("guarded synonym --%s was corrected by %s (must not be): %+v", emitted, corr.Handler, corr) + } + } +} + +func paramFixtureValue(cmd *cobra.Command, emitted, expect string) string { + if cmd == nil { + return "FIXTURE_VALUE" + } + wanted := []string{emitted} + if !strings.HasPrefix(expect, "did-you-mean:") { + wanted = append(wanted, expect) + } + for _, name := range wanted { + var found *pflag.Flag + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if found == nil && cmdutil.Morph(flag.Name) == cmdutil.Morph(name) { + found = flag + } + }) + if found == nil { + continue + } + switch found.Value.Type() { + case "bool": + return "true" + case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64": + return "1" + } + } + return "FIXTURE_VALUE" +} + +// resolveParamLeaf resolves a fixture command path (no "dws" prefix, e.g. +// "chat message search-advanced") to its live Cobra command, or nil. +func resolveParamLeaf(root *cobra.Command, path string) *cobra.Command { + cmd, _, err := root.Find(strings.Fields(path)) + if err != nil || cmd == nil || cmd == root { + return nil + } + return cmd +} + +// commandHasRealFlagByMorph reports whether the command has any real flag +// (local or inherited, including hidden) whose Morph matches morphed — the same +// notion of "real flag" the build-time reducer uses to absorb legacy synonyms. +func commandHasRealFlagByMorph(cmd *cobra.Command, morphed string) bool { + found := false + check := func(f *pflag.Flag) { + if f.Name != "help" && cmdutil.Morph(f.Name) == morphed { + found = true + } + } + cmd.Flags().VisitAll(check) + cmd.InheritedFlags().VisitAll(check) + return found +} diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go new file mode 100644 index 00000000..b6f67a5e --- /dev/null +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -0,0 +1,339 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// paramAliasCompleteCommands is deliberately keyed by the exact reviewed +// fixture command path. Every argv is a complete, business-valid invocation: +// required companion flags are present, time and enum values are valid, and +// write commands use the capture caller rather than a real transport. The +// target canonical flag must occur exactly once so the test can replace only +// its spelling while holding every other input constant. +var paramAliasCompleteCommands = map[string][]string{ + "aitable +base-search": {"aitable", "+base-search", "--query", "fixture"}, + "aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"}, + "aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"}, + "aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"}, + "aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"}, + "aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"}, + "aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"}, + "attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, + "attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, + "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"}, + "chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"}, + "chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"}, + "chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"}, + "chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"}, + "chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, + "chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"}, + "chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, + "chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"}, + "chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"}, + "chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"}, + "chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"}, + "chat category create": {"chat", "category", "create", "--title", "Fixture Cat", "--yes"}, + "chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"}, + "chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"}, + "chat group members": {"chat", "group", "members", "--id", "fixture-conversation"}, + "chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"}, + "chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, + "chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"}, + "chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"}, + "chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"}, + "chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"}, + "chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"}, + "chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"}, + "chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"}, + "chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"}, + "chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"}, + "chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"}, + "chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"}, + "chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, + "chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"}, + "chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"}, + "chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"}, + "chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"}, + "chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"}, + "chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"}, + "chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"}, + "chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"}, + "chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, + "contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"}, + "contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"}, + "contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"}, + "contact +search-user": {"contact", "+search-user", "--query", "Fixture User"}, + "contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"}, + "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"}, + "dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"}, + "devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"}, + "ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"}, + "ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"}, + "ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"}, + "doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"}, + "doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"}, + "doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"}, + "drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"}, + "drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"}, + "mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"}, + "mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"}, + "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, + "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, + "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, + "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"}, + "oa +search-forms": {"oa", "+search-forms", "--query", "fixture"}, + "oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"}, + "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, +} + +// A command can expose more than one mutually exclusive canonical route. In +// that case the shared command template above cannot contain every canonical +// flag at once, so select a fixture-specific complete invocation here. +var paramAliasCompleteCommandVariants = map[string]map[string][]string{ + "chat message list": { + "user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, + }, + "chat message list-by-sender": { + "sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, + }, + "chat message send": { + "group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"}, + "file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"}, + }, +} + +// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM +// optimization. The dedicated gate below requires every one to remain active +// in the embedded generated table and equivalent at the final transport. +var paramAliasNewIMCases = []struct { + command string + emitted string + canonical string +}{ + {command: "chat +bot-find", emitted: "name", canonical: "query"}, + {command: "chat bot find", emitted: "name", canonical: "query"}, + {command: "chat +bot-search", emitted: "query", canonical: "name"}, + {command: "chat +bot-search", emitted: "current-page", canonical: "page"}, + {command: "chat +category-create", emitted: "name", canonical: "title"}, + {command: "chat +category-rename", emitted: "name", canonical: "title"}, + {command: "chat +messages-list-direct", emitted: "start", canonical: "time"}, + {command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"}, + {command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"}, + {command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"}, + {command: "chat +unread-chats", emitted: "limit", canonical: "count"}, + {command: "chat +unread-chats", emitted: "size", canonical: "count"}, + {command: "chat bot search", emitted: "query", canonical: "name"}, + {command: "chat bot search", emitted: "current-page", canonical: "page"}, + {command: "chat category create", emitted: "name", canonical: "title"}, + {command: "chat category create-smart", emitted: "title", canonical: "name"}, + {command: "chat category rename", emitted: "name", canonical: "title"}, + {command: "chat message list", emitted: "start", canonical: "time"}, + {command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"}, + {command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"}, + {command: "chat message list-favorites", emitted: "limit", canonical: "size"}, + {command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"}, + {command: "chat message list-unread-conversations", emitted: "size", canonical: "count"}, + {command: "chat message send", emitted: "file", canonical: "file-path"}, + {command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"}, + {command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"}, +} + +// paramAliasRepresentativePayloadCases keeps final transport coverage across +// old concept aliases, command overrides, native compatibility flags, read and +// write commands, and different products. Every reviewed alias is still +// checked through the embedded PreParse delivery path and against a complete +// business-valid command template. The separate IM gate below continues to +// execute every alias introduced by the current IM optimization. +// +// Keeping the older 100+ aliases at the contract layer avoids rebuilding and +// executing the complete 800+ command Root twice per spelling under -race. +// That duplicated command construction was enough to push the pre-existing +// macOS app suite beyond its package-level 10-minute timeout. +var paramAliasRepresentativePayloadCases = map[string]bool{ + paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read + paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias + paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias + paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias + paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag + paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override + paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias + paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias +} + +func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) { + concepts, err := cli.LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + + activeCommands := make(map[string]bool) + activeCases := 0 + executedRepresentatives := make(map[string]bool) + for _, fixture := range concepts.Fixture { + if strings.HasPrefix(fixture.Expect, "did-you-mean:") { + continue + } + activeCommands[fixture.Command] = true + activeCases++ + complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect) + if !ok { + t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted) + continue + } + canonicalArgs := append([]string(nil), complete...) + aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted) + if replacements != 1 { + t.Errorf("complete command for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, canonicalArgs) + continue + } + + caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) + if !paramAliasRepresentativePayloadCases[caseKey] { + continue + } + executedRepresentatives[caseKey] = true + t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { + + canonicalCaller := ¶mAliasCaptureCaller{} + _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) + if canonicalErr != nil { + t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) + } + if len(canonicalCaller.calls) == 0 { + t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) + } + + aliasCaller := ¶mAliasCaptureCaller{} + ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) + if aliasErr != nil { + t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) + } + if ctx == nil { + t.Fatal("complete alias command skipped PreParse") + } + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) + } + }) + } + + if activeCases == 0 { + t.Fatal("reviewed fixture contains no active alias cases") + } + for command := range paramAliasCompleteCommands { + if !activeCommands[command] { + t.Errorf("complete-command E2E template %q has no active reviewed fixture", command) + } + } + for command := range activeCommands { + if _, ok := paramAliasCompleteCommands[command]; !ok { + t.Errorf("active reviewed command %q has no complete-command E2E template", command) + } + } + if len(activeCommands) != len(paramAliasCompleteCommands) { + t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases) + } + for caseKey := range paramAliasRepresentativePayloadCases { + if !executedRepresentatives[caseKey] { + t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey) + } + } + if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) { + t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases)) + } +} + +func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { + activeAliases := 0 + for _, test := range paramAliasNewIMCases { + test := test + t.Run(test.command+"/"+test.emitted, func(t *testing.T) { + complete, ok := paramAliasCompleteCommand(test.command, test.canonical) + if !ok { + t.Fatal("reviewed IM alias has no complete-command E2E template") + } + canonicalArgs := append([]string(nil), complete...) + aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted) + if replacements != 1 { + t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs) + } + + canonicalCaller := ¶mAliasCaptureCaller{} + if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil { + t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls) + } + if len(canonicalCaller.calls) == 0 { + t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) + } + + entry, exists := cli.LookupParamAlias(test.command) + target, active := entry.ResolveAlias(test.emitted) + if !exists || !active { + return + } + if target != test.canonical { + t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical) + } + activeAliases++ + aliasCaller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) + if err != nil { + t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls) + } + if ctx == nil { + t.Fatal("complete alias command skipped PreParse") + } + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) + } + }) + } + if activeAliases != len(paramAliasNewIMCases) { + t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases)) + } +} + +func paramAliasCompleteCommand(command, canonical string) ([]string, bool) { + complete, ok := paramAliasCompleteCommands[command] + if variants := paramAliasCompleteCommandVariants[command]; variants != nil { + if variant, exists := variants[canonical]; exists { + return variant, true + } + } + return complete, ok +} + +func paramAliasPayloadCaseKey(command, emitted string) string { + return command + "\x00" + emitted +} + +func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) { + t.Helper() + return executeParamAliasE2E(t, caller, args...) +} + +func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) { + out := append([]string(nil), args...) + replacements := 0 + for index, arg := range out { + if arg == "--"+canonical { + out[index] = "--" + emitted + replacements++ + continue + } + if strings.HasPrefix(arg, "--"+canonical+"=") { + out[index] = "--" + emitted + strings.TrimPrefix(arg, "--"+canonical) + replacements++ + } + } + return out, replacements +} diff --git a/internal/app/param_concept_calendar_pilot_test.go b/internal/app/param_concept_calendar_pilot_test.go new file mode 100644 index 00000000..11ef25a2 --- /dev/null +++ b/internal/app/param_concept_calendar_pilot_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist locks the +// boundary between the command's original hidden compatibility flags and the +// new central semantic normalizer. Existing real flags stay untouched and are +// handled by calendar.go's flagOrFallback chain; only spellings that are not +// real flags (for example --date, --from, and --since) are rewritten centrally. +func TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist(t *testing.T) { + engine := newPipelineEngine() + + cases := []struct { + emitted string + value string + canonical string + isInt bool + native bool + }{ + // Existing Calendar compatibility flags remain native. + {"start-time", "2026-03-10T14:00:00+08:00", "start", false, true}, + {"startTime", "2026-03-10T14:00:00+08:00", "start", false, true}, + {"start_time", "2026-03-10T14:00:00+08:00", "start", false, true}, + {"start-date", "2026-03-10T14:00:00+08:00", "start", false, true}, + {"min-time", "2026-03-10T14:00:00+08:00", "start", false, true}, + {"time-min", "2026-03-10T14:00:00+08:00", "start", false, true}, + {"end-time", "2026-03-10T18:00:00+08:00", "end", false, true}, + {"endTime", "2026-03-10T18:00:00+08:00", "end", false, true}, + {"end-date", "2026-03-10T18:00:00+08:00", "end", false, true}, + {"max-time", "2026-03-10T18:00:00+08:00", "end", false, true}, + {"time-max", "2026-03-10T18:00:00+08:00", "end", false, true}, + {"max-results", "50", "limit", true, true}, + {"maxResults", "50", "limit", true, true}, + {"page-size", "50", "limit", true, true}, + {"size", "50", "limit", true, true}, + {"next-cursor", "TOKEN123", "cursor", false, true}, + {"nextCursor", "TOKEN123", "cursor", false, true}, + {"page-token", "TOKEN123", "cursor", false, true}, + {"next-token", "TOKEN123", "cursor", false, true}, + {"calendar", "primary", "calendar-id", false, true}, + {"calendarId", "primary", "calendar-id", false, true}, + // These spellings have no native Calendar flag and remain central aliases. + {"from", "2026-03-10T14:00:00+08:00", "start", false, false}, + {"since", "2026-03-10T14:00:00+08:00", "start", false, false}, + {"date", "2026-03-10T14:00:00+08:00", "start", false, false}, + } + + for _, tc := range cases { + t.Run(tc.emitted, func(t *testing.T) { + // Fresh command tree per case: ParseFlags mutates flag state. + root := NewRootCommand() + target := mustFindCommand(t, root, "calendar", "event", "list") + + ctx := &pipeline.Context{ + Args: []string{"calendar", "event", "list", "--" + tc.emitted, tc.value}, + Command: target.CommandPath(), + FlagSpecs: pipeline.FlagInfoFromCommand(target), + } + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("PreParse error = %v", err) + } + + parsedFlag := tc.canonical + if tc.native { + parsedFlag = tc.emitted + if len(ctx.Corrections) != 0 { + t.Fatalf("native --%s triggered central corrections: %#v", tc.emitted, ctx.Corrections) + } + if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.emitted+" "+tc.value) { + t.Fatalf("native --%s did not survive unchanged: args = %v", tc.emitted, ctx.Args) + } + } else { + if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) { + t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args) + } + if len(ctx.Corrections) != 1 { + t.Fatalf("central --%s corrections = %#v, want one", tc.emitted, ctx.Corrections) + } + } + + flagArgs := ctx.Args[3:] + if err := target.ParseFlags(flagArgs); err != nil { + t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err) + } + if tc.isInt { + got, err := target.Flags().GetInt(parsedFlag) + if err != nil || got != 50 { + t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err) + } + } else { + got, err := target.Flags().GetString(parsedFlag) + if err != nil || got != tc.value { + t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value) + } + } + }) + } +} + +// TestCalendarEventListKeepsCountExclusion pins the reviewed decision that +// pagination_size deliberately excludes --count (count != limit). The kept +// hidden --count flag must be left untouched by the pipeline: it is a real +// flag, not a concept member, so it must not be rewritten to --limit. +func TestCalendarEventListKeepsCountExclusion(t *testing.T) { + engine := newPipelineEngine() + root := NewRootCommand() + target := mustFindCommand(t, root, "calendar", "event", "list") + + ctx := &pipeline.Context{ + Args: []string{"calendar", "event", "list", "--count", "5"}, + Command: target.CommandPath(), + FlagSpecs: pipeline.FlagInfoFromCommand(target), + } + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("PreParse error = %v", err) + } + if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--count 5") { + t.Fatalf("--count must not be rewritten: args = %v", ctx.Args) + } + if len(ctx.Corrections) != 0 { + t.Fatalf("--count triggered corrections %#v, want none", ctx.Corrections) + } + if err := target.ParseFlags(ctx.Args[3:]); err != nil { + t.Fatalf("Cobra ParseFlags error = %v", err) + } + if got, _ := target.Flags().GetInt("count"); got != 5 { + t.Fatalf("flag --count = %d, want 5", got) + } +} diff --git a/internal/app/preparse_presentation_test.go b/internal/app/preparse_presentation_test.go new file mode 100644 index 00000000..361d8d11 --- /dev/null +++ b/internal/app/preparse_presentation_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + stderrors "errors" + "io" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "camel case", args: []string{"--dryRun", "chat", "bot", "find", "--help"}}, + {name: "fuzzy boolean", args: []string{"--dry-rnu", "chat", "bot", "find", "--help"}}, + {name: "fuzzy value", args: []string{"--profle", "corp:user", "chat", "bot", "find", "--help"}}, + {name: "sticky value", args: []string{"--timeout30", "chat", "bot", "find", "--help"}}, + {name: "sticky boolean value", args: []string{"--verbosefalse", "chat", "bot", "find", "--help"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", test.args, err) + } + if ctx == nil || ctx.Command != "dws chat bot find" || len(ctx.Corrections) == 0 { + t.Fatalf("RunPreParseArgs(%v) context = %#v", test.args, ctx) + } + if err := root.Execute(); err != nil { + t.Fatalf("corrected leading persistent flag failed: %v", err) + } + }) + } +} + +func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) { + root := NewSchemaSourceRootCommand() + args := []string{ + "chat", "message", "send", + "--user-id", "123", "--user", "456", "--text", "hi", + "--format", "table", "--debug", + } + _, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err == nil { + t.Fatal("alias/canonical conflict unexpectedly succeeded") + } + if wantsJSONErrors(root) { + t.Fatal("--format table was not applied before rendering the PreParse error") + } + if got := resolveVerbosity(root); got != apperrors.VerbosityDebug { + t.Fatalf("PreParse error verbosity = %v, want debug", got) + } + + err = newPreParseValidationError(err) + var structured *apperrors.Error + if !stderrors.As(err, &structured) { + t.Fatalf("PreParse validation error = %T, want *errors.Error", err) + } + if strings.Contains(structured.Message, "pipeline") || strings.Contains(structured.Message, "semantic-alias") || + strings.Contains(structured.Cause.Error(), "pipeline") || strings.Contains(structured.Cause.Error(), "semantic-alias") { + t.Fatalf("internal pipeline identity leaked to user error: message=%q cause=%q", structured.Message, structured.Cause) + } + var conflict *pipeline.FlagConflictError + if !stderrors.As(err, &conflict) { + t.Fatalf("PreParse validation error lost FlagConflictError: %v", err) + } + var output bytes.Buffer + if printErr := printExecutionError(root, &output, &output, err); printErr != nil { + t.Fatalf("printExecutionError() error = %v", printErr) + } + rendered := output.String() + if strings.HasPrefix(strings.TrimSpace(rendered), "{") { + t.Fatalf("--format table rendered JSON:\n%s", rendered) + } + if !strings.Contains(rendered, "Reason: parameter_conflict") || !strings.Contains(rendered, "Cause:") { + t.Fatalf("--debug details missing from early error:\n%s", rendered) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index 8f407aae..a437d01c 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -76,6 +76,7 @@ var ( rootPluginLoadHooks = (*plugin.Plugin).LoadHooks rootPluginSyncSkills = plugin.SyncSkills rootAuthLoadTokenData = authpkg.LoadTokenData + rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags ) // Execute runs the root command and returns the process exit code. @@ -114,7 +115,11 @@ func Execute() (exitCode int) { // Run PreParse handlers on raw argv before Cobra parses flags. // This corrects model-generated errors like --userId → --user-id // and --limit100 → --limit 100. - rootRunPreParse(root, engine) + if err := rootRunPreParse(root, engine); err != nil { + err = newPreParseValidationError(err) + _ = printExecutionError(root, os.Stdout, os.Stderr, err) + return apperrors.ExitCode(err) + } executed, err := rootExecuteCommand(root) if err != nil { @@ -136,6 +141,22 @@ func Execute() (exitCode int) { return 0 } +// newPreParseValidationError keeps pipeline handler identity in internal logs +// while exposing only the underlying parameter-domain error to CLI users. +func newPreParseValidationError(err error) error { + userErr := err + var handlerErr *pipeline.HandlerError + if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil { + userErr = handlerErr.Unwrap() + } + return apperrors.NewValidation( + userErr.Error(), + apperrors.WithReason("parameter_conflict"), + apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."), + apperrors.WithCause(userErr), + ) +} + func isUnknownCommandError(err error) bool { return err != nil && strings.Contains(err.Error(), "unknown command") } @@ -183,6 +204,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error { // 无论哪种格式,子串 "--help' for usage." 都可被检索到。 tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath()) msgWithTail := errMsg + tail + if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok { + hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath()) + reason := "blocked_flag" + if protection == pipeline.FlagProtectionAmbiguous { + hint = fmt.Sprintf("Parameter --%s is ambiguous on %q and cannot be normalized safely; choose the intended explicit flag from --help.", flag, cmd.CommandPath()) + reason = "ambiguous_flag" + } + return apperrors.NewValidation( + msgWithTail, + apperrors.WithHint(hint), + apperrors.WithReason(reason), + apperrors.WithCause(err), + apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())), + apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...), + ) + } // Common flag aliases and suggestions suggestions := map[string]string{ @@ -231,6 +268,33 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error { return fmt.Errorf("%s%s", errMsg, tail) } +func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline.FlagProtection, bool) { + if cmd == nil { + return "", "", false + } + const prefix = "unknown flag: --" + idx := strings.Index(errMsg, prefix) + if idx < 0 { + return "", "", false + } + flag := strings.TrimSpace(errMsg[idx+len(prefix):]) + if i := strings.IndexAny(flag, " =\n\t"); i >= 0 { + flag = flag[:i] + } + entry, ok := cli.LookupParamAlias(cmd.CommandPath()) + if !ok { + return "", "", false + } + morphed := cmdutil.Morph(flag) + if entry.IsBlocked(morphed) { + return flag, pipeline.FlagProtectionBlocked, true + } + if entry.IsAmbiguous(morphed) { + return flag, pipeline.FlagProtectionAmbiguous, true + } + return "", "", false +} + func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error { var raw apperrors.RawStderrError if stderrors.As(err, &raw) { @@ -339,7 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loader := cli.EnvironmentLoader{ LookupEnv: os.LookupEnv, } - runner := newCommandRunnerWithFlags(loader, flags) + runner := rootNewCommandRunnerWithFlags(loader, flags) root := &cobra.Command{ Use: "dws", @@ -453,11 +517,38 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, configureRootHelp(root) // Set custom flag error handler for better UX root.SetFlagErrorFunc(flagErrorWithSuggestions) + installReviewedFlagProtectionHandlers(root) root.SetContext(rootCtx) return root } +// installReviewedFlagProtectionHandlers makes reviewed blocked/ambiguous +// parameters authoritative even when an older command subtree has installed a +// local FlagErrorFunc. Commands without a reviewed guard keep their existing +// handler or inherit the root handler as before. +func installReviewedFlagProtectionHandlers(root *cobra.Command) { + if root == nil { + return + } + var visit func(*cobra.Command) + visit = func(cmd *cobra.Command) { + if entry, ok := cli.LookupParamAlias(cmd.CommandPath()); ok && (len(entry.Blocked) > 0 || len(entry.Ambiguous) > 0) { + previous := cmd.FlagErrorFunc() + cmd.SetFlagErrorFunc(func(current *cobra.Command, err error) error { + if _, _, guarded := reviewedFlagProtection(current, err.Error()); guarded { + return flagErrorWithSuggestions(current, err) + } + return previous(current, err) + }) + } + for _, child := range cmd.Commands() { + visit(child) + } + } + visit(root) +} + func preparseProfileFlag(args []string) string { args, _ = normalizeProfileFlagArgs(args) for i := 0; i < len(args); i++ { @@ -1252,13 +1343,27 @@ func newPipelineEngine() *pipeline.Engine { // Register handler runs during command tree building. handlers.RegisterHandler{}, - // PreParse handlers run in order: alias → sticky → paramname. - // Alias normalises case first (--userId → --user-id), then - // sticky splits glued values (--limit100 → --limit 100), then - // paramname fixes near-miss typos (--limt → --limit). + // PreParse handlers run in order: alias → semantic → sticky → paramname + // → boolvalue. + // Alias normalises case first (--userId → --user-id), then semantic + // resolves reviewed synonyms to the real flag (--keyword → --query), + // then sticky splits glued values (--limit100 → --limit 100), then + // paramname fixes near-miss typos (--limt → --limit). Boolvalue runs + // last so detached values for every real boolean flag (for example + // `--dry-run false`) become explicit `--flag=false` tokens before pflag + // can interpret the bare flag as true. handlers.AliasHandler{}, + handlers.SemanticAliasHandler{ + // Inject the build-time reduced alias table with native types so + // the handler package stays decoupled from cli. + Lookup: func(rawCommandPath string) (map[string]string, []string, []string, bool) { + e, ok := cli.LookupParamAlias(rawCommandPath) + return e.Aliases, e.Blocked, e.Ambiguous, ok + }, + }, handlers.StickyHandler{}, handlers.ParamNameHandler{}, + handlers.BoolValueHandler{}, // PostParse handlers normalise structured values. handlers.ParamValueHandler{}, diff --git a/internal/app/root_full_coverage_test.go b/internal/app/root_full_coverage_test.go index 4876059e..f9e2e6c2 100644 --- a/internal/app/root_full_coverage_test.go +++ b/internal/app/root_full_coverage_test.go @@ -40,7 +40,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) { }) os.Args = []string{"dws"} rootNormalizeProcessProfileArgs = func() func() { return func() {} } - rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {} + rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil } rootResetRecoveryState = func() {} rootStopAllStdioClients = func() {} rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command { @@ -52,6 +52,12 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) { t.Fatalf("successful Execute code = %d", code) } + rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") } + if code := Execute(); code == 0 { + t.Fatal("pre-parse conflict returned zero") + } + rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil } + wantErr := errors.New("unknown command missing") rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} } rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr } diff --git a/internal/cli/gen.go b/internal/cli/gen.go index b07a5824..c7309f7e 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -11,11 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -// gen.go is the single entry point for schema metadata generation. It isolates +// gen.go is the single entry point for reviewed CLI asset generation. It isolates // all //go:generate pragmas from business code so that: // - schema_agent_metadata.go / schema_catalog.go contain only types + embed. // - Generation is a standalone process (make generate-schema triggers this). -// - The 6-input → 1-output contract is documented in one place. +// - The authored-input → generated-output contract is documented in one place. // // Generation inputs (authored, reviewed): // 1. schema_command_registry/ identity (canonical/aliases/navigation) @@ -23,11 +23,13 @@ // 3. schema_hints/selection/*.json selection (use_when/avoid_when) // 4. schema_mcp_metadata.json MCP server tool definitions // 5. schema_parameter_bindings.json parameter type/property mappings -// 6. cobra command tree (Go runtime) flags/usage/required (reflected) +// 6. param_concepts.json + schema reviewed parameter synonym policy +// 7. 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 package cli @@ -36,3 +38,4 @@ package cli // package cached by the preceding metadata generator with the old embedded // 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 diff --git a/internal/cli/generate_entrypoint_contract_test.go b/internal/cli/generate_entrypoint_contract_test.go new file mode 100644 index 00000000..cc86e1f8 --- /dev/null +++ b/internal/cli/generate_entrypoint_contract_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read internal/cli: %v", err) + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".go" || name == "gen.go" { + continue + } + content, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + for _, line := range bytes.Split(content, []byte("\n")) { + if strings.HasPrefix(strings.TrimSpace(string(line)), "//go:generate") { + t.Errorf("%s contains //go:generate; all directives must stay in gen.go", name) + } + } + } + + content, err := os.ReadFile("gen.go") + if err != nil { + t.Fatalf("read gen.go: %v", err) + } + for _, generator := range []string{ + "cmd_schema_agent_metadata", + "cmd_schema_catalog", + "cmd_param_aliases", + } { + if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) { + t.Errorf("gen.go does not register %s", generator) + } + } +} diff --git a/internal/cli/param_aliases.go b/internal/cli/param_aliases.go new file mode 100644 index 00000000..d98e7ac6 --- /dev/null +++ b/internal/cli/param_aliases.go @@ -0,0 +1,427 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ParamAliasEntry is the reduced parameter-alias table for one runnable Cobra +// leaf. It is the typed value the build-time generator serializes into +// param_aliases_generated.go and that the runtime normalizer (P2) consumes. +// +// Aliases maps an already-morphed emitted name to the command's canonical real +// flag; the runtime looks up Morph(emitted) here to resolve a synonym. Blocked +// lists morphed names that must never be reduced (they route to did-you-mean), +// and Ambiguous lists morphed names that a reviewed co-occurrence guard leaves +// unresolved on purpose. +type ParamAliasEntry struct { + CLIPath string `json:"cli_path"` + Aliases map[string]string `json:"aliases,omitempty"` + Blocked []string `json:"blocked,omitempty"` + Ambiguous []string `json:"ambiguous,omitempty"` +} + +// ReduceParamAliases resolves the reviewed concept dictionary against every +// runnable leaf's real flags and returns the per-command alias table. It is the +// single source of the reduction algorithm, shared by the generator and tests +// so the build-time (intersection) and generated views can never disagree. +// +// The reduction is deliberately mechanical (no NLU): for each concept it morphs +// the concept members (plus any command-bound generic flag) and intersects them +// with the command's morphed real flags. An intersection of exactly one real +// flag yields aliases onto it; two or more real flags is a co-occurrence that +// must be an explicitly reviewed `ambiguous` entry or generation fails. Command +// scoped aliases override, blocks are removed and recorded, and every override +// path and target is validated against the live tree. +func ReduceParamAliases(root *cobra.Command) ([]ParamAliasEntry, error) { + concepts, err := LoadParamConcepts() + if err != nil { + return nil, fmt.Errorf("load reviewed parameter concepts: %w", err) + } + if root == nil { + return nil, fmt.Errorf("parameter alias source root is nil") + } + + overrideByPath := make(map[string]CommandOverride, len(concepts.Overrides)) + for _, ov := range concepts.Overrides { + overrideByPath[ov.CommandPath] = ov + } + usedOverride := make(map[string]bool, len(overrideByPath)) + conceptsByPath := make(map[string][]Concept) + usedConceptScope := make(map[string]bool) + for _, concept := range concepts.Concepts { + for _, path := range concept.Commands { + conceptsByPath[path] = append(conceptsByPath[path], concept) + } + } + + var problems []string + var entries []ParamAliasEntry + + walkRunnableParamCommands(root, func(leaf *cobra.Command) { + path := normalizeSchemaCLIPath(leaf.CommandPath()) + realByMorph := realFlagsByMorph(leaf) + ov, hasOverride := overrideByPath[path] + if hasOverride { + usedOverride[path] = true + } + scopedConcepts := conceptsByPath[path] + for _, concept := range scopedConcepts { + usedConceptScope[concept.ID+"\x00"+path] = true + if !conceptHasRealFlag(concept, ov, realByMorph) { + problems = append(problems, fmt.Sprintf("concept %q reviewed command %q has no matching real flag or reviewed bind", concept.ID, path)) + } + } + + entry, entryProblems := reduceLeafParamAliases(path, realByMorph, scopedConcepts, ov) + problems = append(problems, entryProblems...) + if entry != nil { + entries = append(entries, *entry) + } + }) + + for path := range overrideByPath { + if !usedOverride[path] { + problems = append(problems, fmt.Sprintf("command_override %q does not match any runnable Cobra leaf", path)) + } + } + for _, concept := range concepts.Concepts { + for _, path := range concept.Commands { + if !usedConceptScope[concept.ID+"\x00"+path] { + problems = append(problems, fmt.Sprintf("concept %q command scope %q does not match any runnable Cobra command", concept.ID, path)) + } + } + } + + if len(problems) > 0 { + sort.Strings(problems) + return nil, fmt.Errorf("parameter alias reduction failed:\n - %s", strings.Join(problems, "\n - ")) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].CLIPath < entries[j].CLIPath }) + return entries, nil +} + +// walkRunnableParamCommands invokes fn for every runnable command in the tree, +// including runnable parents such as `chat group members` that expose their own +// flags while also owning subcommands. Parameter aliasing applies to any command +// that accepts flags, which is broader than the schema's leaf-only traversal. +func walkRunnableParamCommands(root *cobra.Command, fn func(*cobra.Command)) { + if root == nil { + return + } + var walk func(*cobra.Command) + walk = func(cmd *cobra.Command) { + if cmd.Runnable() { + fn(cmd) + } + for _, sub := range cmd.Commands() { + if sub.Name() == "help" { + continue + } + if !sub.IsAvailableCommand() && !hasRuntimeSchemaCommand(sub) { + continue + } + walk(sub) + } + } + walk(root) +} + +// realFlag is one of a leaf's real flags, remembering whether it is hidden so +// the reduction can treat a hidden legacy alias flag (for example a hand-written +// --base living next to the visible --base-id) as an absorbable synonym rather +// than a genuine co-occurrence. +type realFlag struct { + name string + hidden bool +} + +// realFlagsByMorph maps each of a leaf's real flags (local + inherited) by its +// morphed name to the real flags that share that morph. +func realFlagsByMorph(leaf *cobra.Command) map[string][]realFlag { + byMorph := make(map[string][]realFlag) + visitManualAgentCommandFlags(leaf, func(flag *pflag.Flag) { + if flag == nil || flag.Name == "help" { + return + } + key := cmdutil.Morph(flag.Name) + byMorph[key] = appendRealFlag(byMorph[key], realFlag{name: flag.Name, hidden: flag.Hidden}) + }) + return byMorph +} + +// reduceLeafParamAliases computes one leaf's alias entry and returns any +// contract problems. A nil entry means the leaf produced no aliases, blocks, or +// ambiguous guards. +func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, concepts []Concept, ov CommandOverride) (*ParamAliasEntry, []string) { + var problems []string + aliasMap := make(map[string]string) + blockedSet := make(map[string]bool) + excludedSet := make(map[string]bool) + pendingReview := ov.Confirm || ov.Investigate + + for boundFlag, conceptID := range ov.Bind { + if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; !ok { + problems = append(problems, fmt.Sprintf("command_override %q binds %q to concept %q but %q is not a real flag", path, boundFlag, conceptID, boundFlag)) + } + } + + // (a) Concept auto-reduction. The caller has already admitted only the + // concepts whose reviewed command scope contains this exact leaf. + for _, concept := range concepts { + eff := make(map[string]bool, len(concept.Members)+2) + for _, member := range concept.Members { + eff[cmdutil.Morph(member)] = true + } + for boundFlag, conceptID := range ov.Bind { + if conceptID == concept.ID { + if pendingReview { + for _, member := range concept.Members { + morphed := cmdutil.Morph(member) + if _, isReal := realByMorph[morphed]; !isReal { + blockedSet[morphed] = true + } + } + } else { + eff[cmdutil.Morph(boundFlag)] = true + } + } + } + + // Gather the concept's candidate real flags on this command, then + // choose a canonical. A single visible real flag wins and absorbs the + // rest (including hidden legacy alias flags). Two or more visible real + // flags is a genuine co-occurrence that must be reviewed. + var candidates []realFlag + for key := range eff { + candidates = append(candidates, realByMorph[key]...) + } + if len(candidates) == 0 { + continue + } + visible := distinctRealNames(candidates, true) + var canon string + switch len(visible) { + case 1: + canon = visible[0] + case 0: + names := distinctRealNames(candidates, false) + if len(names) != 1 { + continue + } + canon = names[0] + default: + // Genuine co-occurrence: this concept intersects two or more + // visible real flags, so it cannot be auto-reduced. Require that + // every emittable synonym of THIS concept (a concept member that is + // not itself a real flag on the command) is acknowledged in the + // reviewed ambiguous whitelist. Checking only that the command has + // some ambiguous entry would let one concept's whitelist silently + // vouch for a different concept's unreviewed co-occurrence. + ambiguousSet := make(map[string]bool, len(ov.Ambiguous)) + for _, a := range ov.Ambiguous { + ambiguousSet[cmdutil.Morph(a)] = true + } + var unreviewed []string + for m := range eff { + if _, isReal := realByMorph[m]; isReal { + continue + } + if !ambiguousSet[m] { + unreviewed = append(unreviewed, m) + } + } + if len(unreviewed) > 0 { + sort.Strings(visible) + sort.Strings(unreviewed) + problems = append(problems, fmt.Sprintf("command %q concept %q intersects visible real flags %s; unreviewed emittable members %s must be listed in the ambiguous whitelist", path, concept.ID, strings.Join(visible, ","), strings.Join(unreviewed, ","))) + } + continue + } + for m := range eff { + if _, isReal := realByMorph[m]; isReal { + continue + } + if prev, ok := aliasMap[m]; ok && prev != canon { + problems = append(problems, fmt.Sprintf("command %q emitted %q reduces to both %q and %q", path, m, prev, canon)) + continue + } + aliasMap[m] = canon + } + // Excludes are not passive prose: once this concept is active on a + // reviewed command, a non-real excluded spelling is protected from + // downstream fuzzy correction. A real flag is left alone because it + // already has an independently valid command-local meaning. + for _, exclude := range concept.Excludes { + morphed := cmdutil.Morph(exclude) + if _, isReal := realByMorph[morphed]; !isReal { + excludedSet[morphed] = true + } + } + } + for excluded := range excludedSet { + if _, isAlias := aliasMap[excluded]; !isAlias { + blockedSet[excluded] = true + } + } + + // (b) Command scoped aliases override concept reductions. + for emitted, target := range ov.ScopedAliases { + morphedEmitted := cmdutil.Morph(emitted) + reals, ok := realByMorph[cmdutil.Morph(target)] + if !ok { + problems = append(problems, fmt.Sprintf("command_override %q scoped alias %q->%q targets %q which is not a real flag", path, emitted, target, target)) + continue + } + if _, sourceIsReal := realByMorph[morphedEmitted]; sourceIsReal { + problems = append(problems, fmt.Sprintf("command_override %q scoped alias source %q is already a real flag; keep its native compatibility path or remove it before enabling semantic rewrite", path, emitted)) + continue + } + if pendingReview { + delete(aliasMap, morphedEmitted) + blockedSet[morphedEmitted] = true + continue + } + delete(blockedSet, morphedEmitted) + aliasMap[morphedEmitted] = canonicalRealName(reals) + } + + // (c) Blocks are removed from the alias map and recorded for did-you-mean. + for _, b := range ov.Block { + mb := cmdutil.Morph(b) + if _, isReal := realByMorph[mb]; isReal { + problems = append(problems, fmt.Sprintf("command_override %q blocks %q but it is already a real flag; blocking must not disable a canonical/native parameter", path, b)) + continue + } + delete(aliasMap, mb) + blockedSet[mb] = true + } + ambiguous := make([]string, 0, len(ov.Ambiguous)) + for _, a := range ov.Ambiguous { + ma := cmdutil.Morph(a) + if _, isReal := realByMorph[ma]; isReal { + problems = append(problems, fmt.Sprintf("command_override %q marks %q ambiguous but it is already a real flag", path, a)) + continue + } + if canon, ok := aliasMap[ma]; ok { + problems = append(problems, fmt.Sprintf("command %q name %q is both auto-reduced to %q and marked ambiguous; a name cannot be aliased and ambiguous at once", path, a, canon)) + } + delete(aliasMap, ma) + delete(blockedSet, ma) + ambiguous = append(ambiguous, ma) + } + blocked := make([]string, 0, len(blockedSet)) + for b := range blockedSet { + blocked = append(blocked, b) + } + + if len(aliasMap) == 0 && len(blocked) == 0 && len(ambiguous) == 0 { + return nil, problems + } + return &ParamAliasEntry{ + CLIPath: path, + Aliases: aliasMap, + Blocked: sortedUnique(blocked), + Ambiguous: sortedUnique(ambiguous), + }, problems +} + +func conceptHasRealFlag(concept Concept, ov CommandOverride, realByMorph map[string][]realFlag) bool { + for _, member := range concept.Members { + if _, ok := realByMorph[cmdutil.Morph(member)]; ok { + return true + } + } + for boundFlag, conceptID := range ov.Bind { + if conceptID == concept.ID { + if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; ok { + return true + } + } + } + return false +} + +func appendRealFlag(list []realFlag, value realFlag) []realFlag { + for i, existing := range list { + if existing.name == value.name { + // Prefer the visible record if any registration is visible. + if existing.hidden && !value.hidden { + list[i] = value + } + return list + } + } + list = append(list, value) + sort.Slice(list, func(i, j int) bool { return list[i].name < list[j].name }) + return list +} + +// distinctRealNames returns the sorted unique flag names among candidates, +// optionally restricted to visible (non-hidden) flags. +func distinctRealNames(candidates []realFlag, visibleOnly bool) []string { + seen := make(map[string]bool, len(candidates)) + out := make([]string, 0, len(candidates)) + for _, c := range candidates { + if visibleOnly && c.hidden { + continue + } + if seen[c.name] { + continue + } + seen[c.name] = true + out = append(out, c.name) + } + sort.Strings(out) + return out +} + +// canonicalRealName chooses the canonical target among real flags sharing a +// morph key, preferring a visible flag over a hidden legacy alias. +func canonicalRealName(reals []realFlag) string { + for _, r := range reals { + if !r.hidden { + return r.name + } + } + if len(reals) > 0 { + return reals[0].name + } + return "" +} + +func sortedUnique(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]bool, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + if seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go new file mode 100644 index 00000000..36c4d8c0 --- /dev/null +++ b/internal/cli/param_aliases_generated.go @@ -0,0 +1,1515 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +// Code generated by cmd_param_aliases; DO NOT EDIT. +// Source: internal/cli/param_concepts.json reduced against the live Cobra tree. +// Regenerate with `make generate-schema` (or `go generate ./internal/cli`). + +package cli + +// generatedParamAliases is the per-command parameter-alias table reduced from +// the reviewed concept dictionary. Each entry binds one runnable Cobra leaf. +var generatedParamAliases = []ParamAliasEntry{ + { + CLIPath: "aitable +base-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "aitable +field-get", + Aliases: map[string]string{ + "base": "base-id", + "base-token": "base-id", + }, + }, + { + CLIPath: "aitable +list-tables", + Aliases: map[string]string{ + "base-id": "base", + "base-token": "base", + }, + }, + { + CLIPath: "aitable +record-query", + Aliases: map[string]string{ + "base": "base-id", + "base-token": "base-id", + }, + }, + { + CLIPath: "aitable +record-share-url", + Aliases: map[string]string{ + "base": "base-id", + "base-token": "base-id", + }, + }, + { + CLIPath: "aitable +table-get", + Aliases: map[string]string{ + "base": "base-id", + "base-token": "base-id", + }, + }, + { + CLIPath: "aitable record query", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "page"}, + }, + { + CLIPath: "attendance +check-result", + Aliases: map[string]string{ + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "attendance check result", + Aliases: map[string]string{ + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "calendar event list", + Aliases: map[string]string{ + "begin": "start", + "date": "start", + "from": "start", + "from-date": "start", + "max-result": "limit", + "next-page-token": "cursor", + "per-page": "limit", + "since": "start", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"offset", "page", "time"}, + }, + { + CLIPath: "chat +bot-find", + Aliases: map[string]string{ + "name": "query", + }, + }, + { + CLIPath: "chat +bot-search", + Aliases: map[string]string{ + "current-page": "page", + "query": "name", + }, + Blocked: []string{"cursor"}, + }, + { + CLIPath: "chat +category-create", + Aliases: map[string]string{ + "name": "title", + }, + }, + { + CLIPath: "chat +category-delete", + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat +category-rename", + Aliases: map[string]string{ + "name": "title", + }, + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat +chat-bots", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-dismiss", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-invite-url", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-mute", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-role-add", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-role-list", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-role-query-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat +chat-role-set-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat +chat-role-update", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-set-admin", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "open-dingtalk-id", "source", "src-conversation-id", "target", "user-id"}, + }, + { + CLIPath: "chat +chat-set-history", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-update-alias", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-update-nick", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +conversation-info", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat +group-members", + Aliases: map[string]string{ + "group-name": "group", + }, + Blocked: []string{"chat-id", "conversation-id", "group-id", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat +messages-list-direct", + Aliases: map[string]string{ + "staff-id": "user", + "start": "time", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "end", "name", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat +messages-list-pin", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +messages-list-unread-conversations", + Aliases: map[string]string{ + "limit": "count", + "size": "count", + }, + }, + { + CLIPath: "chat +messages-mget", + Aliases: map[string]string{ + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + }, + Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + }, + { + CLIPath: "chat +messages-read-status", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "msg-id": "message-id", + "open-conversation-id": "conversation-id", + "open-message-id": "message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-dingtalk-id", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id", "user-id"}, + }, + { + CLIPath: "chat +messages-send-by-webhook", + Aliases: map[string]string{ + "at-user-ids": "at-users", + }, + }, + { + CLIPath: "chat +send-to-group", + Aliases: map[string]string{ + "group-name": "group", + }, + Blocked: []string{"chat-id", "conversation-id", "group-id", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat +unread-chats", + Aliases: map[string]string{ + "limit": "count", + "size": "count", + }, + }, + { + CLIPath: "chat bot find", + Aliases: map[string]string{ + "name": "query", + }, + }, + { + CLIPath: "chat bot search", + Aliases: map[string]string{ + "current-page": "page", + "query": "name", + }, + Blocked: []string{"cursor"}, + }, + { + CLIPath: "chat category add-conv", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat category create", + Aliases: map[string]string{ + "name": "title", + }, + }, + { + CLIPath: "chat category create-smart", + Aliases: map[string]string{ + "open-dingtalk-ids": "members", + "title": "name", + }, + Blocked: []string{"staff-id", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "chat category delete", + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat category list-conversations", + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat category remove-conv", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat category rename", + Aliases: map[string]string{ + "name": "title", + }, + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat chmod", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat clear-messages", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat clear-red-point", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat conversation-info", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group audit-join-validation", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat group bots", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group create", + Blocked: []string{"open-dingtalk-id", "user-id"}, + }, + { + CLIPath: "chat group dismiss", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group get-by-group-id", + Blocked: []string{"chat", "chat-id", "conversation-id", "conversation-ids", "group", "group-name", "id", "name", "open-conversation-id", "open-conversation-ids"}, + }, + { + CLIPath: "chat group invite-url", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group members", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group members add", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "open-dingtalk-id", "source", "src-conversation-id", "target", "user-id"}, + }, + { + CLIPath: "chat group members add-bot", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + "robot": "robot-code", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group members list-by-ids", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + "open-dingtalk-ids": "users", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "user", "user-id", "user-ids"}, + }, + { + CLIPath: "chat group members remove", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat group members remove-bot", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-bot-id": "bot-id", + "open-conversation-id": "id", + }, + Blocked: []string{"bot-code", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot", "robot-code", "robot-id", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice create", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice edit", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice get", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice list", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group quit", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group rename", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group set-admin", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, + }, + { + CLIPath: "chat group set-history", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group share-invite", + Aliases: map[string]string{ + "source-conversation-id": "source", + "target-conversation-id": "target", + }, + Blocked: []string{"group-id", "group-ids", "staff-id", "uid", "user", "user-id", "userid"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat group transfer-owner", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group update-alias", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-icon", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-nick", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-settings", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-mute", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-mute-member", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, + }, + { + CLIPath: "chat group-role add", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-role list", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-role query-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group-role remove", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-role remove-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group-role set-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group-role update", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat hide", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat mark-read", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "msg-id": "message-id", + "open-conversation-id": "conversation-id", + "open-message-id": "message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat mark-unread", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message add-emoji", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "message-id": "msg-id", + "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message add-favorite", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "open-message-id", + "msg-id": "open-message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message add-text-emotion", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "message-id": "msg-id", + "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message combine-forward", + Aliases: map[string]string{ + "dest-open-cid": "dest-conversation-id", + "destination-conversation-id": "dest-conversation-id", + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + "source-conversation-id": "src-conversation-id", + "src-open-cid": "src-conversation-id", + "target-conversation-id": "dest-conversation-id", + }, + Blocked: []string{"group-id", "group-ids", "message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat message download-media", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message forward", + Aliases: map[string]string{ + "dest-open-cid": "dest-conversation-id", + "destination-conversation-id": "dest-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + "source-conversation-id": "src-conversation-id", + "src-open-cid": "src-conversation-id", + "target-conversation-id": "dest-conversation-id", + }, + Blocked: []string{"group-id", "group-ids", "message-ids", "msg-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat message forward-topic", + Aliases: map[string]string{ + "dest-open-conversation-id": "dest-conversation-id", + "destination-conversation-id": "dest-conversation-id", + "source-conversation-id": "src-conversation-id", + "source-message-id": "src-msg-id", + "src-open-conversation-id": "src-conversation-id", + "src-open-message-id": "src-msg-id", + "target-conversation-id": "dest-conversation-id", + }, + Blocked: []string{"group-id", "group-ids", "message-id", "msg-id", "open-message-id"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat message list", + Aliases: map[string]string{ + "chat-id": "group", + "max-result": "limit", + "max-results": "limit", + "open-conversation-id": "group", + "page-size": "limit", + "per-page": "limit", + "staff-id": "user", + "start": "time", + "take": "limit", + "top": "limit", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat message list-all", + Aliases: map[string]string{ + "begin": "start", + "from": "start", + "from-date": "start", + "min-time": "start", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-min": "start", + }, + Blocked: []string{"date", "time"}, + }, + { + CLIPath: "chat message list-by-ids", + Aliases: map[string]string{ + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + }, + Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + }, + { + CLIPath: "chat message list-by-sender", + Aliases: map[string]string{ + "open-dingtalk-id": "sender-open-dingtalk-id", + "user-id": "sender-user-id", + }, + Blocked: []string{"time"}, + }, + { + CLIPath: "chat message list-emotion-replies", + Aliases: map[string]string{ + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + }, + Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + }, + { + CLIPath: "chat message list-favorites", + Aliases: map[string]string{ + "limit": "size", + }, + }, + { + CLIPath: "chat message list-mentions", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message list-pin-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message list-topic-replies", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message list-unread-conversations", + Aliases: map[string]string{ + "limit": "count", + "size": "count", + }, + }, + { + CLIPath: "chat message read-status", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "msg-id": "message-id", + "open-conversation-id": "conversation-id", + "open-message-id": "message-id", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"}, + }, + { + CLIPath: "chat message recall", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "message-id": "msg-id", + "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message recall-by-bot", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + "robot": "robot-code", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message remove-emoji", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "message-id": "msg-id", + "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message remove-favorite", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "open-message-id", + "msg-id": "open-message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message remove-text-emotion", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "message-id": "msg-id", + "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message reply", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + "ref-message-id": "ref-msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat message search", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message search-advanced", + Aliases: map[string]string{ + "open-conversation-ids": "conversation-ids", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "chat-id", "conversation-id", "group-id", "group-ids", "open-conversation-id", "staff-id", "uid", "userid"}, + }, + { + CLIPath: "chat message send", + Aliases: map[string]string{ + "chat-id": "group", + "file": "file-path", + "open-conversation-id": "group", + "staff-id": "user", + "to-user": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"}, + }, + { + CLIPath: "chat message send-by-bot", + Aliases: map[string]string{ + "at-users": "at-user-ids", + "chat-id": "group", + "open-conversation-id": "group", + "robot": "robot-code", + "user-ids": "users", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"}, + Ambiguous: []string{"at-ids"}, + }, + { + CLIPath: "chat message send-by-webhook", + Aliases: map[string]string{ + "at-user-ids": "at-users", + }, + }, + { + CLIPath: "chat message send-card", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat message set-pin-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message set-top-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message unset-pin-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message unset-top-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat mute", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"}, + }, + { + CLIPath: "chat mute-at-all", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat mute-red-envelope", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat set-top", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "contact +dept-members", + Aliases: map[string]string{ + "keyword": "dept", + "keywords": "dept", + "name": "dept", + "q": "dept", + "query": "dept", + "search-word": "dept", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "contact +list-dept-members", + Aliases: map[string]string{ + "department-ids": "depts", + "dept-ids": "depts", + }, + Blocked: []string{"department-id", "dept", "dept-id", "name", "query"}, + }, + { + CLIPath: "contact +list-sub-depts", + Aliases: map[string]string{ + "department": "dept", + "department-id": "dept", + "dept-id": "dept", + "parent": "dept", + "parent-id": "dept", + }, + Blocked: []string{"department-ids", "dept-ids", "depts", "name", "query"}, + }, + { + CLIPath: "contact +resolve-dept", + Aliases: map[string]string{ + "keyword": "name", + "keywords": "name", + "q": "name", + "query": "name", + "search-word": "name", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "contact +search-user", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "contact dept list-children", + Aliases: map[string]string{ + "department": "dept", + "department-id": "dept", + "parent": "dept", + "parent-id": "dept", + }, + Blocked: []string{"department-ids", "depts", "name", "query"}, + }, + { + CLIPath: "contact user profile get", + Aliases: map[string]string{ + "id": "staff-id", + "ids": "staff-id", + "uid": "staff-id", + "user": "staff-id", + "user-id": "staff-id", + "userid": "staff-id", + }, + Blocked: []string{"at-user-ids", "name", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "dev app get", + Aliases: map[string]string{ + "app-id": "unified-app-id", + "application-id": "unified-app-id", + }, + Blocked: []string{"agent-id", "app-secret"}, + }, + { + CLIPath: "devdoc article search", + Aliases: map[string]string{ + "current-page": "page", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-size": "size", + "per-page": "size", + "take": "size", + "top": "size", + }, + Blocked: []string{"count", "cursor", "page-index", "page-token"}, + }, + { + CLIPath: "ding +receiver-status", + Aliases: map[string]string{ + "id": "ding-id", + }, + }, + { + CLIPath: "ding message receiver-status", + Aliases: map[string]string{ + "id": "ding-id", + "open-ding-id": "ding-id", + }, + }, + { + CLIPath: "ding message send", + Aliases: map[string]string{ + "robot": "robot-code", + }, + Blocked: []string{"robot-id"}, + }, + { + CLIPath: "doc +export-get", + Blocked: []string{"node"}, + }, + { + CLIPath: "doc +template-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "doc block delete", + Blocked: []string{"index"}, + }, + { + CLIPath: "doc block insert", + Aliases: map[string]string{ + "body": "text", + "content": "text", + }, + Blocked: []string{"before-block-id", "name", "title"}, + }, + { + CLIPath: "doc block update", + Aliases: map[string]string{ + "body": "text", + "content": "text", + }, + Blocked: []string{"name", "title"}, + }, + { + CLIPath: "drive info", + Aliases: map[string]string{ + "space": "space-id", + "workspace": "space-id", + "workspace-id": "space-id", + }, + Blocked: []string{"folder"}, + }, + { + CLIPath: "drive list", + Aliases: map[string]string{ + "folder-id": "folder", + }, + Ambiguous: []string{"space"}, + }, + { + CLIPath: "drive upload", + Ambiguous: []string{"space"}, + }, + { + CLIPath: "mail +find-mail-user", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "mail folder update", + Aliases: map[string]string{ + "folder": "id", + "folder-id": "id", + }, + Blocked: []string{"space-id"}, + }, + { + CLIPath: "mail message search", + Aliases: map[string]string{ + "subject": "query", + }, + }, + { + CLIPath: "mail thread list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "page"}, + }, + { + CLIPath: "mail user search", + Aliases: map[string]string{ + "keywords": "keyword", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "oa +list-executed", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor"}, + }, + { + CLIPath: "oa +search-forms", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "oa approval search-forms", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "report list", + Aliases: map[string]string{ + "begin": "start", + "from": "start", + "from-date": "start", + "min-time": "start", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-min": "start", + }, + Blocked: []string{"date", "time"}, + }, + { + CLIPath: "report outbox list", + Blocked: []string{"template-type"}, + }, +} + +func loadGeneratedParamAliases() []ParamAliasEntry { + return generatedParamAliases +} diff --git a/internal/cli/param_aliases_lookup.go b/internal/cli/param_aliases_lookup.go new file mode 100644 index 00000000..92eb7168 --- /dev/null +++ b/internal/cli/param_aliases_lookup.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import "sync" + +// paramAliasIndex is the lazily built per-command view of the generated +// parameter-alias table, keyed by the same normalized CLI path the generator +// used. It is populated once; the generated slice never changes at runtime. +var ( + paramAliasIndexOnce sync.Once + paramAliasIndex map[string]ParamAliasEntry +) + +func buildParamAliasIndex() { + entries := loadGeneratedParamAliases() + paramAliasIndex = make(map[string]ParamAliasEntry, len(entries)) + for _, e := range entries { + paramAliasIndex[e.CLIPath] = e + } +} + +// LookupParamAlias resolves the reduced parameter-alias entry for a command. +// +// rawCommandPath is Cobra's CommandPath() (it still carries the "dws" prefix). +// It is normalized through normalizeSchemaCLIPath — the exact function the +// build-time generator used to key each entry — so the runtime lookup key is +// byte-identical to the generation key and there is zero mapping drift. +func LookupParamAlias(rawCommandPath string) (ParamAliasEntry, bool) { + paramAliasIndexOnce.Do(buildParamAliasIndex) + e, ok := paramAliasIndex[normalizeSchemaCLIPath(rawCommandPath)] + return e, ok +} + +// ResolveAlias returns the canonical real flag a morphed emitted name reduces +// to, if this command aliases it. The caller is expected to pass an +// already-morphed name (cmdutil.Morph), matching how the table is keyed. +func (e ParamAliasEntry) ResolveAlias(morphed string) (string, bool) { + canon, ok := e.Aliases[morphed] + return canon, ok +} + +// IsBlocked reports whether a morphed emitted name is on this command's block +// list: it must never be auto-rewritten and instead routes to did-you-mean. +func (e ParamAliasEntry) IsBlocked(morphed string) bool { + return containsParamAlias(e.Blocked, morphed) +} + +// IsAmbiguous reports whether a morphed emitted name is on this command's +// reviewed co-occurrence whitelist: it is intentionally left unresolved so the +// runtime asks instead of guessing between two real flags. +func (e ParamAliasEntry) IsAmbiguous(morphed string) bool { + return containsParamAlias(e.Ambiguous, morphed) +} + +func containsParamAlias(list []string, target string) bool { + for _, v := range list { + if v == target { + return true + } + } + return false +} diff --git a/internal/cli/param_aliases_test.go b/internal/cli/param_aliases_test.go new file mode 100644 index 00000000..989fc055 --- /dev/null +++ b/internal/cli/param_aliases_test.go @@ -0,0 +1,518 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package cli + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// realMap builds a per-leaf real-flag table keyed by the shared Morph so the +// tests exercise exactly the same intersection the generator performs. +func realMap(flags ...realFlag) map[string][]realFlag { + m := make(map[string][]realFlag) + for _, f := range flags { + k := cmdutil.Morph(f.name) + m[k] = appendRealFlag(m[k], f) + } + return m +} + +// conceptFixture is a small synthetic concept set; reduceLeafParamAliases is +// deliberately pure so it can be tested without the whole Cobra tree. +func conceptFixture() []Concept { + return []Concept{ + {ID: "pagination_size", CanonicalHint: "limit", Members: []string{"limit", "size", "page-size", "max-results"}}, + {ID: "base_id", CanonicalHint: "base-id", Members: []string{"base", "base-id", "base-token"}}, + {ID: "user_id", CanonicalHint: "user-id", Members: []string{"user", "users", "user-id", "uid"}}, + } +} + +func useParamConceptLoader(t *testing.T, concepts ParamConcepts, err error) { + t.Helper() + previous := loadReviewedParamConcepts + loadReviewedParamConcepts = func() (ParamConcepts, error) { return concepts, err } + t.Cleanup(func() { loadReviewedParamConcepts = previous }) +} + +func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) { + t.Run("load failure", func(t *testing.T) { + useParamConceptLoader(t, ParamConcepts{}, errors.New("fixture load")) + if _, err := ReduceParamAliases(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "fixture load") { + t.Fatalf("ReduceParamAliases() error = %v", err) + } + }) + + t.Run("nil root", func(t *testing.T) { + useParamConceptLoader(t, ParamConcepts{Version: 1}, nil) + if _, err := ReduceParamAliases(nil); err == nil || !strings.Contains(err.Error(), "root is nil") { + t.Fatalf("ReduceParamAliases(nil) error = %v", err) + } + }) + + t.Run("real tree", func(t *testing.T) { + concepts := ParamConcepts{ + Version: 1, + Concepts: []Concept{ + {ID: "query", Members: []string{"query", "keyword"}, Commands: []string{"demo run"}}, + {ID: "user_id", Members: []string{"user-id", "uid"}, Commands: []string{"demo run"}}, + }, + Overrides: []CommandOverride{ + {CommandPath: "alpha", Block: []string{"unsafe"}}, + {CommandPath: "demo run", Bind: map[string]string{"id": "user_id"}}, + }, + } + useParamConceptLoader(t, concepts, nil) + + root := &cobra.Command{Use: "dws"} + alpha := &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}} + alpha.Flags().String("name", "", "name") + demo := &cobra.Command{Use: "demo", Run: func(*cobra.Command, []string) {}} + run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + run.Flags().String("query", "", "query") + run.Flags().String("id", "", "id") + demo.AddCommand(run) + root.AddCommand(alpha, demo) + root.AddCommand(&cobra.Command{Use: "help", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, Run: func(*cobra.Command, []string) {}}) + + entries, err := ReduceParamAliases(root) + if err != nil { + t.Fatalf("ReduceParamAliases() error = %v", err) + } + if len(entries) != 2 || entries[0].CLIPath != "alpha" || entries[1].CLIPath != "demo run" { + t.Fatalf("entries = %#v", entries) + } + if entries[1].Aliases["keyword"] != "query" || entries[1].Aliases["uid"] != "id" { + t.Fatalf("demo aliases = %#v", entries[1].Aliases) + } + }) + + t.Run("stale and unbound review inputs", func(t *testing.T) { + concepts := ParamConcepts{ + Version: 1, + Concepts: []Concept{ + {ID: "missing", Members: []string{"missing"}, Commands: []string{"demo run"}}, + {ID: "stale", Members: []string{"stale"}, Commands: []string{"ghost run"}}, + }, + Overrides: []CommandOverride{{CommandPath: "ghost run", Block: []string{"unsafe"}}}, + } + useParamConceptLoader(t, concepts, nil) + root := &cobra.Command{Use: "dws"} + demo := &cobra.Command{Use: "demo"} + run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + run.Flags().String("query", "", "query") + demo.AddCommand(run) + root.AddCommand(demo) + + _, err := ReduceParamAliases(root) + for _, want := range []string{"has no matching real flag", "does not match any runnable Cobra leaf", "does not match any runnable Cobra command"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("ReduceParamAliases() error = %v, want %q", err, want) + } + } + }) +} + +func TestParamAliasHelperEdges(t *testing.T) { + walkRunnableParamCommands(nil, func(*cobra.Command) { t.Fatal("nil root was visited") }) + helpOnly := &cobra.Command{Use: "demo"} + helpOnly.Flags().String("help", "", "help") + if got := realFlagsByMorph(helpOnly); len(got) != 0 { + t.Fatalf("help flag entered the real parameter table: %#v", got) + } + + flags := []realFlag{{name: "same", hidden: true}} + flags = appendRealFlag(flags, realFlag{name: "same"}) + if len(flags) != 1 || flags[0].hidden { + t.Fatalf("visible duplicate did not replace hidden registration: %#v", flags) + } + flags = appendRealFlag(flags, realFlag{name: "same", hidden: true}) + if len(flags) != 1 || flags[0].hidden { + t.Fatalf("hidden duplicate replaced visible registration: %#v", flags) + } + flags = appendRealFlag(flags, realFlag{name: "alpha"}) + if !reflect.DeepEqual([]string{flags[0].name, flags[1].name}, []string{"alpha", "same"}) { + t.Fatalf("new real flags are not sorted: %#v", flags) + } + + candidates := []realFlag{{name: "hidden", hidden: true}, {name: "visible"}, {name: "visible"}} + if got := distinctRealNames(candidates, true); !reflect.DeepEqual(got, []string{"visible"}) { + t.Fatalf("visible names = %v", got) + } + if got := canonicalRealName([]realFlag{{name: "hidden", hidden: true}}); got != "hidden" { + t.Fatalf("hidden-only canonical = %q", got) + } + if got := canonicalRealName(nil); got != "" { + t.Fatalf("empty canonical = %q", got) + } + if got := sortedUnique(nil); got != nil { + t.Fatalf("sortedUnique(nil) = %#v", got) + } + if got := sortedUnique([]string{"b", "a", "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("sortedUnique() = %v", got) + } + + real := realMap(realFlag{name: "query"}, realFlag{name: "id"}) + if !conceptHasRealFlag(Concept{ID: "query", Members: []string{"query"}}, CommandOverride{}, real) { + t.Fatal("concept member did not match a real flag") + } + if !conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"id": "user"}}, real) { + t.Fatal("reviewed bind did not match a real flag") + } + if conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"missing": "user"}}, real) { + t.Fatal("missing reviewed bind matched a real flag") + } +} + +func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) { + t.Run("pending reviewed bind blocks non-real members", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "id"}), + []Concept{{ID: "user_id", Members: []string{"user-id", "uid"}}}, + CommandOverride{Bind: map[string]string{"id": "user_id"}, Investigate: true}, + ) + if len(problems) != 0 || entry == nil || + !containsParamAlias(entry.Blocked, "user-id") || !containsParamAlias(entry.Blocked, "uid") { + t.Fatalf("pending bind entry = %#v, problems = %v", entry, problems) + } + }) + + t.Run("hidden-only canonical", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "query", hidden: true}), + []Concept{{ID: "query", Members: []string{"query", "keyword"}}}, + CommandOverride{}, + ) + if len(problems) != 0 || entry == nil || entry.Aliases["keyword"] != "query" { + t.Fatalf("hidden-only entry = %#v, problems = %v", entry, problems) + } + }) + + t.Run("multiple hidden candidates stay unresolved", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "first", hidden: true}, realFlag{name: "second", hidden: true}), + []Concept{{ID: "choice", Members: []string{"first", "second", "choice"}}}, + CommandOverride{}, + ) + if len(problems) != 0 || entry != nil { + t.Fatalf("multiple hidden candidates entry = %#v, problems = %v", entry, problems) + } + }) + + t.Run("two concepts cannot claim one emitted spelling", func(t *testing.T) { + _, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "first"}, realFlag{name: "second"}), + []Concept{ + {ID: "first", Members: []string{"first", "shared"}}, + {ID: "second", Members: []string{"second", "shared"}}, + }, + CommandOverride{}, + ) + if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "reduces to both") { + t.Fatalf("alias collision problems = %v", problems) + } + }) + + t.Run("unclaimed exclude becomes blocked", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "query"}), + []Concept{{ID: "query", Members: []string{"query", "keyword"}, Excludes: []string{"name"}}}, + CommandOverride{}, + ) + if len(problems) != 0 || entry == nil || !containsParamAlias(entry.Blocked, "name") { + t.Fatalf("exclude entry = %#v, problems = %v", entry, problems) + } + }) +} + +func TestParamAliasEntryLookupMethods(t *testing.T) { + entry := ParamAliasEntry{ + Aliases: map[string]string{"uid": "user"}, + Blocked: []string{"count"}, + Ambiguous: []string{"user-id"}, + } + if got, ok := entry.ResolveAlias("uid"); !ok || got != "user" { + t.Fatalf("ResolveAlias(uid) = %q, %v", got, ok) + } + if _, ok := entry.ResolveAlias("missing"); ok { + t.Fatal("ResolveAlias(missing) unexpectedly matched") + } + if !entry.IsBlocked("count") || entry.IsBlocked("missing") { + t.Fatalf("blocked lookup mismatch: %#v", entry.Blocked) + } + if !entry.IsAmbiguous("user-id") || entry.IsAmbiguous("missing") { + t.Fatalf("ambiguous lookup mismatch: %#v", entry.Ambiguous) + } +} + +func TestReduceLeafParamAliasesAutoReduction(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(), CommandOverride{}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry == nil { + t.Fatal("expected a reduced entry") + } + for _, emitted := range []string{"size", "page-size", "max-results"} { + if entry.Aliases[emitted] != "limit" { + t.Fatalf("alias %q = %q, want limit", emitted, entry.Aliases[emitted]) + } + } + if _, ok := entry.Aliases["limit"]; ok { + t.Fatal("the real flag limit must never be an alias key") + } +} + +func TestReduceLeafParamAliasesCoOccurrenceRequiresReview(t *testing.T) { + _, problems := reduceLeafParamAliases("demo cmd", + realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(), CommandOverride{}) + if len(problems) == 0 { + t.Fatal("two visible real flags for one concept must fail without a reviewed ambiguous whitelist") + } +} + +func TestReduceLeafParamAliasesAmbiguousWhitelist(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", + realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry == nil { + t.Fatal("expected a reduced entry") + } + if _, ok := entry.Aliases["user-id"]; ok { + t.Fatal("a reviewed co-occurrence must not auto-reduce its concept members") + } + if len(entry.Ambiguous) != 2 || entry.Ambiguous[0] != "uid" || entry.Ambiguous[1] != "user-id" { + t.Fatalf("ambiguous = %v, want sorted [uid user-id]", entry.Ambiguous) + } +} + +// TestReduceLeafParamAliasesCoOccurrencePerConcept locks the per-concept guard: +// reviewing one concept's co-occurrence must not silently vouch for a second, +// unreviewed co-occurring concept on the same command. Here user_id (user + +// users) is whitelisted while base_id (base + base-id) is not, so base_id's +// unreviewed emittable member base-token must still fail generation. +func TestReduceLeafParamAliasesCoOccurrencePerConcept(t *testing.T) { + real := realMap( + realFlag{name: "user"}, realFlag{name: "users"}, + realFlag{name: "base"}, realFlag{name: "base-id"}, + ) + _, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}}) + if len(problems) == 0 { + t.Fatal("an unreviewed second co-occurring concept must fail even when another concept is whitelisted") + } + found := false + for _, p := range problems { + if strings.Contains(p, `concept "base_id"`) { + found = true + } + } + if !found { + t.Fatalf("expected a problem naming the unreviewed base_id concept, got: %v", problems) + } +} + +// TestReduceLeafParamAliasesCoOccurrenceBothReviewed confirms the per-concept +// guard passes once every co-occurring concept's emittable members are listed. +func TestReduceLeafParamAliasesCoOccurrenceBothReviewed(t *testing.T) { + real := realMap( + realFlag{name: "user"}, realFlag{name: "users"}, + realFlag{name: "base"}, realFlag{name: "base-id"}, + ) + _, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid", "base-token"}}) + if len(problems) != 0 { + t.Fatalf("both concepts reviewed should pass: %v", problems) + } +} + +// TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap locks the guard that a +// single name cannot be both auto-reduced and marked ambiguous. Here only +// --users is real, so user_id auto-reduces user-id to users; hand-listing +// user-id as ambiguous would produce a self-contradictory entry. +func TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap(t *testing.T) { + _, problems := reduceLeafParamAliases("demo cmd", + realMap(realFlag{name: "users"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id"}}) + if len(problems) == 0 { + t.Fatal("a name that both auto-reduces and is listed ambiguous must fail") + } +} + +func TestReduceLeafParamAliasesAbsorbsHiddenLegacyAlias(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", + realMap(realFlag{name: "base-id"}, realFlag{name: "base", hidden: true}), conceptFixture(), CommandOverride{}) + if len(problems) != 0 { + t.Fatalf("a hidden legacy alias flag must not be a co-occurrence: %v", problems) + } + if entry == nil { + t.Fatal("expected a reduced entry") + } + if entry.Aliases["base-token"] != "base-id" { + t.Fatalf("base-token = %q, want base-id", entry.Aliases["base-token"]) + } + if _, ok := entry.Aliases["base"]; ok { + t.Fatal("a real (hidden) flag must never be an alias key") + } +} + +func TestReduceLeafParamAliasesBindGenericFlag(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"id": "base_id"}}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry.Aliases["base"] != "id" || entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" { + t.Fatalf("bind reduction wrong: %#v", entry.Aliases) + } +} + +func TestReduceLeafParamAliasesBindRejectsNonRealFlag(t *testing.T) { + _, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"missing": "base_id"}}) + if len(problems) == 0 { + t.Fatal("binding a non-real flag must fail") + } +} + +func TestReduceLeafParamAliasesScopedAlias(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"ding-id": "id"}}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry == nil || entry.Aliases[cmdutil.Morph("ding-id")] != "id" { + t.Fatalf("scoped alias not applied: %#v", entry) + } +} + +func TestReduceLeafParamAliasesScopedAliasRejectsNonRealTarget(t *testing.T) { + _, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"foo": "nonexistent"}}) + if len(problems) == 0 { + t.Fatal("a scoped alias onto a non-real flag must fail") + } +} + +func TestReduceLeafParamAliasesBlockRemovesAndRecords(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(), + CommandOverride{CommandPath: "demo cmd", Block: []string{"size"}}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry == nil { + t.Fatal("expected a reduced entry") + } + if _, ok := entry.Aliases["size"]; ok { + t.Fatal("a blocked emitted name must be removed from the alias map") + } + found := false + for _, b := range entry.Blocked { + if b == "size" { + found = true + } + } + if !found { + t.Fatalf("size not recorded in blocked: %v", entry.Blocked) + } +} + +func TestReduceLeafParamAliasesPendingReviewDoesNotEmit(t *testing.T) { + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "query"}), nil, + CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"keyword": "query"}, Confirm: true}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry == nil || entry.Aliases["keyword"] != "" || !containsParamAlias(entry.Blocked, "keyword") { + t.Fatalf("pending mapping entered automatic aliases: %#v", entry) + } +} + +func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcept(t *testing.T) { + concepts := []Concept{ + {ID: "page_number", Members: []string{"page", "page-no"}, Excludes: []string{"page-size"}}, + {ID: "page_size", Members: []string{"limit", "page-size"}}, + } + entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "page"}, realFlag{name: "limit"}), concepts, CommandOverride{}) + if len(problems) != 0 { + t.Fatalf("unexpected problems: %v", problems) + } + if entry.Aliases["page-size"] != "limit" || containsParamAlias(entry.Blocked, "page-size") { + t.Fatalf("another reviewed concept alias was overridden by an exclude: %#v", entry) + } +} + +func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) { + real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"}) + for name, override := range map[string]CommandOverride{ + "block": {CommandPath: "demo cmd", Block: []string{"user-id"}}, + "ambiguous": {CommandPath: "demo cmd", Ambiguous: []string{"user-id"}}, + "scoped": {CommandPath: "demo cmd", ScopedAliases: map[string]string{"user-id": "user"}}, + } { + t.Run(name, func(t *testing.T) { + if _, problems := reduceLeafParamAliases("demo cmd", real, nil, override); len(problems) == 0 { + t.Fatal("real native flag was allowed to be reclassified") + } + }) + } +} + +// TestGeneratedParamAliasesAreWellFormed guards the committed generated table +// at the Go level, complementing the byte-identity drift gate. +func TestGeneratedParamAliasesAreWellFormed(t *testing.T) { + if len(generatedParamAliases) == 0 { + t.Fatal("generated parameter alias table is empty") + } + seen := make(map[string]bool, len(generatedParamAliases)) + for _, e := range generatedParamAliases { + if e.CLIPath == "" { + t.Fatal("generated entry has an empty CLIPath") + } + if seen[e.CLIPath] { + t.Fatalf("duplicate CLIPath %q in generated table", e.CLIPath) + } + seen[e.CLIPath] = true + for emitted, canon := range e.Aliases { + if emitted != cmdutil.Morph(emitted) { + t.Fatalf("%s: alias key %q is not morph-normalized", e.CLIPath, emitted) + } + if canon == "" { + t.Fatalf("%s: alias %q has an empty target", e.CLIPath, emitted) + } + if emitted == canon { + t.Fatalf("%s: alias %q maps to itself", e.CLIPath, emitted) + } + } + classified := make(map[string]string, len(e.Aliases)+len(e.Blocked)+len(e.Ambiguous)) + for emitted := range e.Aliases { + classified[emitted] = "alias" + } + for kind, values := range map[string][]string{"blocked": e.Blocked, "ambiguous": e.Ambiguous} { + for _, name := range values { + if name != cmdutil.Morph(name) { + t.Fatalf("%s: %s name %q is not morph-normalized", e.CLIPath, kind, name) + } + if previous := classified[name]; previous != "" { + t.Fatalf("%s: %q is classified as both %s and %s", e.CLIPath, name, previous, kind) + } + classified[name] = kind + } + } + } +} diff --git a/internal/cli/param_concepts.go b/internal/cli/param_concepts.go new file mode 100644 index 00000000..1cca0de6 --- /dev/null +++ b/internal/cli/param_concepts.go @@ -0,0 +1,488 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package cli + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "io" + "regexp" + "sort" + "strings" + "sync" +) + +const paramConceptsSchemaRef = "./param_concepts.schema.json" + +// param_concepts.json is the reviewed, typed parameter concept dictionary and +// the sole source of equivalent flag spellings ("concepts") plus per-command +// overrides. Build-time generators reduce these concepts against each command's +// real Cobra flags; generated alias tables are downstream views and must never +// be read back here. + +//go:embed param_concepts.json +var embeddedParamConceptsJSON []byte + +//go:embed param_concepts.schema.json +var embeddedParamConceptsSchemaJSON []byte + +var ( + paramConceptIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + paramFlagTokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + paramCommandPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*$`) +) + +// didYouMean sentinels are the only non-flag values a fixture case may expect. +const ( + paramDidYouMeanAmbiguous = "did-you-mean:ambiguous" + paramDidYouMeanBlocked = "did-you-mean:blocked" +) + +type paramConceptsSnapshot struct { + Schema string `json:"$schema"` + Version int `json:"version"` + MorphRules map[string]ParamMorphRule `json:"morphological_rules,omitempty"` + Concepts map[string]paramConceptSpec `json:"concepts"` + Overrides map[string]paramCommandOverride `json:"command_overrides,omitempty"` + Fixture *paramValidationFixtureSpec `json:"validation_fixture,omitempty"` +} + +type paramConceptSpec struct { + Denotes string `json:"denotes"` + CanonicalHint string `json:"canonical_hint"` + Members []string `json:"members"` + Excludes []string `json:"excludes,omitempty"` + Commands []string `json:"commands"` + Risk string `json:"risk"` +} + +type paramCommandOverride struct { + Bind map[string]string `json:"bind,omitempty"` + ScopedAliases map[string]string `json:"scoped_aliases,omitempty"` + Block []string `json:"block,omitempty"` + Ambiguous []string `json:"ambiguous,omitempty"` + Confirm bool `json:"confirm,omitempty"` + ScopeStrict bool `json:"scope_strict,omitempty"` + Investigate bool `json:"investigate,omitempty"` + Note string `json:"note,omitempty"` +} + +type paramValidationFixtureSpec struct { + Cases []paramFixtureCaseSpec `json:"cases"` +} + +type paramFixtureCaseSpec struct { + Command string `json:"command"` + Emitted string `json:"emitted"` + Expect string `json:"expect"` + Via string `json:"via,omitempty"` + Occ int `json:"occ,omitempty"` +} + +// ParamMorphRule documents one table-free name normalization behavior. It is +// evidence for the shared Morph function; it is not a per-command alias. +type ParamMorphRule struct { + Desc string `json:"desc"` + Enabled bool `json:"enabled"` + Guard string `json:"guard,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Concept is one reviewed set of equivalent flag spellings that all denote a +// single entity. Members reduce onto the command's real flag; Excludes lists +// spellings that denote a different entity and must never be reduced in. +type Concept struct { + ID string + Denotes string + CanonicalHint string + Members []string + Excludes []string + Commands []string + Risk string +} + +// CommandOverride is one reviewed per-command adjustment: binding a generic +// real flag to a concept, command-scoped aliases, blocks, and the reviewed +// co-occurrence whitelist. +type CommandOverride struct { + CommandPath string + Bind map[string]string + ScopedAliases map[string]string + Block []string + Ambiguous []string + Confirm bool + ScopeStrict bool + Investigate bool + Note string +} + +// ParamFixtureCase is one reviewed regression assertion derived from evaluation +// bad cases: the emitted name on Command must reduce to Expect (a real flag) or +// route to a did-you-mean sentinel. +type ParamFixtureCase struct { + Command string + Emitted string + Expect string + Via string + Occ int +} + +// ParamConcepts is the decoded, validated reviewed concept dictionary. +type ParamConcepts struct { + Version int + Morph map[string]ParamMorphRule + Concepts []Concept + ByConcept map[string]Concept + Overrides []CommandOverride + Fixture []ParamFixtureCase +} + +var ( + embeddedParamConceptsOnce sync.Once + embeddedParamConceptsData ParamConcepts + embeddedParamConceptsErr error + loadReviewedParamConcepts = loadEmbeddedParamConcepts +) + +// LoadParamConcepts decodes and validates the embedded reviewed concept +// dictionary exactly once. +func LoadParamConcepts() (ParamConcepts, error) { + return loadReviewedParamConcepts() +} + +func loadEmbeddedParamConcepts() (ParamConcepts, error) { + embeddedParamConceptsOnce.Do(func() { + embeddedParamConceptsData, embeddedParamConceptsErr = decodeParamConcepts(embeddedParamConceptsJSON) + }) + return cloneParamConcepts(embeddedParamConceptsData), embeddedParamConceptsErr +} + +func decodeParamConcepts(data []byte) (ParamConcepts, error) { + var snapshot paramConceptsSnapshot + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&snapshot); err != nil { + return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple JSON values") + } + return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err) + } + if snapshot.Version != 1 { + return ParamConcepts{}, fmt.Errorf("unsupported parameter concepts version %d", snapshot.Version) + } + if strings.TrimSpace(snapshot.Schema) != paramConceptsSchemaRef { + return ParamConcepts{}, fmt.Errorf("parameter concepts must declare $schema=%q", paramConceptsSchemaRef) + } + if len(snapshot.Concepts) == 0 { + return ParamConcepts{}, fmt.Errorf("parameter concepts declares no concepts") + } + + concepts, byConcept, err := decodeParamConceptSpecs(snapshot.Concepts) + if err != nil { + return ParamConcepts{}, err + } + + overrides, err := decodeParamCommandOverrides(snapshot.Overrides, byConcept) + if err != nil { + return ParamConcepts{}, err + } + + fixture, err := decodeParamFixtureCases(snapshot.Fixture) + if err != nil { + return ParamConcepts{}, err + } + + morph := make(map[string]ParamMorphRule, len(snapshot.MorphRules)) + for name, rule := range snapshot.MorphRules { + if strings.TrimSpace(rule.Desc) == "" { + return ParamConcepts{}, fmt.Errorf("parameter concepts morph rule %q has empty desc", name) + } + morph[name] = rule + } + + return ParamConcepts{ + Version: snapshot.Version, + Morph: morph, + Concepts: concepts, + ByConcept: byConcept, + Overrides: overrides, + Fixture: fixture, + }, nil +} + +// decodeParamConceptSpecs validates every concept and enforces two purity +// invariants: members are unique across all concepts, and no member appears in +// its own excludes list. +func decodeParamConceptSpecs(specs map[string]paramConceptSpec) ([]Concept, map[string]Concept, error) { + ids := make([]string, 0, len(specs)) + for id := range specs { + ids = append(ids, id) + } + sort.Strings(ids) + + concepts := make([]Concept, 0, len(ids)) + byConcept := make(map[string]Concept, len(ids)) + memberOwner := make(map[string]string) + for _, id := range ids { + if !paramConceptIDPattern.MatchString(id) { + return nil, nil, fmt.Errorf("parameter concepts contains invalid concept id %q", id) + } + spec := specs[id] + if strings.TrimSpace(spec.Denotes) == "" { + return nil, nil, fmt.Errorf("concept %s has empty denotes", id) + } + if !paramFlagTokenPattern.MatchString(spec.CanonicalHint) { + return nil, nil, fmt.Errorf("concept %s has invalid canonical_hint %q", id, spec.CanonicalHint) + } + switch spec.Risk { + case "green", "yellow": + default: + return nil, nil, fmt.Errorf("concept %s has invalid risk %q", id, spec.Risk) + } + if len(spec.Members) == 0 { + return nil, nil, fmt.Errorf("concept %s has no members", id) + } + if len(spec.Commands) == 0 { + return nil, nil, fmt.Errorf("concept %s has no reviewed command scope", id) + } + members := make([]string, 0, len(spec.Members)) + memberSet := make(map[string]bool, len(spec.Members)) + for _, member := range spec.Members { + if !paramFlagTokenPattern.MatchString(member) { + return nil, nil, fmt.Errorf("concept %s has invalid member %q", id, member) + } + if memberSet[member] { + return nil, nil, fmt.Errorf("concept %s repeats member %q", id, member) + } + memberSet[member] = true + if owner, exists := memberOwner[member]; exists { + return nil, nil, fmt.Errorf("member %q belongs to both concept %s and %s", member, owner, id) + } + memberOwner[member] = id + members = append(members, member) + } + excludes := make([]string, 0, len(spec.Excludes)) + excludeSet := make(map[string]bool, len(spec.Excludes)) + for _, exclude := range spec.Excludes { + if !paramFlagTokenPattern.MatchString(exclude) { + return nil, nil, fmt.Errorf("concept %s has invalid exclude %q", id, exclude) + } + if excludeSet[exclude] { + return nil, nil, fmt.Errorf("concept %s repeats exclude %q", id, exclude) + } + excludeSet[exclude] = true + if memberSet[exclude] { + return nil, nil, fmt.Errorf("concept %s lists %q as both member and exclude", id, exclude) + } + excludes = append(excludes, exclude) + } + commands := make([]string, 0, len(spec.Commands)) + commandSet := make(map[string]bool, len(spec.Commands)) + for _, command := range spec.Commands { + if !validParamCommandPath(command) { + return nil, nil, fmt.Errorf("concept %s has invalid command scope %q", id, command) + } + if commandSet[command] { + return nil, nil, fmt.Errorf("concept %s repeats command scope %q", id, command) + } + commandSet[command] = true + commands = append(commands, command) + } + sort.Strings(commands) + concept := Concept{ + ID: id, + Denotes: strings.TrimSpace(spec.Denotes), + CanonicalHint: spec.CanonicalHint, + Members: members, + Excludes: excludes, + Commands: commands, + Risk: spec.Risk, + } + concepts = append(concepts, concept) + byConcept[id] = concept + } + return concepts, byConcept, nil +} + +func decodeParamCommandOverrides(specs map[string]paramCommandOverride, byConcept map[string]Concept) ([]CommandOverride, error) { + paths := make([]string, 0, len(specs)) + for path := range specs { + paths = append(paths, path) + } + sort.Strings(paths) + + overrides := make([]CommandOverride, 0, len(paths)) + for _, path := range paths { + if !validParamCommandPath(path) { + return nil, fmt.Errorf("command_overrides contains invalid command path %q", path) + } + spec := specs[path] + if len(spec.Bind) == 0 && len(spec.ScopedAliases) == 0 && len(spec.Block) == 0 && len(spec.Ambiguous) == 0 { + return nil, fmt.Errorf("command_override %q declares no bind/scoped_aliases/block/ambiguous", path) + } + for flag, conceptID := range spec.Bind { + if !paramFlagTokenPattern.MatchString(flag) { + return nil, fmt.Errorf("command_override %q bind has invalid flag %q", path, flag) + } + if _, ok := byConcept[conceptID]; !ok { + return nil, fmt.Errorf("command_override %q binds %q to undeclared concept %q", path, flag, conceptID) + } + } + for emitted, realFlag := range spec.ScopedAliases { + if !paramFlagTokenPattern.MatchString(emitted) { + return nil, fmt.Errorf("command_override %q scoped_aliases has invalid emitted %q", path, emitted) + } + if !paramFlagTokenPattern.MatchString(realFlag) { + return nil, fmt.Errorf("command_override %q scoped_aliases has invalid target %q", path, realFlag) + } + } + if err := validParamTokenList(path, "block", spec.Block); err != nil { + return nil, err + } + if err := validParamTokenList(path, "ambiguous", spec.Ambiguous); err != nil { + return nil, err + } + overrides = append(overrides, CommandOverride{ + CommandPath: path, + Bind: cloneStringMap(spec.Bind), + ScopedAliases: cloneStringMap(spec.ScopedAliases), + Block: append([]string(nil), spec.Block...), + Ambiguous: append([]string(nil), spec.Ambiguous...), + Confirm: spec.Confirm, + ScopeStrict: spec.ScopeStrict, + Investigate: spec.Investigate, + Note: strings.TrimSpace(spec.Note), + }) + } + return overrides, nil +} + +func decodeParamFixtureCases(spec *paramValidationFixtureSpec) ([]ParamFixtureCase, error) { + if spec == nil { + return nil, nil + } + if len(spec.Cases) == 0 { + return nil, fmt.Errorf("validation_fixture declares no cases") + } + cases := make([]ParamFixtureCase, 0, len(spec.Cases)) + for i, c := range spec.Cases { + if !validParamCommandPath(c.Command) { + return nil, fmt.Errorf("validation_fixture case %d has invalid command %q", i, c.Command) + } + if !paramFlagTokenPattern.MatchString(c.Emitted) { + return nil, fmt.Errorf("validation_fixture case %d has invalid emitted %q", i, c.Emitted) + } + expect := strings.TrimSpace(c.Expect) + if expect == "" { + return nil, fmt.Errorf("validation_fixture case %d has empty expect", i) + } + if strings.HasPrefix(expect, "did-you-mean:") { + if expect != paramDidYouMeanAmbiguous && expect != paramDidYouMeanBlocked { + return nil, fmt.Errorf("validation_fixture case %d has unknown did-you-mean sentinel %q", i, expect) + } + } else if !paramFlagTokenPattern.MatchString(expect) { + return nil, fmt.Errorf("validation_fixture case %d has invalid expect %q", i, expect) + } + if c.Occ < 0 { + return nil, fmt.Errorf("validation_fixture case %d has negative occ %d", i, c.Occ) + } + cases = append(cases, ParamFixtureCase{ + Command: c.Command, + Emitted: c.Emitted, + Expect: expect, + Via: strings.TrimSpace(c.Via), + Occ: c.Occ, + }) + } + return cases, nil +} + +func validParamCommandPath(path string) bool { + if strings.TrimSpace(path) != path || path == "" { + return false + } + for _, token := range strings.Split(path, " ") { + if !paramCommandPathPattern.MatchString(token) { + return false + } + } + return true +} + +func validParamTokenList(path, field string, tokens []string) error { + seen := make(map[string]bool, len(tokens)) + for _, token := range tokens { + if !paramFlagTokenPattern.MatchString(token) { + return fmt.Errorf("command_override %q %s has invalid token %q", path, field, token) + } + if seen[token] { + return fmt.Errorf("command_override %q %s repeats token %q", path, field, token) + } + seen[token] = true + } + return nil +} + +func cloneParamConcepts(src ParamConcepts) ParamConcepts { + dst := ParamConcepts{Version: src.Version} + if src.Morph != nil { + dst.Morph = make(map[string]ParamMorphRule, len(src.Morph)) + for k, v := range src.Morph { + dst.Morph[k] = v + } + } + if src.Concepts != nil { + dst.Concepts = make([]Concept, 0, len(src.Concepts)) + for _, c := range src.Concepts { + dst.Concepts = append(dst.Concepts, cloneConcept(c)) + } + } + if src.ByConcept != nil { + dst.ByConcept = make(map[string]Concept, len(src.ByConcept)) + for k, v := range src.ByConcept { + dst.ByConcept[k] = cloneConcept(v) + } + } + if src.Overrides != nil { + dst.Overrides = make([]CommandOverride, 0, len(src.Overrides)) + for _, o := range src.Overrides { + dst.Overrides = append(dst.Overrides, cloneCommandOverride(o)) + } + } + if src.Fixture != nil { + dst.Fixture = append([]ParamFixtureCase(nil), src.Fixture...) + } + return dst +} + +func cloneConcept(c Concept) Concept { + c.Members = append([]string(nil), c.Members...) + c.Excludes = append([]string(nil), c.Excludes...) + c.Commands = append([]string(nil), c.Commands...) + return c +} + +func cloneCommandOverride(o CommandOverride) CommandOverride { + o.Bind = cloneStringMap(o.Bind) + o.ScopedAliases = cloneStringMap(o.ScopedAliases) + o.Block = append([]string(nil), o.Block...) + o.Ambiguous = append([]string(nil), o.Ambiguous...) + return o +} + +func cloneStringMap(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json new file mode 100644 index 00000000..38b8382a --- /dev/null +++ b/internal/cli/param_concepts.json @@ -0,0 +1,261 @@ +{ + "$schema": "./param_concepts.schema.json", + "version": 1, + + "morphological_rules": { + "kebab_camel_equivalence": {"desc": "--page-size == --pageSize", "enabled": true}, + "separator_normalization": {"desc": "-, _, . are equivalent separators", "enabled": true}, + "trailing_id_tolerance": {"desc": "--base tolerates --base-id when only one is a real flag on the command", "enabled": true, "guard": "the two must not both be real flags with different semantics"}, + "pluralization": {"desc": "--id<->--ids, --user<->--users", "enabled": false, "reason": "singular/list semantics can differ; handled by concept+intersection or command override instead"} + }, + + "concepts": { + "search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"}, + "pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"}, + "page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"}, + "page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"}, + "content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"}, + "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"}, + "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"}, + "base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"}, + "dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"}, + "dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"}, + "group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"}, + "open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"}, + "open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"}, + "group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"}, + "open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"}, + "open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"}, + "referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"}, + "user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"}, + "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"}, + "open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"}, + "ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"}, + "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"}, + "space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"}, + "app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"}, + "robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"}, + "open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"} + }, + + "command_overrides": { + "chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."}, + "chat group members": {"bind": {"id": "open_conversation_id"}}, + "chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, + "chat group members remove": {"bind": {"id": "open_conversation_id"}}, + "chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."}, + "drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"}, + "drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"}, + "ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"}, + "ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}}, + "contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"}, + "mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"}, + "mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"}, + "calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, + "chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, + "chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, + "chat message list-favorites": {"scoped_aliases": {"limit": "size"}, "scope_strict": true, "note": "On this exact command, both names denote the same bounded result count; the numeric value is unchanged."}, + "chat +messages-list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, + "chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, + "chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."}, + "contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."}, + "contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"}, + "contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."}, + "chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."}, + "chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, + "chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."}, + "chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."}, + "chat +category-rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."}, + "chat category rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."}, + "chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, + "chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, + "chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, + "chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."}, + "chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."}, + "chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, + "chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, + "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, + "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, + "chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, + "chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, + "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, + "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, + "doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"}, + "chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."}, + "doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"}, + "doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"}, + "report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"}, + "chat group members add-bot": {"bind": {"id": "open_conversation_id"}}, + "chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."}, + "chat group members remove-bot": {"bind": {"id": "open_conversation_id"}}, + "chat +send-to-group": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, + "chat group share-invite": {"scoped_aliases": {"source-conversation-id": "source", "target-conversation-id": "target"}, "block": ["group-id", "group-ids", "user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."}, + "chat message combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, + "chat message forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, + "chat message forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Conversation and message source/destination roles are preserved; role-free identifiers are rejected."}, + "chat +conversation-info": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."}, + "chat group create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed identifier domains."}, + "chat +chat-set-admin": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."}, + "chat +messages-read-status": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."}, + "chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."}, + "chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."}, + "chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."}, + "chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."} + }, + + "validation_fixture": { + "cases": [ + {"command": "oa +search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 28}, + {"command": "aitable +list-tables", "emitted": "base-id", "expect": "base", "via": "concept:base_id+morph", "occ": 26}, + {"command": "mail +find-mail-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 18}, + {"command": "aitable +field-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, + {"command": "aitable +record-query", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, + {"command": "aitable +table-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, + {"command": "doc block update", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 6}, + {"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "concept:search_query+bind", "occ": 4}, + {"command": "devdoc article search", "emitted": "limit", "expect": "size", "via": "concept:pagination_size", "occ": 2}, + {"command": "devdoc article search", "emitted": "page-size", "expect": "size", "via": "concept:pagination_size", "occ": 2}, + {"command": "devdoc article search", "emitted": "current-page", "expect": "page", "via": "concept:page_number", "occ": 2}, + {"command": "mail message search", "emitted": "subject", "expect": "query", "via": "override:scoped_strict", "occ": 4}, + {"command": "aitable +record-share-url", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 3}, + {"command": "aitable record query", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, + {"command": "calendar event list", "emitted": "date", "expect": "start", "via": "override:scoped(reviewed+payload)", "occ": 2}, + {"command": "calendar event list", "emitted": "start-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "min-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "time-min", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "end-time", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "time-max", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "max-results", "expect": "limit", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "next-cursor", "expect": "cursor", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "calendar event list", "emitted": "calendar", "expect": "calendar-id", "via": "native:reviewed-compatibility-fallback", "occ": 2}, + {"command": "chat message list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, + {"command": "chat message list-by-sender", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-format-boundary", "occ": 2}, + {"command": "doc +template-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, + {"command": "doc block insert", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 2}, + {"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2}, + {"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, + {"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2}, + {"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, + {"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, + {"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2}, + {"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, + {"command": "contact +search-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, + {"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 31}, + {"command": "ding +receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 24}, + {"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 8}, + {"command": "contact +list-sub-depts", "emitted": "dept-id", "expect": "dept", "via": "concept:dept_id+morph", "occ": 4}, + {"command": "contact +list-sub-depts", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:name-vs-id", "occ": 2}, + {"command": "contact +list-sub-depts", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:query-vs-id", "occ": 2}, + {"command": "contact user profile get", "emitted": "user-id", "expect": "staff-id", "via": "concept:user_id", "occ": 2}, + {"command": "contact user profile get", "emitted": "id", "expect": "staff-id", "via": "override:scoped", "occ": 2}, + {"command": "contact user profile get", "emitted": "ids", "expect": "staff-id", "via": "override:scoped", "occ": 2}, + {"command": "chat message send-by-bot", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 4}, + {"command": "chat message send-by-bot", "emitted": "to-user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 1}, + {"command": "dev app get", "emitted": "app-id", "expect": "unified-app-id", "via": "concept:app_id", "occ": 5}, + {"command": "chat message list-all", "emitted": "from", "expect": "start", "via": "concept:time_start", "occ": 2}, + {"command": "chat message list-all", "emitted": "start-time", "expect": "start", "via": "concept:time_start", "occ": 2}, + {"command": "chat message search-advanced", "emitted": "group", "expect": "conversation-ids", "via": "native:reviewed-single-to-list", "occ": 4}, + {"command": "chat message send", "emitted": "to-user", "expect": "user", "via": "override:scoped(reviewed+payload)", "occ": 4}, + {"command": "contact +dept-members", "emitted": "name", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2}, + {"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "concept:search_query+bind", "occ": 2}, + {"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id", "occ": 2}, + {"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "concept:dept_id", "occ": 2}, + {"command": "ding message receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 2}, + {"command": "ding message receiver-status", "emitted": "open-ding-id", "expect": "ding-id", "via": "concept:ding_id", "occ": 2}, + {"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 3}, + {"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "concept:user_ids+exclude", "occ": 2}, + {"command": "attendance check result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids", "occ": 2}, + {"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 2}, + {"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2}, + {"command": "ding message send", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code", "occ": 2}, + {"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, + {"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, + {"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2}, + {"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2}, + {"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2}, + {"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, + {"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, + {"command": "chat group get-by-group-id", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-group-id"}, + {"command": "chat group get-by-group-id", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-vs-group-id"}, + {"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"}, + {"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, + {"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"}, + {"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"}, + {"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, + {"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"}, + {"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, + {"command": "chat message search-advanced", "emitted": "open-conversation-ids", "expect": "conversation-ids", "via": "concept:open_conversation_ids"}, + {"command": "chat message search-advanced", "emitted": "group-ids", "expect": "did-you-mean:blocked", "via": "guard:group-id-list-vs-open-conversation-id-list"}, + {"command": "chat message recall", "emitted": "message-id", "expect": "msg-id", "via": "concept:open_message_id"}, + {"command": "chat message add-favorite", "emitted": "msg-id", "expect": "open-message-id", "via": "concept:open_message_id"}, + {"command": "chat message list-by-ids", "emitted": "message-ids", "expect": "msg-ids", "via": "concept:open_message_ids"}, + {"command": "chat message list-by-ids", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat message reply", "emitted": "ref-message-id", "expect": "ref-msg-id", "via": "concept:referenced_open_message_id"}, + {"command": "chat message reply", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"}, + {"command": "chat message forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-role"}, + {"command": "chat message forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"}, + {"command": "chat message combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-role"}, + {"command": "chat message forward-topic", "emitted": "dest-open-conversation-id", "expect": "dest-conversation-id", "via": "override:scoped-role"}, + {"command": "chat message forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"}, + {"command": "chat group share-invite", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-target-role"}, + {"command": "chat message send", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, + {"command": "chat message list", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, + {"command": "chat +messages-list-direct", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, + {"command": "attendance +check-result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"}, + {"command": "contact +list-sub-depts", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id"}, + {"command": "chat +conversation-info", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-vs-open-dingtalk-id"}, + {"command": "chat group members remove", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"}, + {"command": "chat group members remove", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat group create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat +chat-set-admin", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat +messages-read-status", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat group members list-by-ids", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"}, + {"command": "chat group members remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"}, + {"command": "chat group members remove-bot", "emitted": "robot-code", "expect": "did-you-mean:blocked", "via": "guard:robot-code-vs-open-bot-id"}, + {"command": "chat group members add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"}, + {"command": "chat +bot-find", "emitted": "name", "expect": "query", "via": "override:scoped"}, + {"command": "chat bot find", "emitted": "name", "expect": "query", "via": "override:scoped"}, + {"command": "chat +bot-search", "emitted": "query", "expect": "name", "via": "override:scoped"}, + {"command": "chat bot search", "emitted": "query", "expect": "name", "via": "override:scoped"}, + {"command": "chat message list-favorites", "emitted": "limit", "expect": "size", "via": "override:scoped"}, + {"command": "chat bot search", "emitted": "current-page", "expect": "page", "via": "override:scoped"}, + {"command": "chat bot search", "emitted": "cursor", "expect": "did-you-mean:blocked", "via": "guard:page-number-vs-cursor"}, + {"command": "chat message list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"}, + {"command": "chat +messages-list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}, + {"command": "chat message list", "emitted": "start", "expect": "time", "via": "override:scoped"}, + {"command": "chat message list", "emitted": "end", "expect": "did-you-mean:blocked", "via": "guard:single-time-vs-range"}, + {"command": "chat message list-all", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-range-required"}, + {"command": "chat message list-by-sender", "emitted": "user-id", "expect": "sender-user-id", "via": "override:scoped-role"}, + {"command": "chat message list-by-sender", "emitted": "open-dingtalk-id", "expect": "sender-open-dingtalk-id", "via": "override:scoped-role"}, + {"command": "chat category create-smart", "emitted": "title", "expect": "name", "via": "override:scoped"}, + {"command": "chat category create", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat message send", "emitted": "file", "expect": "file-path", "via": "override:scoped"}, + {"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, + {"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, + {"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, + {"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, + {"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, + {"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"}, + {"command": "chat message send-by-bot", "emitted": "at-ids", "expect": "did-you-mean:ambiguous", "via": "guard:user-id-vs-open-dingtalk-id"}, + {"command": "chat +bot-search", "emitted": "current-page", "expect": "page", "via": "override:scoped"}, + {"command": "chat +category-create", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat +category-rename", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat +messages-list-direct", "emitted": "start", "expect": "time", "via": "override:scoped"}, + {"command": "chat +messages-list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"}, + {"command": "chat +messages-send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, + {"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"}, + {"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"}, + {"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"} + ] + } +} diff --git a/internal/cli/param_concepts.schema.json b/internal/cli/param_concepts.schema.json new file mode 100644 index 00000000..691fa064 --- /dev/null +++ b/internal/cli/param_concepts.schema.json @@ -0,0 +1,269 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/param_concepts.schema.json", + "title": "DWS Reviewed Parameter Concept Dictionary", + "description": "Human-reviewed source of parameter concepts (equivalent flag spellings for the same entity) and per-command overrides. Build-time generators reduce these concepts against each command's real Cobra flags; they must never rewrite this file.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "version", + "concepts" + ], + "properties": { + "$schema": { + "const": "./param_concepts.schema.json", + "description": "Relative editor contract. Keep this value unchanged so agents can validate the dictionary without network access." + }, + "version": { + "const": 1, + "description": "ParamConcepts source format version." + }, + "morphological_rules": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/morphRule" + }, + "description": "Table-free, global name normalization behaviors realized by pflag SetNormalizeFunc. Documentation of morph behavior, not per-command aliases." + }, + "concepts": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/conceptId" + }, + "additionalProperties": { + "$ref": "#/$defs/concept" + }, + "description": "Global concepts keyed by stable concept id. Go validation additionally rejects members that overlap across concepts and members that intersect their own excludes." + }, + "command_overrides": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/commandPath" + }, + "additionalProperties": { + "$ref": "#/$defs/commandOverride" + }, + "description": "Per-command overrides keyed by the command path (without leading 'dws'). Only needed for generic-name binding, command-scoped aliases, co-occurrence whitelisting, or reject." + }, + "validation_fixture": { + "$ref": "#/$defs/validationFixture" + } + }, + "$defs": { + "conceptId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Stable concept id in lower snake_case." + }, + "flagToken": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "description": "A flag spelling token without leading dashes." + }, + "commandPath": { + "type": "string", + "pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$", + "description": "Exact command path without the leading 'dws' or flags. A leading '+' segment marks a shortcut subcommand. Existence against the real Cobra tree is validated by the generator, not this schema." + }, + "risk": { + "type": "string", + "enum": [ + "green", + "yellow" + ], + "description": "Reviewed risk band. green concepts reduce freely; yellow concepts need extra reviewer attention on excludes boundaries." + }, + "morphRule": { + "type": "object", + "additionalProperties": false, + "required": [ + "desc", + "enabled" + ], + "properties": { + "desc": { + "type": "string", + "minLength": 1 + }, + "enabled": { + "type": "boolean" + }, + "guard": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + }, + "concept": { + "type": "object", + "additionalProperties": false, + "required": [ + "denotes", + "canonical_hint", + "members", + "commands", + "risk" + ], + "properties": { + "denotes": { + "type": "string", + "minLength": 1, + "description": "Human description of the single entity this concept denotes." + }, + "canonical_hint": { + "$ref": "#/$defs/flagToken", + "description": "Governance hint only; the runtime reduces to whichever real flag the command exposes, not to this value." + }, + "members": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/flagToken" + }, + "description": "Equivalent spellings that all denote the concept's entity." + }, + "excludes": { + "type": "array", + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/$defs/flagToken" + }, + "description": "Spellings that denote a DIFFERENT entity and must never be reduced into this concept." + }, + "commands": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/commandPath" + }, + "description": "Exact reviewed runnable command paths on which this concept may participate in reduction. A concept never activates on an unlisted command merely because a real flag has the same spelling." + }, + "risk": { + "$ref": "#/$defs/risk" + } + } + }, + "commandOverride": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bind": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/flagToken" + }, + "additionalProperties": { + "$ref": "#/$defs/conceptId" + }, + "description": "Maps a generic real flag (e.g. id) to a concept id so concept members can reduce onto it. Go validation requires every value to be a declared concept id." + }, + "scoped_aliases": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/flagToken" + }, + "additionalProperties": { + "$ref": "#/$defs/flagToken" + }, + "description": "Command-scoped emitted->realFlag aliases. Never promoted to a global concept member." + }, + "block": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/flagToken" + }, + "description": "Emitted names that must never be reduced on this command; they route to did-you-mean instead." + }, + "ambiguous": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/flagToken" + }, + "description": "Reviewed co-occurrence whitelist: when a concept intersects two or more real flags on this command, the emitted names listed here are expected to route to did-you-mean rather than fail generation." + }, + "confirm": { + "type": "boolean", + "description": "Reviewer flagged this override as needing user confirmation of the mapping semantics." + }, + "scope_strict": { + "type": "boolean", + "description": "This alias must stay strictly command-local; another command has a real and different flag with the same spelling." + }, + "investigate": { + "type": "boolean", + "description": "Reviewer flagged this override as needing source-case investigation before landing." + }, + "note": { + "type": "string", + "minLength": 1, + "description": "Reviewer note explaining the override." + } + } + }, + "validationFixture": { + "type": "object", + "additionalProperties": false, + "required": [ + "cases" + ], + "properties": { + "cases": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/fixtureCase" + } + } + } + }, + "fixtureCase": { + "type": "object", + "additionalProperties": false, + "required": [ + "command", + "emitted", + "expect" + ], + "properties": { + "command": { + "$ref": "#/$defs/commandPath" + }, + "emitted": { + "$ref": "#/$defs/flagToken", + "description": "The name the model produced." + }, + "expect": { + "type": "string", + "minLength": 1, + "description": "Either the real flag the emitted name must reduce to, or one of the did-you-mean sentinels 'did-you-mean:ambiguous' / 'did-you-mean:blocked'." + }, + "via": { + "type": "string", + "minLength": 1, + "description": "Reviewer annotation of the reduction path; documentation only." + }, + "occ": { + "type": "integer", + "minimum": 0, + "description": "Occurrence count in the evaluation batch; frequency evidence only." + } + } + } + } +} diff --git a/internal/cli/param_concepts_contract_test.go b/internal/cli/param_concepts_contract_test.go new file mode 100644 index 00000000..c53c7b29 --- /dev/null +++ b/internal/cli/param_concepts_contract_test.go @@ -0,0 +1,312 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParamConceptsJSONSchemaDocumentsClosedShape(t *testing.T) { + var schema map[string]any + if err := json.Unmarshal(embeddedParamConceptsSchemaJSON, &schema); err != nil { + t.Fatalf("decode param_concepts.schema.json: %v", err) + } + if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false { + t.Fatalf("param concepts root schema is not closed: %#v", schema) + } + definitions := schema["$defs"].(map[string]any) + concept := definitions["concept"].(map[string]any) + if concept["additionalProperties"] != false { + t.Fatalf("concept schema allows unknown fields: %#v", concept) + } + properties := concept["properties"].(map[string]any) + for _, field := range []string{"denotes", "canonical_hint", "members", "excludes", "commands", "risk"} { + if _, ok := properties[field]; !ok { + t.Fatalf("concept schema is missing %s", field) + } + } + override := definitions["commandOverride"].(map[string]any) + if override["additionalProperties"] != false { + t.Fatalf("commandOverride schema allows unknown fields: %#v", override) + } + + var source map[string]any + if err := json.Unmarshal(embeddedParamConceptsJSON, &source); err != nil { + t.Fatalf("decode param_concepts.json: %v", err) + } + if source["$schema"] != paramConceptsSchemaRef { + t.Fatalf("param concepts source $schema = %#v, want %q", source["$schema"], paramConceptsSchemaRef) + } +} + +func TestEmbeddedParamConceptsLoadsAndSatisfiesInvariants(t *testing.T) { + concepts, err := LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + if len(concepts.Concepts) == 0 { + t.Fatal("embedded param concepts declares no concepts") + } + + // Members must be globally unique and disjoint from their own excludes. + memberOwner := make(map[string]string) + for _, concept := range concepts.Concepts { + if len(concept.Commands) == 0 { + t.Fatalf("concept %s has no reviewed command scope", concept.ID) + } + excludeSet := make(map[string]bool, len(concept.Excludes)) + for _, exclude := range concept.Excludes { + excludeSet[exclude] = true + } + for _, member := range concept.Members { + if owner, exists := memberOwner[member]; exists { + t.Fatalf("member %q belongs to both concept %s and %s", member, owner, concept.ID) + } + memberOwner[member] = concept.ID + if excludeSet[member] { + t.Fatalf("concept %s lists %q as both member and exclude", concept.ID, member) + } + } + } + + // Every bind target must reference a declared concept. + for _, override := range concepts.Overrides { + if override.Confirm || override.Investigate { + t.Fatalf("current override %q remains unresolved (confirm=%v investigate=%v)", override.CommandPath, override.Confirm, override.Investigate) + } + for flag, conceptID := range override.Bind { + if _, ok := concepts.ByConcept[conceptID]; !ok { + t.Fatalf("command_override %q binds %q to undeclared concept %q", override.CommandPath, flag, conceptID) + } + } + } + + // Fixture sentinels are limited to the two known did-you-mean forms. + for _, c := range concepts.Fixture { + if strings.HasPrefix(c.Expect, "did-you-mean:") && + c.Expect != paramDidYouMeanAmbiguous && c.Expect != paramDidYouMeanBlocked { + t.Fatalf("fixture %q/%q has unknown sentinel %q", c.Command, c.Emitted, c.Expect) + } + } +} + +func TestParamConceptRiskAuditBoundaries(t *testing.T) { + concepts, err := LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + assertMembers := func(id string, forbidden ...string) { + t.Helper() + concept, ok := concepts.ByConcept[id] + if !ok { + t.Fatalf("missing audited concept %q", id) + } + members := make(map[string]bool, len(concept.Members)) + for _, member := range concept.Members { + members[member] = true + } + for _, name := range forbidden { + if members[name] { + t.Fatalf("audited concept %s still contains forbidden cross-semantics member %q", id, name) + } + } + } + assertMembers("user_id", "users", "user-ids") + assertMembers("user_ids", "user", "user-id") + assertMembers("dept_id", "depts", "dept-ids") + assertMembers("dept_ids", "dept", "dept-id") + assertMembers("group_id", "conversation-ids", "group-ids") + assertMembers("page_number", "page-index") + assertMembers("robot_code", "robot-id") +} + +func TestDecodeParamConceptsRejectsUnknownFieldsAtEveryLevel(t *testing.T) { + valid := `{"$schema":"./param_concepts.schema.json","version":1,` + + `"concepts":{"search_query":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo cmd"],"risk":"green"}},` + + `"command_overrides":{"chat group rename":{"bind":{"id":"search_query"}}}}` + for name, input := range map[string]string{ + "root": strings.Replace(valid, `"version":1`, `"version":1,"unknown":true`, 1), + "concept": strings.Replace(valid, `"risk":"green"`, `"risk":"green","unknown":true`, 1), + "override": strings.Replace(valid, `"bind":{"id":"search_query"}`, `"bind":{"id":"search_query"},"unknown":true`, 1), + } { + t.Run(name, func(t *testing.T) { + if _, err := decodeParamConcepts([]byte(input)); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("decodeParamConcepts() error = %v, want unknown field", err) + } + }) + } +} + +func TestDecodeParamConceptsEnforcesReviewedConstraints(t *testing.T) { + wrap := func(body string) string { + return `{"$schema":"./param_concepts.schema.json","version":1,` + body + `}` + } + concept := func(members, excludes, risk string) string { + return `"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":` + members + `,"excludes":` + excludes + `,"commands":["demo cmd"],"risk":"` + risk + `"}}` + } + tests := map[string]string{ + "missing schema ref": `{"version":1,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`, + "wrong version": `{"$schema":"./param_concepts.schema.json","version":2,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`, + "no concepts": wrap(`"concepts":{}`), + "invalid concept id": wrap(`"concepts":{"BadID":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`), + "empty denotes": wrap(`"concepts":{"c_one":{"denotes":"","canonical_hint":"query","members":["query"],"risk":"green"}}`), + "invalid risk": wrap(concept(`["query"]`, `[]`, "red")), + "no members": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":[],"risk":"green"}}`), + "no command scope": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":[],"risk":"green"}}`), + "member equals exclude": wrap(concept(`["query"]`, `["query"]`, "green")), + "member overlaps concepts": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"},"c_two":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`), + "bind undeclared concept": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{"bind":{"id":"missing"}}}`), + "empty override": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{}}`), + "bad fixture sentinel": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[{"command":"chat group rename","emitted":"group","expect":"did-you-mean:oops"}]}`), + "empty fixture cases": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[]}`), + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeParamConcepts([]byte(input)); err == nil { + t.Fatal("decodeParamConcepts() unexpectedly accepted invalid reviewed source") + } + }) + } + + got, err := decodeParamConcepts([]byte(wrap(concept(`["query","keyword"]`, `["name"]`, "green")))) + if err != nil { + t.Fatalf("decodeParamConcepts() valid source error = %v", err) + } + if len(got.Concepts) != 1 || got.Concepts[0].ID != "c_one" { + t.Fatalf("decodeParamConcepts() concepts = %#v", got.Concepts) + } +} + +func validParamConceptSpecFixture() paramConceptSpec { + return paramConceptSpec{ + Denotes: "a reviewed value", + CanonicalHint: "query", + Members: []string{"query"}, + Excludes: []string{"name"}, + Commands: []string{"demo run"}, + Risk: "green", + } +} + +func TestDecodeParamConceptsRemainingSyntaxEdges(t *testing.T) { + valid := `{"$schema":"./param_concepts.schema.json","version":1,` + + `"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo run"],"risk":"green"}}}` + if _, err := decodeParamConcepts([]byte(valid + ` {}`)); err == nil || !strings.Contains(err.Error(), "multiple JSON values") { + t.Fatalf("multiple JSON values error = %v", err) + } + + withEmptyMorphDescription := strings.Replace( + valid, + `"concepts":`, + `"morphological_rules":{"camel":{"desc":""}},"concepts":`, + 1, + ) + if _, err := decodeParamConcepts([]byte(withEmptyMorphDescription)); err == nil || !strings.Contains(err.Error(), "empty desc") { + t.Fatalf("empty morph description error = %v", err) + } +} + +func TestDecodeParamConceptSpecsRemainingValidationEdges(t *testing.T) { + base := validParamConceptSpecFixture() + invalidCanonical := base + invalidCanonical.CanonicalHint = "bad token" + invalidMember := base + invalidMember.Members = []string{"bad token"} + repeatedMember := base + repeatedMember.Members = []string{"query", "query"} + invalidExclude := base + invalidExclude.Excludes = []string{"bad token"} + repeatedExclude := base + repeatedExclude.Excludes = []string{"name", "name"} + invalidCommand := base + invalidCommand.Commands = []string{"demo /bad"} + repeatedCommand := base + repeatedCommand.Commands = []string{"demo run", "demo run"} + + tests := map[string]map[string]paramConceptSpec{ + "invalid canonical hint": {"c_one": invalidCanonical}, + "invalid member": {"c_one": invalidMember}, + "repeated member": {"c_one": repeatedMember}, + "invalid exclude": {"c_one": invalidExclude}, + "repeated exclude": {"c_one": repeatedExclude}, + "invalid command": {"c_one": invalidCommand}, + "repeated command": {"c_one": repeatedCommand}, + "cross-concept member": { + "c_one": base, + "c_two": base, + }, + } + for name, specs := range tests { + t.Run(name, func(t *testing.T) { + if _, _, err := decodeParamConceptSpecs(specs); err == nil { + t.Fatal("decodeParamConceptSpecs() unexpectedly accepted invalid input") + } + }) + } +} + +func TestDecodeParamCommandOverridesRemainingValidationEdges(t *testing.T) { + byConcept := map[string]Concept{"c_one": {ID: "c_one"}} + tests := map[string]map[string]paramCommandOverride{ + "invalid path": { + " demo run": {Block: []string{"name"}}, + }, + "invalid bind flag": { + "demo run": {Bind: map[string]string{"bad token": "c_one"}}, + }, + "invalid scoped emitted": { + "demo run": {ScopedAliases: map[string]string{"bad token": "query"}}, + }, + "invalid scoped target": { + "demo run": {ScopedAliases: map[string]string{"keyword": "bad token"}}, + }, + "invalid block token": { + "demo run": {Block: []string{"bad token"}}, + }, + "repeated ambiguous token": { + "demo run": {Ambiguous: []string{"uid", "uid"}}, + }, + } + for name, specs := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeParamCommandOverrides(specs, byConcept); err == nil { + t.Fatal("decodeParamCommandOverrides() unexpectedly accepted invalid input") + } + }) + } +} + +func TestDecodeParamFixtureCasesRemainingValidationEdges(t *testing.T) { + tests := map[string]paramFixtureCaseSpec{ + "invalid command": {Command: " demo run", Emitted: "query", Expect: "query"}, + "invalid emitted": {Command: "demo run", Emitted: "bad token", Expect: "query"}, + "empty expect": {Command: "demo run", Emitted: "query", Expect: " "}, + "invalid expect": {Command: "demo run", Emitted: "query", Expect: "bad token"}, + "negative occ": {Command: "demo run", Emitted: "query", Expect: "query", Occ: -1}, + } + for name, fixture := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeParamFixtureCases(¶mValidationFixtureSpec{Cases: []paramFixtureCaseSpec{fixture}}); err == nil { + t.Fatal("decodeParamFixtureCases() unexpectedly accepted invalid input") + } + }) + } +} + +func TestParamConceptPathAndTokenValidationEdges(t *testing.T) { + if validParamCommandPath(" demo run") { + t.Fatal("command path with surrounding whitespace was accepted") + } + if validParamCommandPath("demo /bad") { + t.Fatal("command path with an invalid token was accepted") + } + if err := validParamTokenList("demo run", "block", []string{"bad token"}); err == nil { + t.Fatal("invalid parameter token was accepted") + } + if err := validParamTokenList("demo run", "block", []string{"uid", "uid"}); err == nil { + t.Fatal("repeated parameter token was accepted") + } +} diff --git a/internal/generator/cmd_param_aliases/main.go b/internal/generator/cmd_param_aliases/main.go new file mode 100644 index 00000000..e7d4a5db --- /dev/null +++ b/internal/generator/cmd_param_aliases/main.go @@ -0,0 +1,161 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command cmd_param_aliases reduces the reviewed parameter concept dictionary +// (internal/cli/param_concepts.json) against the live Cobra command tree and +// writes the deterministic per-command alias table to +// internal/cli/param_aliases_generated.go. +// +// The reduction algorithm lives in package cli (cli.ReduceParamAliases) so the +// build-time intersection and the runtime normalizer share exactly one +// implementation. This command is a thin, deterministic serializer: it never +// invents identity, and it fails closed on any un-whitelisted co-occurrence, +// missing override path, or non-real alias target. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "sort" + "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/param_aliases_generated.go" + +var ( + newParamAliasRoot = app.NewSchemaSourceRootCommand + reduceParamAliasEntries = cli.ReduceParamAliases + formatParamAliasSource = format.Source + writeParamAliasFile = os.WriteFile + exitParamAliasProcess = 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 parameter-alias table") + flag.Parse() + + if err := generateParamAliases(rootPath, outputPath); err != nil { + fail(err) + } +} + +func generateParamAliases(rootPath, outputPath string) error { + if err := validateOutputIsolation(rootPath, outputPath); err != nil { + return err + } + + entries, err := reduceParamAliasEntries(newParamAliasRoot()) + if err != nil { + return err + } + + source, err := renderParamAliases(entries) + if err != nil { + return err + } + if err := writeParamAliasFile(outputPath, source, 0o644); err != nil { + return fmt.Errorf("write generated parameter aliases: %w", err) + } + + _, _ = fmt.Fprintf(os.Stderr, "generated parameter aliases: output=%s commands=%d\n", outputPath, len(entries)) + return nil +} + +func validateOutputIsolation(rootPath, outputPath string) error { + inputs := []outputguard.Input{ + {Name: "reviewed parameter concept dictionary", Path: "internal/cli/param_concepts.json"}, + {Name: "reviewed parameter concept schema", Path: "internal/cli/param_concepts.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) +} + +// renderParamAliases serializes the reduced entries into gofmt-stable Go +// source. Entries and every map/slice are emitted in sorted order so +// consecutive generations are byte-identical. +func renderParamAliases(entries []cli.ParamAliasEntry) ([]byte, error) { + var b bytes.Buffer + b.WriteString("// Copyright 2026 Alibaba Group\n") + b.WriteString("// Licensed under the Apache License, Version 2.0 (the \"License\");\n\n") + b.WriteString("// Code generated by cmd_param_aliases; DO NOT EDIT.\n") + b.WriteString("// Source: internal/cli/param_concepts.json reduced 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("// generatedParamAliases is the per-command parameter-alias table reduced from\n") + b.WriteString("// the reviewed concept dictionary. Each entry binds one runnable Cobra leaf.\n") + b.WriteString("var generatedParamAliases = []ParamAliasEntry{\n") + + for _, entry := range entries { + b.WriteString("\t{\n") + fmt.Fprintf(&b, "\t\tCLIPath: %q,\n", entry.CLIPath) + if len(entry.Aliases) > 0 { + b.WriteString("\t\tAliases: map[string]string{\n") + for _, key := range sortedKeys(entry.Aliases) { + fmt.Fprintf(&b, "\t\t\t%q: %q,\n", key, entry.Aliases[key]) + } + b.WriteString("\t\t},\n") + } + if len(entry.Blocked) > 0 { + fmt.Fprintf(&b, "\t\tBlocked: %s,\n", renderStringSlice(entry.Blocked)) + } + if len(entry.Ambiguous) > 0 { + fmt.Fprintf(&b, "\t\tAmbiguous: %s,\n", renderStringSlice(entry.Ambiguous)) + } + b.WriteString("\t},\n") + } + b.WriteString("}\n") + b.WriteString("\nfunc loadGeneratedParamAliases() []ParamAliasEntry {\n") + b.WriteString("\treturn generatedParamAliases\n") + b.WriteString("}\n") + + formatted, err := formatParamAliasSource(b.Bytes()) + if err != nil { + return nil, fmt.Errorf("format generated parameter aliases: %w", err) + } + return formatted, nil +} + +func renderStringSlice(values []string) string { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = fmt.Sprintf("%q", v) + } + return "[]string{" + strings.Join(quoted, ", ") + "}" +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func fail(err error) { + _, _ = fmt.Fprintln(os.Stderr, "error:", err) + exitParamAliasProcess(1) +} diff --git a/internal/generator/cmd_param_aliases/main_test.go b/internal/generator/cmd_param_aliases/main_test.go new file mode 100644 index 00000000..cf2e548c --- /dev/null +++ b/internal/generator/cmd_param_aliases/main_test.go @@ -0,0 +1,184 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +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 TestParamAliasGeneratorUsesDistributionOwnedCommandTree(t *testing.T) { + got := reflect.ValueOf(newParamAliasRoot).Pointer() + want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer() + if got != want { + t.Fatal("parameter alias generator must use the distribution-owned Schema source command tree") + } +} + +func preserveParamAliasGeneratorGlobals(t *testing.T) { + t.Helper() + oldArgs, oldFlags := os.Args, flag.CommandLine + oldRoot := newParamAliasRoot + oldReduce := reduceParamAliasEntries + oldFormat := formatParamAliasSource + oldWrite := writeParamAliasFile + oldExit := exitParamAliasProcess + t.Cleanup(func() { + os.Args, flag.CommandLine = oldArgs, oldFlags + newParamAliasRoot = oldRoot + reduceParamAliasEntries = oldReduce + formatParamAliasSource = oldFormat + writeParamAliasFile = oldWrite + exitParamAliasProcess = oldExit + }) +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +func TestCrossPlatformCoverageParamAliasMainWritesGeneratedFile(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + output := filepath.Join(t.TempDir(), "param_aliases_generated.go") + flag.CommandLine = flag.NewFlagSet("param-alias-success", flag.ContinueOnError) + os.Args = []string{"cmd_param_aliases", "-root", repositoryRoot(t), "-output", output} + newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} } + reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { + return []cli.ParamAliasEntry{{CLIPath: "demo get", Aliases: map[string]string{"uid": "user"}}}, nil + } + + main() + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "func loadGeneratedParamAliases() []ParamAliasEntry") || + !strings.Contains(string(data), `"uid": "user"`) { + t.Fatalf("generated source missing runtime table:\n%s", data) + } +} + +func TestCrossPlatformCoverageParamAliasMainReportsFailure(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + flag.CommandLine = flag.NewFlagSet("param-alias-failure", flag.ContinueOnError) + os.Args = []string{ + "cmd_param_aliases", + "-root", repositoryRoot(t), + "-output", filepath.Join(repositoryRoot(t), "internal", "cli", "param_concepts.json"), + } + exitParamAliasProcess = func(code int) { panic(code) } + defer func() { + if recovered := recover(); recovered != 1 { + t.Fatalf("main exit = %#v, want 1", recovered) + } + }() + main() +} + +func TestGenerateParamAliasesFailurePaths(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + root := repositoryRoot(t) + output := filepath.Join(t.TempDir(), "param_aliases_generated.go") + newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} } + + if err := generateParamAliases(root, filepath.Join(root, "internal", "cli", "param_concepts.json")); err == nil { + t.Fatal("generateParamAliases accepted an output that overlaps a reviewed input") + } + + reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { + return nil, errors.New("reduce") + } + if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "reduce") { + t.Fatalf("reduction error = %v", err) + } + + reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { return nil, nil } + formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("format") } + if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "format generated") { + t.Fatalf("format error = %v", err) + } + + formatParamAliasSource = format.Source + writeParamAliasFile = func(string, []byte, os.FileMode) error { return errors.New("write") } + if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "write generated") { + t.Fatalf("write error = %v", err) + } +} + +func TestValidateParamAliasOutputIsolation(t *testing.T) { + root := repositoryRoot(t) + if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "aliases.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 TestRenderParamAliasesDeterministicShape(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + entries := []cli.ParamAliasEntry{ + { + CLIPath: "demo get", + Aliases: map[string]string{"z-name": "name", "a-name": "name"}, + Blocked: []string{"page", "count"}, + Ambiguous: []string{"user-id"}, + }, + {CLIPath: "demo empty"}, + } + data, err := renderParamAliases(entries) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Index(text, `"a-name": "name"`) > strings.Index(text, `"z-name": "name"`) { + t.Fatalf("alias keys are not sorted:\n%s", text) + } + for _, want := range []string{ + "func loadGeneratedParamAliases() []ParamAliasEntry", + `Blocked: []string{"page", "count"}`, + `Ambiguous: []string{"user-id"}`, + `CLIPath: "demo empty"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("generated source missing %q:\n%s", want, text) + } + } + + empty, err := renderParamAliases(nil) + if err != nil || !strings.Contains(string(empty), "var generatedParamAliases = []ParamAliasEntry{") { + t.Fatalf("empty render = %q, error = %v", empty, err) + } + if got := renderStringSlice(nil); got != "[]string{}" { + t.Fatalf("renderStringSlice(nil) = %q", got) + } + if got := sortedKeys(nil); len(got) != 0 { + t.Fatalf("sortedKeys(nil) = %v", got) + } + if got := sortedKeys(map[string]string{"b": "2", "a": "1"}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("sortedKeys() = %v", got) + } + + formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") } + if _, err := renderParamAliases(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") { + t.Fatalf("formatter error = %v", err) + } +} diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index 67a444ad..de6a788f 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -14,9 +14,14 @@ package pipeline import ( + "errors" + "fmt" + "io" "log/slog" "os" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -30,36 +35,61 @@ import ( // 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. -func RunPreParse(root *cobra.Command, engine *Engine) { +func RunPreParse(root *cobra.Command, engine *Engine) error { + _, err := RunPreParseArgs(root, engine, os.Args[1:]) + return err +} + +// RunPreParseArgs is the testable form of RunPreParse. Production passes +// os.Args[1:]; end-to-end tests can pass an isolated argv while exercising the +// 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 + return nil, nil } - rawArgs := os.Args[1:] if len(rawArgs) == 0 { - return + return nil, nil } - // Traverse the command tree to find the target command. - target, _, err := root.Traverse(rawArgs) + // Cobra's Traverse does not merge root persistent flags before deciding + // whether a leading flag consumes the next token. For example, it can treat + // the command name in `--dry-run calendar event list` as a value and resolve + // 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)) if err != nil { - return + return nil, nil } // Build FlagInfo from the target command's registered flags. flagInfos := FlagInfoFromCommand(target) if len(flagInfos) == 0 { - return + return nil, nil } ctx := &Context{ - Args: append([]string{}, rawArgs...), + Args: append([]string{}, rawArgs...), + // 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 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 + return ctx, err } // Only set corrected args if PreParse actually changed something. @@ -75,6 +105,210 @@ func RunPreParse(root *cobra.Command, engine *Engine) { ) } } + return ctx, nil +} + +func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { + if root == nil || len(rawArgs) == 0 { + return rawArgs + } + flags := root.PersistentFlags() + if flags == nil || !flags.HasFlags() { + return rawArgs + } + + matcher := newFlagTokenMatcher(flags) + out := make([]string, 0, len(rawArgs)) + for index := 0; index < len(rawArgs); index++ { + argument := rawArgs[index] + if argument == "--" { + out = append(out, rawArgs[index:]...) + break + } + flag, inlineValue, matched := matcher.matchTraversalToken(argument) + if !matched { + out = append(out, argument) + continue + } + if index+1 < len(rawArgs) { + if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok { + index++ + continue + } + } + if !inlineValue && flag.NoOptDefVal == "" && index+1 < len(rawArgs) { + index++ + } + } + return out +} + +// separatedBoolValue recognises model-friendly `--flag false` and exact +// shorthand `-f false` spellings without mistaking an already attached value +// or a shorthand cluster for a detached value. pflag otherwise treats a bare +// bool flag as true and leaves the following token positional. +func separatedBoolValue(argument, following string, flag *pflag.Flag, inlineValue bool) (string, bool) { + if flag == nil || (flag.Value.Type() != "bool" && flag.Value.Type() != "boolean") { + return "", false + } + if strings.HasPrefix(argument, "--") { + if inlineValue || strings.Contains(argument, "=") { + return "", false + } + } else if flag.Shorthand == "" || argument != "-"+flag.Shorthand { + return "", false + } + return cmdutil.NormalizeBoolLiteral(following) +} + +type longFlagMatch struct { + flag *pflag.Flag + value string + hasValue bool + recognized bool +} + +type flagTokenMatcher struct { + byName map[string]*pflag.Flag + byShorthand map[string]*pflag.Flag + known map[string]bool + candidates []string + specByName map[string]FlagInfo +} + +func newFlagTokenMatcher(flagSets ...*pflag.FlagSet) *flagTokenMatcher { + matcher := &flagTokenMatcher{ + byName: make(map[string]*pflag.Flag), + byShorthand: make(map[string]*pflag.Flag), + known: make(map[string]bool), + specByName: make(map[string]FlagInfo), + } + for _, flags := range flagSets { + if flags == nil { + continue + } + flags.VisitAll(func(flag *pflag.Flag) { + if _, exists := matcher.byName[flag.Name]; exists { + return + } + matcher.byName[flag.Name] = flag + matcher.known[flag.Name] = true + matcher.candidates = append(matcher.candidates, flag.Name) + matcher.specByName[flag.Name] = flagInfoFromPflag(flag) + if flag.Shorthand != "" { + matcher.byShorthand[flag.Shorthand] = flag + } + }) + } + return matcher +} + +func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { + if m == nil || !strings.HasPrefix(argument, "--") || argument == "--" { + return longFlagMatch{} + } + + canonical := argument + body := strings.TrimPrefix(canonical, "--") + name, value, hasValue := strings.Cut(body, "=") + if flag := m.byName[name]; flag != nil { + return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true} + } + + if normalized, ok := NormalizeFlagToken(argument, m.known); ok { + canonical = normalized + } else if split, ok := SplitStickyFlag(argument, m.specByName); ok { + name = strings.TrimPrefix(split.Flag, "--") + return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true} + } else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok { + canonical = fuzzy + } else { + return longFlagMatch{} + } + + body = strings.TrimPrefix(canonical, "--") + name, value, hasValue = strings.Cut(body, "=") + flag := m.byName[name] + return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil} +} + +func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) { + if m == nil || argument == "" || argument == "-" || argument == "--" { + return nil, false, false + } + if strings.HasPrefix(argument, "--") { + match := m.matchLongToken(argument) + return match.flag, match.hasValue, match.recognized + } + if !strings.HasPrefix(argument, "-") { + return nil, false, false + } + + body := strings.TrimPrefix(argument, "-") + shorthands := []rune(body) + for index, shorthand := range shorthands { + flag := m.byShorthand[string(shorthand)] + if flag == nil { + return nil, false, false + } + if flag.NoOptDefVal == "" { + // A value-taking shorthand consumes the next token only when it is + // last; otherwise the remainder is its attached value (`-vfjson`). + return flag, index < len(shorthands)-1, true + } + } + return m.byShorthand[string(shorthands[0])], true, true +} + +func primeEarlyErrorPresentation(root, target *cobra.Command, rawArgs []string) error { + if root == nil || target == nil || len(rawArgs) == 0 { + return nil + } + rootFlags := root.PersistentFlags() + presentationFlags := pflag.NewFlagSet("early-error-presentation", pflag.ContinueOnError) + presentationFlags.SetOutput(io.Discard) + presentationFlags.ParseErrorsWhitelist.UnknownFlags = true + presentationFlags.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { + return pflag.NormalizedName(cmdutil.Morph(name)) + }) + + names := make([]string, 0, 3) + if source := rootFlags.Lookup("format"); source != nil { + value, err := rootFlags.GetString("format") + if err != nil { + return fmt.Errorf("read presentation flag --format: %w", err) + } + presentationFlags.StringP("format", source.Shorthand, value, source.Usage) + names = append(names, "format") + } + for _, name := range []string{"debug", "verbose"} { + source := rootFlags.Lookup(name) + if source == nil { + continue + } + value, err := rootFlags.GetBool(name) + if err != nil { + return fmt.Errorf("read presentation flag --%s: %w", name, err) + } + presentationFlags.BoolP(name, source.Shorthand, value, source.Usage) + names = append(names, name) + } + // Keep the existing contract in which a PreParse conflict wins over help; + // registering help prevents pflag's special unknown-help early return. + presentationFlags.BoolP("help", "h", false, "") + + parseErr := presentationFlags.Parse(rawArgs) + errs := []error{parseErr} + for _, name := range names { + if !presentationFlags.Changed(name) { + continue + } + value := presentationFlags.Lookup(name).Value.String() + if err := rootFlags.Set(name, value); err != nil { + errs = append(errs, fmt.Errorf("apply presentation flag --%s: %w", name, err)) + } + } + return errors.Join(errs...) } // FlagInfoFromCommand extracts FlagInfo entries from a Cobra @@ -117,6 +351,7 @@ func appendFlagInfo(infos *[]FlagInfo, seen map[string]bool, flag *pflag.Flag) { func flagInfoFromPflag(f *pflag.Flag) FlagInfo { fi := FlagInfo{ Name: f.Name, + Shorthand: f.Shorthand, PropertyName: f.Name, Type: f.Value.Type(), } diff --git a/internal/pipeline/cobra_test.go b/internal/pipeline/cobra_test.go index 8a059776..78d8d5bb 100644 --- a/internal/pipeline/cobra_test.go +++ b/internal/pipeline/cobra_test.go @@ -4,9 +4,11 @@ import ( "errors" "os" "reflect" + "strings" "testing" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnotations(t *testing.T) { @@ -16,7 +18,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota root := &cobra.Command{Use: "root"} root.PersistentFlags().String("profile", "", "") child := &cobra.Command{Use: "child"} - child.Flags().String("start-time", "", "") + child.Flags().StringP("start-time", "s", "", "") child.Flags().Lookup("start-time").Annotations = map[string][]string{ "x-cli-format": {"date-time"}, "x-cli-enum": {"one", "two"}, @@ -31,7 +33,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota for _, info := range infos { byName[info.Name] = info } - if byName["profile"].Type != "string" || byName["start-time"].Format != "date-time" || + if byName["profile"].Type != "string" || byName["start-time"].Shorthand != "s" || byName["start-time"].Format != "date-time" || !reflect.DeepEqual(byName["start-time"].Enum, []string{"one", "two"}) { t.Fatalf("flag infos = %#v", infos) } @@ -106,8 +108,348 @@ func TestCrossPlatformCoverageRunPreParseAppliesCorrectionsOnlyOnSuccess(t *test failing := NewEngine() failing.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("boom") })) os.Args = []string{"root", "child", "--name", "original"} - RunPreParse(root, failing) + if err := RunPreParse(root, failing); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("failed preparse error = %v, want boom", err) + } if err := root.Execute(); err != nil || *value != "original" { t.Fatalf("failed preparse execute = %q, %v", *value, err) } } + +func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) { + tests := []struct { + name string + args []string + executable bool + }{ + {name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "boolean long flag with detached false", args: []string{"--dry-run", "false", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "camel-case boolean long flag", args: []string{"--dryRun", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "camel-case boolean with detached false", args: []string{"--dryRun", "false", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "fuzzy boolean long flag", args: []string{"--dry-rnu", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "fuzzy boolean with detached false", args: []string{"--dry-rnu", "false", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "fuzzy valued long flag", args: []string{"--profle", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "sticky valued long flag", args: []string{"--timeout30", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "clustered attached shorthand", args: []string{"-vfjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "boolean shorthand with detached false", args: []string{"-v", "false", "calendar", "event", "list", "--date", "2026-03-10"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("profile", "", "") + root.PersistentFlags().Int("timeout", 0, "") + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().BoolP("verbose", "v", false, "") + + // This similarly named root path makes the old traversal failure + // deterministic: `--dry-run` consumed "calendar" as a value and + // incorrectly selected `dws event list`. + misleadingEvent := &cobra.Command{Use: "event"} + misleadingEvent.AddCommand(&cobra.Command{Use: "list"}) + root.AddCommand(misleadingEvent) + + calendar := &cobra.Command{Use: "calendar"} + event := &cobra.Command{Use: "event"} + value := "" + list := &cobra.Command{Use: "list"} + list.Flags().StringVar(&value, "start", "", "") + event.AddCommand(list) + calendar.AddCommand(event) + root.AddCommand(calendar) + + engine := NewEngine() + engine.Register(newStub("calendar-date-alias", PreParse, func(ctx *Context) error { + if ctx.Command != "dws calendar event list" { + t.Fatalf("resolved command = %q, want dws calendar event list", ctx.Command) + } + for index, argument := range ctx.Args { + if argument == "--date" { + ctx.Args[index] = "--start" + ctx.AddCorrection("calendar-date-alias", PreParse, "start", "--date", "--start", "test") + } + } + return nil + })) + + root.SetArgs(test.args) + ctx, err := RunPreParseArgs(root, engine, test.args) + if err != nil { + t.Fatalf("RunPreParseArgs() error = %v", err) + } + if ctx == nil || len(ctx.Corrections) != 1 { + t.Fatalf("RunPreParseArgs() context = %#v", ctx) + } + if test.executable { + if err := root.Execute(); err != nil { + t.Fatalf("corrected command failed: %v", err) + } + if value != "2026-03-10" { + t.Fatalf("canonical --start value = %q", value) + } + } + }) + } +} + +func TestRunPreParsePrimesPresentationFlagsForEarlyErrors(t *testing.T) { + tests := []struct { + name string + args []string + wantFormat string + wantDebug bool + wantVerbose bool + }{ + { + name: "canonical flags after command", + args: []string{"child", "--name", "demo", "--format", "table", "--debug"}, + wantFormat: "table", + wantDebug: true, + }, + { + name: "normalized presentation names", + args: []string{"--dryRun", "--FORMAT=pretty", "--Verbose", "child", "--name", "demo"}, + wantFormat: "pretty", + wantVerbose: true, + }, + { + name: "clustered shorthands", + args: []string{"-vfraw", "child", "--name", "demo"}, + wantFormat: "raw", + wantVerbose: true, + }, + { + name: "explicit boolean presentation values", + args: []string{"--debug=true", "-v=false", "child", "--name", "demo"}, + wantFormat: "json", + wantDebug: true, + wantVerbose: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().BoolP("verbose", "v", false, "") + child := &cobra.Command{Use: "child"} + child.Flags().String("name", "", "") + root.AddCommand(child) + + engine := NewEngine() + engine.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("early") })) + ctx, err := RunPreParseArgs(root, engine, test.args) + if err == nil || ctx == nil { + t.Fatalf("RunPreParseArgs() = %#v, %v; want early error with context", ctx, err) + } + format, _ := root.PersistentFlags().GetString("format") + debug, _ := root.PersistentFlags().GetBool("debug") + verbose, _ := root.PersistentFlags().GetBool("verbose") + if format != test.wantFormat || debug != test.wantDebug || verbose != test.wantVerbose { + t.Fatalf("presentation flags = format:%q debug:%v verbose:%v; want %q/%v/%v", format, debug, verbose, test.wantFormat, test.wantDebug, test.wantVerbose) + } + }) + } +} + +func TestRunPreParseKeepsPrimaryErrorWhenPresentationParsingFails(t *testing.T) { + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().BoolP("verbose", "v", false, "") + child := &cobra.Command{Use: "child"} + child.Flags().String("name", "", "") + root.AddCommand(child) + + primaryErr := errors.New("primary pre-parse failure") + engine := NewEngine() + engine.Register(newStub("fail", PreParse, func(*Context) error { return primaryErr })) + + ctx, err := RunPreParseArgs(root, engine, []string{ + "child", "--name", "demo", "--format", "table", "--debug=maybe", + }) + if ctx == nil || !errors.Is(err, primaryErr) { + t.Fatalf("RunPreParseArgs() = %#v, %v; want primary error", ctx, err) + } + format, _ := root.PersistentFlags().GetString("format") + debug, _ := root.PersistentFlags().GetBool("debug") + if format != "table" || debug { + t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug) + } +} + +func TestCommandTraversalFlagTokenEdges(t *testing.T) { + raw := []string{"child"} + if got := argsForCommandTraversal(nil, raw); !reflect.DeepEqual(got, raw) { + t.Fatalf("nil-root traversal args = %v", got) + } + root := &cobra.Command{Use: "root"} + if got := argsForCommandTraversal(root, nil); got != nil { + t.Fatalf("empty traversal args = %v", got) + } + root.PersistentFlags().BoolP("verbose", "v", false, "") + root.PersistentFlags().StringP("format", "f", "", "") + if got := argsForCommandTraversal(root, []string{"--", "--verbose", "child"}); !reflect.DeepEqual(got, []string{"--", "--verbose", "child"}) { + t.Fatalf("double-dash traversal args = %v", got) + } + + if flag, inline, matched := newFlagTokenMatcher(nil).matchTraversalToken("--verbose"); flag != nil || inline || matched { + t.Fatalf("nil flag set matched: %#v, %v, %v", flag, inline, matched) + } + if flag, inline, matched := (*flagTokenMatcher)(nil).matchTraversalToken(""); flag != nil || inline || matched { + t.Fatalf("nil matcher matched: %#v, %v, %v", flag, inline, matched) + } + if match := (*flagTokenMatcher)(nil).matchLongToken("--verbose"); match.recognized { + t.Fatalf("nil long matcher matched: %#v", match) + } + if flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-x"); flag != nil || inline || matched { + t.Fatalf("unknown shorthand matched: %#v, %v, %v", flag, inline, matched) + } + flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-vv") + if !matched || !inline || flag == nil || flag.Name != "verbose" { + t.Fatalf("boolean shorthand cluster = %#v, %v, %v", flag, inline, matched) + } + + duplicate := pflag.NewFlagSet("duplicate", pflag.ContinueOnError) + duplicate.Bool("verbose", false, "") + matcher := newFlagTokenMatcher(root.PersistentFlags(), duplicate) + if len(matcher.byName) != 2 || matcher.byName["verbose"] != root.PersistentFlags().Lookup("verbose") { + t.Fatalf("duplicate flag precedence = %#v", matcher.byName) + } +} + +func TestSeparatedBoolValueRecognition(t *testing.T) { + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.BoolP("verbose", "v", false, "") + flags.String("format", "", "") + verbose := flags.Lookup("verbose") + format := flags.Lookup("format") + + tests := []struct { + name string + argument string + following string + flag *pflag.Flag + inline bool + want string + ok bool + }{ + {name: "nil flag", argument: "--verbose", following: "false"}, + {name: "non bool", argument: "--format", following: "false", flag: format}, + {name: "long false", argument: "--verbose", following: "false", flag: verbose, want: "false", ok: true}, + {name: "long synonym", argument: "--verbose", following: "on", flag: verbose, want: "true", ok: true}, + {name: "inline long", argument: "--verbosefalse", following: "false", flag: verbose, inline: true}, + {name: "equals long", argument: "--verbose=false", following: "true", flag: verbose}, + {name: "exact shorthand", argument: "-v", following: "0", flag: verbose, want: "false", ok: true}, + {name: "shorthand cluster", argument: "-vv", following: "false", flag: verbose}, + {name: "invalid literal", argument: "--verbose", following: "maybe", flag: verbose}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := separatedBoolValue(test.argument, test.following, test.flag, test.inline) + if got != test.want || ok != test.ok { + t.Fatalf("separatedBoolValue() = %q, %v; want %q, %v", got, ok, test.want, test.ok) + } + }) + } +} + +func TestPrimeEarlyErrorPresentationEdges(t *testing.T) { + if err := primeEarlyErrorPresentation(nil, nil, nil); err != nil { + t.Fatalf("nil presentation priming error = %v", err) + } + + root := &cobra.Command{Use: "root"} + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().BoolP("verbose", "v", false, "") + child := &cobra.Command{Use: "child"} + child.Flags().String("name", "", "") + root.AddCommand(child) + + if err := primeEarlyErrorPresentation(root, child, []string{ + "child", "--unknown", "value", "-x", "--name", "demo", + "-f", "table", "-v", "maybe", "--", "--debug", + }); err != nil { + t.Fatalf("presentation priming error = %v", err) + } + format, _ := root.PersistentFlags().GetString("format") + debug, _ := root.PersistentFlags().GetBool("debug") + verbose, _ := root.PersistentFlags().GetBool("verbose") + if format != "table" || debug || !verbose { + t.Fatalf("presentation after edge argv = format:%q debug:%v verbose:%v", format, debug, verbose) + } +} + +func TestPrimeEarlyErrorPresentationReportsParseAndContractErrors(t *testing.T) { + t.Run("invalid presentation value is reported after applying valid values", func(t *testing.T) { + root := &cobra.Command{Use: "root"} + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().BoolP("verbose", "v", false, "") + child := &cobra.Command{Use: "child"} + root.AddCommand(child) + + err := primeEarlyErrorPresentation(root, child, []string{"child", "--format", "table", "--debug=maybe"}) + if err == nil || !strings.Contains(err.Error(), "invalid argument") { + t.Fatalf("invalid presentation error = %v", err) + } + format, _ := root.PersistentFlags().GetString("format") + debug, _ := root.PersistentFlags().GetBool("debug") + if format != "table" || debug { + t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug) + } + }) + + for _, test := range []struct { + name string + add func(*pflag.FlagSet) + want string + }{ + {name: "format type drift", add: func(flags *pflag.FlagSet) { flags.Bool("format", false, "") }, want: "read presentation flag --format"}, + {name: "debug type drift", add: func(flags *pflag.FlagSet) { + flags.String("format", "json", "") + flags.String("debug", "", "") + }, want: "read presentation flag --debug"}, + } { + t.Run(test.name, func(t *testing.T) { + root := &cobra.Command{Use: "root"} + test.add(root.PersistentFlags()) + err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("presentation contract error = %v, want %q", err, test.want) + } + }) + } + + t.Run("apply error is returned", func(t *testing.T) { + root := &cobra.Command{Use: "root"} + value := &rejectingPresentationString{value: "json"} + root.PersistentFlags().VarP(value, "format", "f", "") + err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"}) + if err == nil || !strings.Contains(err.Error(), "apply presentation flag --format") { + t.Fatalf("presentation apply error = %v", err) + } + }) + + t.Run("missing presentation flags are optional", func(t *testing.T) { + root := &cobra.Command{Use: "root"} + if err := primeEarlyErrorPresentation(root, root, []string{"--unknown", "value"}); err != nil { + t.Fatalf("optional presentation flags error = %v", err) + } + }) +} + +type rejectingPresentationString struct { + value string +} + +func (v *rejectingPresentationString) Set(string) error { return errors.New("rejected") } +func (v *rejectingPresentationString) String() string { return v.value } +func (*rejectingPresentationString) Type() string { return "string" } diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go index 87496191..ad8ad9a4 100644 --- a/internal/pipeline/engine.go +++ b/internal/pipeline/engine.go @@ -24,6 +24,23 @@ type Engine struct { handlers map[Phase][]Handler } +// 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. +type HandlerError struct { + Phase Phase + Handler string + Cause error +} + +func (e *HandlerError) Error() string { + return fmt.Sprintf("pipeline %s handler %q: %v", e.Phase, e.Handler, e.Cause) +} + +func (e *HandlerError) Unwrap() error { + return e.Cause +} + // NewEngine creates a pipeline engine with no registered handlers. func NewEngine() *Engine { return &Engine{ @@ -64,7 +81,7 @@ func (e *Engine) HasHandlers(phase Phase) bool { func (e *Engine) RunPhase(phase Phase, ctx *Context) error { for _, h := range e.handlers[phase] { if err := h.Handle(ctx); err != nil { - return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err) + return &HandlerError{Phase: phase, Handler: h.Name(), Cause: err} } } return nil diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go index ce798769..002cd86e 100644 --- a/internal/pipeline/engine_test.go +++ b/internal/pipeline/engine_test.go @@ -168,6 +168,10 @@ func TestRunPhaseErrorAbortsChain(t *testing.T) { if !strings.Contains(err.Error(), "fail") { t.Errorf("error should contain handler name, got %q", err.Error()) } + var handlerErr *HandlerError + if !errors.As(err, &handlerErr) || handlerErr.Phase != PreParse || handlerErr.Handler != "fail" || handlerErr.Unwrap() != boom { + t.Fatalf("handler error = %#v, want pre-parse/fail wrapping boom", handlerErr) + } if !h1.called { t.Error("h1 should have been called") } @@ -240,6 +244,42 @@ func TestContextAddCorrection(t *testing.T) { } } +func TestContextFlagProtectionAndConflictError(t *testing.T) { + var nilContext *Context + nilContext.ProtectFlag("uid", FlagProtectionBlocked) + if nilContext.IsFlagProtected("uid") { + t.Fatal("nil context reported a protected flag") + } + + ctx := &Context{} + ctx.ProtectFlag("", FlagProtectionBlocked) + if ctx.ProtectedFlags != nil { + t.Fatalf("empty flag initialized protection map: %#v", ctx.ProtectedFlags) + } + ctx.ProtectFlag("uid", FlagProtectionAmbiguous) + if !ctx.IsFlagProtected("uid") || ctx.IsFlagProtected("missing") { + t.Fatalf("protection lookup mismatch: %#v", ctx.ProtectedFlags) + } + + err := (&FlagConflictError{ + Command: "dws demo run", + Canonical: "user", + Spellings: []string{"--user-id", "uid"}, + }).Error() + if !strings.Contains(err, `for --user on "dws demo run": --user-id, --uid`) { + t.Fatalf("FlagConflictError.Error() = %q", err) + } + + boolErr := (&BoolValueConflictError{ + Command: "dws demo run", + Flag: "--yes", + Values: []string{"true", "false"}, + }).Error() + if !strings.Contains(boolErr, `for --yes on "dws demo run": false, true`) { + t.Fatalf("BoolValueConflictError.Error() = %q", boolErr) + } +} + func TestPhaseString(t *testing.T) { tests := []struct { phase Phase diff --git a/internal/pipeline/flagmatch.go b/internal/pipeline/flagmatch.go new file mode 100644 index 00000000..a51816a7 --- /dev/null +++ b/internal/pipeline/flagmatch.go @@ -0,0 +1,153 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" +) + +const maxFlagEditDistance = 2 + +// NormalizeFlagToken folds a long flag's morphological spelling and returns a +// canonical token only when the folded name is a real flag. Both the normal +// PreParse handler chain and Cobra command traversal use this primitive so a +// spelling accepted after a command is also recognised before it. +func NormalizeFlagToken(argument string, known map[string]bool) (string, bool) { + if !strings.HasPrefix(argument, "--") { + return "", false + } + + bare := argument[2:] + if bare == "" { + return "", false + } + + var suffix string + if index := strings.IndexByte(bare, '='); index >= 0 { + suffix = bare[index:] + bare = bare[:index] + } + if known[bare] { + return "", false + } + + normalized := cmdutil.Morph(bare) + if normalized == bare || !known[normalized] { + return "", false + } + return "--" + normalized + suffix, true +} + +// StickyFlagPair is the canonical flag and value resolved from one glued +// long-flag token. Inline is required for boolean flags because pflag treats a +// bare boolean as true without consuming the following argv token. +type StickyFlagPair struct { + Flag string + Value string + Inline bool +} + +// SplitStickyFlag splits a safely recognisable glued flag/value token. The +// suffix must satisfy the real flag's type/format/enum contract, preventing a +// typo from being reinterpreted as data. +func SplitStickyFlag(argument string, specByName map[string]FlagInfo) (StickyFlagPair, bool) { + if !strings.HasPrefix(argument, "--") || strings.Contains(argument, "=") { + return StickyFlagPair{}, false + } + + bare := argument[2:] + if bare == "" { + return StickyFlagPair{}, false + } + if _, ok := specByName[bare]; ok { + return StickyFlagPair{}, false + } + if _, ok := specByName[cmdutil.Morph(bare)]; ok { + return StickyFlagPair{}, false + } + + for index := len(bare) - 1; index >= 1; index-- { + prefix := bare[:index] + matchedFlag := "" + if _, ok := specByName[prefix]; ok { + matchedFlag = prefix + } else if normalized := cmdutil.Morph(prefix); normalized != "" { + if _, ok := specByName[normalized]; ok { + matchedFlag = normalized + } + } + if matchedFlag == "" { + continue + } + + suffix := bare[index:] + spec := specByName[matchedFlag] + if !cmdutil.SuffixLooksLikeValue(suffix, spec.Type, spec.Format, spec.Enum) { + return StickyFlagPair{}, false + } + inline := false + if spec.Type == "bool" || spec.Type == "boolean" { + suffix, _ = cmdutil.NormalizeBoolLiteral(suffix) + inline = true + } + return StickyFlagPair{Flag: "--" + matchedFlag, Value: suffix, Inline: inline}, true + } + return StickyFlagPair{}, false +} + +// FuzzyMatchFlag returns the unique closest real long flag within the +// conservative edit-distance threshold used by ParamNameHandler. +func FuzzyMatchFlag(argument string, known map[string]bool, candidates []string) (string, bool) { + if !strings.HasPrefix(argument, "--") { + return "", false + } + + bare := argument[2:] + if bare == "" { + return "", false + } + + var suffix string + if index := strings.IndexByte(bare, '='); index >= 0 { + suffix = bare[index:] + bare = bare[:index] + } + if known[bare] { + return "", false + } + + threshold := maxFlagEditDistance + if len(bare) <= 3 { + threshold = 1 + } + bestDistance := threshold + 1 + bestMatch := "" + ambiguous := false + for _, candidate := range candidates { + distance := cmdutil.LevenshteinDist(bare, candidate) + if distance < bestDistance { + bestDistance = distance + bestMatch = candidate + ambiguous = false + } else if distance == bestDistance && candidate != bestMatch { + ambiguous = true + } + } + if bestDistance > threshold || ambiguous || bestMatch == "" { + return "", false + } + return "--" + bestMatch + suffix, true +} diff --git a/internal/pipeline/flagmatch_test.go b/internal/pipeline/flagmatch_test.go new file mode 100644 index 00000000..c64999a5 --- /dev/null +++ b/internal/pipeline/flagmatch_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import "testing" + +func TestNormalizeFlagTokenDirectContract(t *testing.T) { + known := map[string]bool{"user-id": true} + tests := []struct { + argument string + want string + ok bool + }{ + {argument: "-u"}, + {argument: "--"}, + {argument: "--user-id"}, + {argument: "--unknown"}, + {argument: "--unknownFlag"}, + {argument: "--userId=42", want: "--user-id=42", ok: true}, + } + for _, test := range tests { + got, ok := NormalizeFlagToken(test.argument, known) + if got != test.want || ok != test.ok { + t.Errorf("NormalizeFlagToken(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok) + } + } +} + +func TestSplitStickyFlagDirectContract(t *testing.T) { + specs := map[string]FlagInfo{ + "limit": {Name: "limit", Type: "int"}, + "page-size": {Name: "page-size", Type: "int"}, + "yes": {Name: "yes", Type: "bool"}, + } + tests := []struct { + argument string + want StickyFlagPair + ok bool + }{ + {argument: "-limit100"}, + {argument: "--limit=100"}, + {argument: "--"}, + {argument: "--limit"}, + {argument: "--pageSize"}, + {argument: "--unknown100"}, + {argument: "--limitabc"}, + {argument: "--yesfalse", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true}, + {argument: "--yestrue", want: StickyFlagPair{Flag: "--yes", Value: "true", Inline: true}, ok: true}, + {argument: "--yesno", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true}, + {argument: "--yesmaybe"}, + {argument: "--limit100", want: StickyFlagPair{Flag: "--limit", Value: "100"}, ok: true}, + {argument: "--pageSize50", want: StickyFlagPair{Flag: "--page-size", Value: "50"}, ok: true}, + } + for _, test := range tests { + got, ok := SplitStickyFlag(test.argument, specs) + if got != test.want || ok != test.ok { + t.Errorf("SplitStickyFlag(%q) = %#v, %v; want %#v, %v", test.argument, got, ok, test.want, test.ok) + } + } +} + +func TestFuzzyMatchFlagDirectContract(t *testing.T) { + known := map[string]bool{"limit": true, "name": true, "nave": true, "id": true} + candidates := []string{"limit", "name", "nave", "id"} + tests := []struct { + argument string + candidates []string + useCandidates bool + want string + ok bool + }{ + {argument: "-limt"}, + {argument: "--"}, + {argument: "--limit"}, + {argument: "--xy"}, + {argument: "--nae"}, + {argument: "--nothing", useCandidates: true}, + {argument: "--limt=10", want: "--limit=10", ok: true}, + } + for _, test := range tests { + caseCandidates := candidates + if test.useCandidates { + caseCandidates = test.candidates + } + got, ok := FuzzyMatchFlag(test.argument, known, caseCandidates) + if got != test.want || ok != test.ok { + t.Errorf("FuzzyMatchFlag(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok) + } + } +} diff --git a/internal/pipeline/handlers/alias.go b/internal/pipeline/handlers/alias.go index f88cf595..073ec9aa 100644 --- a/internal/pipeline/handlers/alias.go +++ b/internal/pipeline/handlers/alias.go @@ -14,10 +14,8 @@ package handlers import ( - "strings" - "unicode" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) // AliasHandler normalises flag names in raw argv so that common @@ -46,6 +44,10 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error { result := make([]string, 0, len(ctx.Args)) for i, arg := range ctx.Args { + if arg == "--" { + result = append(result, ctx.Args[i:]...) + break + } rewritten, ok := tryNormaliseFlag(arg, known) if ok { ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias") @@ -63,40 +65,14 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error { // normalised to a known flag name. It handles both bare flags and // "--flag=value" syntax. func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) { - if !strings.HasPrefix(arg, "--") { - return "", false - } - - bare := arg[2:] - if bare == "" { - return "", false - } - - // Handle --flag=value syntax: split, normalise the key, reassemble. - var suffix string - if idx := strings.IndexByte(bare, '='); idx >= 0 { - suffix = bare[idx:] // includes "=" - bare = bare[:idx] - } - - // Already a known flag in its current form — no change needed. - if known[bare] { - return "", false - } - - normalised := toKebabCase(bare) - if normalised == bare { - return "", false - } - if !known[normalised] { - return "", false - } - - return "--" + normalised + suffix, true + return pipeline.NormalizeFlagToken(arg, known) } -// toKebabCase converts a string from camelCase, PascalCase, or -// snake_case to kebab-case. Examples: +// toKebabCase converts a string from camelCase, PascalCase, or snake_case to +// kebab-case. It is a thin compatibility shim over the single shared +// normaliser cmdutil.Morph so the pipeline handlers and the build-time +// parameter-alias generator can never diverge on how a flag spelling is +// folded. Examples: // // "userId" → "user-id" // "UserName" → "user-name" @@ -104,43 +80,5 @@ func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) { // "USER_ID" → "user-id" // "pageSize" → "page-size" func toKebabCase(s string) string { - if s == "" { - return "" - } - - var b strings.Builder - b.Grow(len(s) + 4) // small extra for hyphens - - runes := []rune(s) - for i, r := range runes { - if r == '_' || r == ' ' { - if b.Len() > 0 { - b.WriteByte('-') - } - continue - } - - if unicode.IsUpper(r) { - // Insert hyphen before an uppercase letter when: - // 1. Not at start, AND - // 2. Previous char was lowercase, OR - // 3. Next char is lowercase (handles "userID" → "user-id" - // at the boundary between "I" and "D" in "ID" we don't - // split, but "IDs" → we split before "s" which is - // handled by the lowercase check at the next iteration). - if i > 0 { - prev := runes[i-1] - if unicode.IsLower(prev) { - b.WriteByte('-') - } else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) { - b.WriteByte('-') - } - } - b.WriteRune(unicode.ToLower(r)) - } else { - b.WriteRune(unicode.ToLower(r)) - } - } - - return strings.Trim(b.String(), "-") + return cmdutil.Morph(s) } diff --git a/internal/pipeline/handlers/alias_test.go b/internal/pipeline/handlers/alias_test.go index dd76a2fd..760da416 100644 --- a/internal/pipeline/handlers/alias_test.go +++ b/internal/pipeline/handlers/alias_test.go @@ -184,3 +184,9 @@ func TestAliasHandlerNameAndPhase(t *testing.T) { t.Errorf("Phase() = %v, want PreParse", h.Phase()) } } + +func TestTryNormaliseFlagRejectsBareDoubleDash(t *testing.T) { + if got, ok := tryNormaliseFlag("--", map[string]bool{"limit": true}); ok || got != "" { + t.Fatalf("tryNormaliseFlag(--) = %q, %v", got, ok) + } +} diff --git a/internal/pipeline/handlers/boolvalue.go b/internal/pipeline/handlers/boolvalue.go new file mode 100644 index 00000000..1df5ef6f --- /dev/null +++ b/internal/pipeline/handlers/boolvalue.go @@ -0,0 +1,162 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" +) + +// BoolValueHandler gives every real Cobra boolean flag one consistent input +// grammar before pflag parsing. pflag interprets `--yes false` as a bare +// `--yes` (true) plus a positional `false`; rewriting the pair to +// `--yes=false` preserves the value the caller actually supplied. The same +// rule applies to default-true, required, local, and inherited boolean flags: +// the handler preserves an explicit value and never substitutes a default. +// +// This handler runs after all flag-name handlers so camelCase, semantic +// aliases, and conservative fuzzy corrections have already reached their +// canonical names. It consumes only recognised boolean literals, stops at +// `--`, and rejects contradictory values for the same canonical flag so the +// result cannot depend on argv order. +type BoolValueHandler struct{} + +func (BoolValueHandler) Name() string { return "boolvalue" } +func (BoolValueHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (BoolValueHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + longNames := make(map[string]pipeline.FlagInfo) + shortNames := make(map[string]pipeline.FlagInfo) + for _, spec := range ctx.FlagSpecs { + if spec.Name == "" || (spec.Type != "bool" && spec.Type != "boolean") { + continue + } + longNames[spec.Name] = spec + if spec.Shorthand != "" { + shortNames[spec.Shorthand] = spec + } + } + + result := make([]string, 0, len(ctx.Args)) + valuesByFlag := make(map[string]map[string]bool) + for index := 0; index < len(ctx.Args); index++ { + argument := ctx.Args[index] + if argument == "--" { + result = append(result, ctx.Args[index:]...) + break + } + + spec, inlineValue, hasInlineValue, matched := matchBooleanFlagToken(argument, longNames, shortNames) + if !matched || ctx.IsFlagProtected(cmdutil.Morph(spec.Name)) { + result = append(result, argument) + continue + } + + normalized := "true" + corrected := argument + original := argument + changed := false + if hasInlineValue { + var ok bool + normalized, ok = cmdutil.NormalizeBoolLiteral(inlineValue) + if !ok { + result = append(result, argument) + continue + } + corrected = "--" + spec.Name + "=" + normalized + changed = corrected != argument + } else if index+1 < len(ctx.Args) { + if value, ok := cmdutil.NormalizeBoolLiteral(ctx.Args[index+1]); ok { + normalized = value + corrected = "--" + spec.Name + "=" + normalized + original = strings.Join(ctx.Args[index:index+2], " ") + changed = true + index++ + } + } + + if valuesByFlag[spec.Name] == nil { + valuesByFlag[spec.Name] = make(map[string]bool) + } + valuesByFlag[spec.Name][normalized] = true + if changed { + ctx.AddCorrection("boolvalue", pipeline.PreParse, "--"+spec.Name, original, corrected, "explicit-bool") + } + result = append(result, corrected) + } + + ctx.Args = result + names := make([]string, 0, len(valuesByFlag)) + for name := range valuesByFlag { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + values := valuesByFlag[name] + if len(values) < 2 { + continue + } + list := make([]string, 0, len(values)) + for value := range values { + list = append(list, value) + } + sort.Strings(list) + return &pipeline.BoolValueConflictError{ + Command: ctx.Command, + Flag: name, + Values: list, + } + } + return nil +} + +// matchBooleanFlagToken recognises canonical long flags, exact shorthands, +// and their explicit =value forms. Shorthand clusters remain native pflag +// syntax and are intentionally not reinterpreted here. +func matchBooleanFlagToken(argument string, longNames, shortNames map[string]pipeline.FlagInfo) (pipeline.FlagInfo, string, bool, bool) { + if strings.HasPrefix(argument, "--") { + bare, suffix, isFlag := splitFlagToken(argument) + if !isFlag { + return pipeline.FlagInfo{}, "", false, false + } + spec, ok := longNames[bare] + if !ok { + return pipeline.FlagInfo{}, "", false, false + } + if suffix == "" { + return spec, "", false, true + } + return spec, strings.TrimPrefix(suffix, "="), true, true + } + if !strings.HasPrefix(argument, "-") || strings.HasPrefix(argument, "--") { + return pipeline.FlagInfo{}, "", false, false + } + body := strings.TrimPrefix(argument, "-") + name, value, hasValue := body, "", false + if index := strings.IndexByte(body, '='); index >= 0 { + name, value, hasValue = body[:index], body[index+1:], true + } + spec, ok := shortNames[name] + if !ok { + return pipeline.FlagInfo{}, "", false, false + } + return spec, value, hasValue, true +} diff --git a/internal/pipeline/handlers/boolvalue_test.go b/internal/pipeline/handlers/boolvalue_test.go new file mode 100644 index 00000000..bb4b0bde --- /dev/null +++ b/internal/pipeline/handlers/boolvalue_test.go @@ -0,0 +1,132 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "errors" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestBoolValueHandler(t *testing.T) { + boolFlags := []pipeline.FlagInfo{{Name: "yes", Shorthand: "y", Type: "bool"}} + tests := []struct { + name string + args []string + flags []pipeline.FlagInfo + protected []string + want []string + corrections int + conflict bool + }{ + {name: "long false", args: []string{"--yes", "false"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1}, + {name: "long mixed-case false", args: []string{"--yes", "FaLsE"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1}, + {name: "long true synonym", args: []string{"--yes", "yes"}, flags: boolFlags, want: []string{"--yes=true"}, corrections: 1}, + {name: "shorthand zero", args: []string{"-y", "0"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1}, + {name: "shorthand equals synonym", args: []string{"-y=off"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1}, + {name: "preserves following flags", args: []string{"--yes", "off", "--format", "json"}, flags: boolFlags, want: []string{"--yes=false", "--format", "json"}, corrections: 1}, + {name: "explicit equals stays native", args: []string{"--yes=false"}, flags: boolFlags, want: []string{"--yes=false"}}, + {name: "explicit equals synonym normalizes", args: []string{"--yes=No"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1}, + {name: "bare bool stays native", args: []string{"--yes"}, flags: boolFlags, want: []string{"--yes"}}, + {name: "invalid literal is positional", args: []string{"--yes", "maybe"}, flags: boolFlags, want: []string{"--yes", "maybe"}}, + {name: "invalid inline literal stays native", args: []string{"--yes=maybe"}, flags: boolFlags, want: []string{"--yes=maybe"}}, + {name: "non bool flag is unchanged", args: []string{"--name", "false"}, flags: []pipeline.FlagInfo{{Name: "name", Type: "string"}}, want: []string{"--name", "false"}}, + {name: "unknown flag is unchanged", args: []string{"--confirm", "false"}, flags: boolFlags, want: []string{"--confirm", "false"}}, + {name: "shorthand cluster is unchanged", args: []string{"-vy", "false"}, flags: boolFlags, want: []string{"-vy", "false"}}, + {name: "protected bool is unchanged", args: []string{"--yes", "false"}, flags: boolFlags, protected: []string{"yes"}, want: []string{"--yes", "false"}}, + {name: "protected noncanonical bool uses morphed key", args: []string{"--dry_run", "false"}, flags: []pipeline.FlagInfo{{Name: "dry_run", Type: "bool"}}, protected: []string{"dry-run"}, want: []string{"--dry_run", "false"}}, + {name: "stops at double dash", args: []string{"--", "--yes", "false"}, flags: boolFlags, want: []string{"--", "--yes", "false"}}, + {name: "no specs", args: []string{"--yes", "false"}, want: []string{"--yes", "false"}}, + {name: "identical repeated values remain valid", args: []string{"--yes", "--yes=true", "--yes", "yes"}, flags: boolFlags, want: []string{"--yes", "--yes=true", "--yes=true"}, corrections: 1}, + {name: "contradictory detached values fail", args: []string{"--yes", "true", "--yes", "false"}, flags: boolFlags, want: []string{"--yes=true", "--yes=false"}, corrections: 2, conflict: true}, + {name: "bare and explicit false fail", args: []string{"--yes", "--yes=false"}, flags: boolFlags, want: []string{"--yes", "--yes=false"}, conflict: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string(nil), test.args...), + FlagSpecs: test.flags, + } + for _, protected := range test.protected { + ctx.ProtectFlag(protected, pipeline.FlagProtectionBlocked) + } + handler := BoolValueHandler{} + err := handler.Handle(ctx) + var conflict *pipeline.BoolValueConflictError + if test.conflict { + if !errors.As(err, &conflict) { + t.Fatalf("Handle() error = %v, want BoolValueConflictError", err) + } + if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) { + t.Fatalf("conflict = %#v", conflict) + } + } else if err != nil { + t.Fatalf("Handle() error = %v", err) + } + if !reflect.DeepEqual(ctx.Args, test.want) { + t.Fatalf("Args = %#v, want %#v", ctx.Args, test.want) + } + if len(ctx.Corrections) != test.corrections { + t.Fatalf("Corrections = %#v, want %d", ctx.Corrections, test.corrections) + } + if test.corrections > 0 { + correction := ctx.Corrections[0] + if correction.Handler != "boolvalue" || correction.Kind != "explicit-bool" || correction.Field != "--yes" { + t.Fatalf("correction metadata = %#v", correction) + } + } + }) + } +} + +func TestMatchBooleanFlagToken(t *testing.T) { + longNames := map[string]pipeline.FlagInfo{"yes": {Name: "yes", Shorthand: "y", Type: "bool"}} + shortNames := map[string]pipeline.FlagInfo{"y": longNames["yes"]} + tests := []struct { + argument string + value string + hasValue bool + matched bool + }{ + {argument: "--yes", matched: true}, + {argument: "--yes=false", value: "false", hasValue: true, matched: true}, + {argument: "-y", matched: true}, + {argument: "-y=true", value: "true", hasValue: true, matched: true}, + {argument: "-vy"}, + {argument: "--unknown=false"}, + {argument: "yes"}, + {argument: "--"}, + } + for _, test := range tests { + t.Run(test.argument, func(t *testing.T) { + spec, value, hasValue, matched := matchBooleanFlagToken(test.argument, longNames, shortNames) + if value != test.value || hasValue != test.hasValue || matched != test.matched { + t.Fatalf("matchBooleanFlagToken(%q) = %#v, %q, %v, %v", test.argument, spec, value, hasValue, matched) + } + if matched && spec.Name != "yes" { + t.Fatalf("matched spec = %#v", spec) + } + }) + } +} + +func TestBoolValueHandlerMeta(t *testing.T) { + handler := BoolValueHandler{} + if handler.Name() != "boolvalue" || handler.Phase() != pipeline.PreParse { + t.Fatalf("handler metadata = %q/%v", handler.Name(), handler.Phase()) + } +} diff --git a/internal/pipeline/handlers/integration_test.go b/internal/pipeline/handlers/integration_test.go index 6132781c..54e0abd1 100644 --- a/internal/pipeline/handlers/integration_test.go +++ b/internal/pipeline/handlers/integration_test.go @@ -21,7 +21,7 @@ import ( ) // TestFullPreParsePipeline exercises the complete PreParse handler -// chain: AliasHandler → StickyHandler → ParamNameHandler. It +// chain: AliasHandler → StickyHandler → ParamNameHandler → BoolValueHandler. It // simulates a model-generated CLI invocation with multiple errors // and verifies the pipeline corrects all of them in one pass. func TestFullPreParsePipeline(t *testing.T) { @@ -30,6 +30,7 @@ func TestFullPreParsePipeline(t *testing.T) { AliasHandler{}, StickyHandler{}, ParamNameHandler{}, + BoolValueHandler{}, ) // Numeric / boolean flag typing matters for the sticky guard. The @@ -106,6 +107,20 @@ func TestFullPreParsePipeline(t *testing.T) { want: "--limit-value 100", corrections: 1, // sticky handles both kebab-normalisation and split }, + { + name: "camelCase bool with detached value", + args: []string{"--dryRun", "false"}, + flags: []pipeline.FlagInfo{{Name: "dry-run", Type: "bool"}}, + want: "--dry-run=false", + corrections: 2, // alias(dryRun) + boolvalue(false) + }, + { + name: "fuzzy bool with detached value", + args: []string{"--yess", "no"}, + flags: []pipeline.FlagInfo{{Name: "yes", Type: "bool"}}, + want: "--yes=false", + corrections: 2, // paramname(yess) + boolvalue(no) + }, // Hardening: a mistyped flag whose name happens to start with // a real flag must NOT be split. The pipeline should leave the @@ -145,6 +160,56 @@ func TestFullPreParsePipeline(t *testing.T) { } } +func TestSemanticProtectionSurvivesStickyAndParamName(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + SemanticAliasHandler{Lookup: fakeLookup(nil, []string{"limt", "limit100"}, nil)}, + StickyHandler{}, + ParamNameHandler{}, + ) + ctx := &pipeline.Context{ + Command: "dws demo cmd", + Args: []string{"--limt", "10", "--limit100"}, + FlagSpecs: []pipeline.FlagInfo{ + {Name: "limit", Type: "int"}, + }, + } + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("RunPhase() error = %v", err) + } + if got, want := strings.Join(ctx.Args, " "), "--limt 10 --limit100"; got != want { + t.Fatalf("protected args = %q, want %q", got, want) + } + if len(ctx.Corrections) != 0 { + t.Fatalf("protected args were corrected: %#v", ctx.Corrections) + } +} + +func TestFullPreParsePipelineStopsAtDoubleDash(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + SemanticAliasHandler{Lookup: fakeLookup(map[string]string{"keyword": "query"}, nil, nil)}, + StickyHandler{}, + ParamNameHandler{}, + BoolValueHandler{}, + ) + ctx := &pipeline.Context{ + Command: "dws demo cmd", + Args: []string{"--query", "before", "--", "--keyword", "--limit100", "--limt"}, + FlagSpecs: []pipeline.FlagInfo{ + {Name: "query", Type: "string"}, + {Name: "limit", Type: "int"}, + }, + } + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("RunPhase() error = %v", err) + } + if got, want := strings.Join(ctx.Args, " "), "--query before -- --keyword --limit100 --limt"; got != want { + t.Fatalf("args after -- = %q, want %q", got, want) + } +} + // TestFullPostParsePipeline exercises the PostParse handler chain // with the ParamValueHandler normalising multiple value types in a // single invocation. @@ -227,7 +292,7 @@ func TestFullPipelineEndToEnd(t *testing.T) { t.Fatalf("PreParse error: %v", err) } - want := "--user-id u001 --page-size 50 --verbose true" + want := "--user-id u001 --page-size 50 --verbose=true" got := strings.Join(ctx.Args, " ") if got != want { t.Errorf("after PreParse: Args = %q, want %q", got, want) @@ -284,6 +349,7 @@ func TestFullFivePhasePipeline(t *testing.T) { AliasHandler{}, StickyHandler{}, ParamNameHandler{}, + BoolValueHandler{}, ParamValueHandler{}, PreRequestHandler{}, PostResponseHandler{}, @@ -326,7 +392,7 @@ func TestFullFivePhasePipeline(t *testing.T) { t.Fatalf("PreParse error: %v", err) } - want := "--user-id u001 --page-size 50 --verbose true" + want := "--user-id u001 --page-size 50 --verbose=true" got := strings.Join(ctx.Args, " ") if got != want { t.Errorf("after PreParse: Args = %q, want %q", got, want) @@ -426,6 +492,7 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) { AliasHandler{}, StickyHandler{}, ParamNameHandler{}, + BoolValueHandler{}, ParamValueHandler{}, PreRequestHandler{}, PostResponseHandler{}, @@ -436,7 +503,7 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) { want int }{ {pipeline.Register, 1}, - {pipeline.PreParse, 3}, + {pipeline.PreParse, 4}, {pipeline.PostParse, 1}, {pipeline.PreRequest, 1}, {pipeline.PostResponse, 1}, @@ -446,8 +513,8 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) { t.Errorf("Handlers(%v) = %d, want %d", tt.phase, got, tt.want) } } - if got := engine.HandlerCount(); got != 7 { - t.Errorf("HandlerCount = %d, want 7", got) + if got := engine.HandlerCount(); got != 8 { + t.Errorf("HandlerCount = %d, want 8", got) } } @@ -473,6 +540,7 @@ func TestPreParseDoesNotBreakValidArgs(t *testing.T) { AliasHandler{}, StickyHandler{}, ParamNameHandler{}, + BoolValueHandler{}, ) original := []string{ diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go index 21378917..9e3744a5 100644 --- a/internal/pipeline/handlers/paramname.go +++ b/internal/pipeline/handlers/paramname.go @@ -14,8 +14,6 @@ package handlers import ( - "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) @@ -35,8 +33,6 @@ import ( // done and fuzzy matching only handles genuine near-misses. type ParamNameHandler struct{} -const maxEditDistance = 2 - func (ParamNameHandler) Name() string { return "paramname" } func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse } @@ -54,7 +50,15 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error { } result := make([]string, 0, len(ctx.Args)) - for _, arg := range ctx.Args { + for i, arg := range ctx.Args { + if arg == "--" { + result = append(result, ctx.Args[i:]...) + break + } + if bare, _, isFlag := splitFlagToken(arg); isFlag && ctx.IsFlagProtected(cmdutil.Morph(bare)) { + result = append(result, arg) + continue + } rewritten, ok := tryFuzzyMatch(arg, known, names) if ok { ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy") @@ -71,50 +75,5 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error { // tryFuzzyMatch attempts to correct an unrecognised "--flag" token by // finding the closest known flag name within the edit distance threshold. func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) { - if !strings.HasPrefix(arg, "--") { - return "", false - } - - bare := arg[2:] - if bare == "" { - return "", false - } - - // Handle --flag=value syntax. - var suffix string - if idx := strings.IndexByte(bare, '='); idx >= 0 { - suffix = bare[idx:] - bare = bare[:idx] - } - - // Already known — nothing to fix. - if known[bare] { - return "", false - } - - threshold := maxEditDistance - if len(bare) <= 3 { - threshold = 1 - } - - bestDist := threshold + 1 - bestMatch := "" - ambiguous := false - - for _, candidate := range candidates { - dist := cmdutil.LevenshteinDist(bare, candidate) - if dist < bestDist { - bestDist = dist - bestMatch = candidate - ambiguous = false - } else if dist == bestDist && candidate != bestMatch { - ambiguous = true - } - } - - if bestDist > threshold || ambiguous || bestMatch == "" { - return "", false - } - - return "--" + bestMatch + suffix, true + return pipeline.FuzzyMatchFlag(arg, known, candidates) } diff --git a/internal/pipeline/handlers/semantic_alias.go b/internal/pipeline/handlers/semantic_alias.go new file mode 100644 index 00000000..2bd92396 --- /dev/null +++ b/internal/pipeline/handlers/semantic_alias.go @@ -0,0 +1,171 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" +) + +// SemanticAliasHandler rewrites semantic parameter synonyms to a command's +// canonical real flag using the build-time reduced alias table. Where +// AliasHandler only folds a spelling that already matches a real flag +// (--userId → --user-id), this handler resolves a different word to the real +// flag the command actually accepts (--keyword → --query) based on the +// reviewed concept dictionary. +// +// It runs in PreParse after AliasHandler (morphology first) and before sticky +// and paramname. The alias table is injected as Lookup so this handler never +// imports the cli package; root.go wires cli.LookupParamAlias in. +type SemanticAliasHandler struct { + // Lookup returns the aliases/blocked/ambiguous sets reduced for a raw + // Cobra CommandPath, or ok=false when the command has no reduced entry. + // Keys are already morphed (cmdutil.Morph), matching how the table is built. + Lookup func(rawCommandPath string) (aliases map[string]string, blocked, ambiguous []string, ok bool) +} + +func (SemanticAliasHandler) Name() string { return "semantic-alias" } +func (SemanticAliasHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (h SemanticAliasHandler) Handle(ctx *pipeline.Context) error { + if h.Lookup == nil || ctx.Command == "" || len(ctx.Args) == 0 { + return nil + } + aliases, blocked, ambiguous, ok := h.Lookup(ctx.Command) + if !ok { + return nil + } + for _, name := range blocked { + ctx.ProtectFlag(name, pipeline.FlagProtectionBlocked) + } + for _, name := range ambiguous { + ctx.ProtectFlag(name, pipeline.FlagProtectionAmbiguous) + } + + if err := rejectMixedAliasSpellings(ctx, aliases); err != nil { + return err + } + + for i, arg := range ctx.Args { + if arg == "--" { + break + } + bare, suffix, isFlag := splitFlagToken(arg) + if !isFlag { + continue + } + morphed := cmdutil.Morph(bare) + + // A blocked or intentionally ambiguous name must never be silently + // rewritten: it is left untouched so the unknown-flag did-you-mean + // path can surface the reviewed candidates instead of guessing. + if ctx.IsFlagProtected(morphed) { + continue + } + + canon, hit := aliases[morphed] + if !hit || canon == bare { + continue + } + + rewritten := "--" + canon + suffix + ctx.Args[i] = rewritten + ctx.AddCorrection("semantic-alias", pipeline.PreParse, canon, arg, rewritten, "semantic") + } + return nil +} + +func rejectMixedAliasSpellings(ctx *pipeline.Context, aliases map[string]string) error { + if len(aliases) == 0 { + return nil + } + targetByMorph := make(map[string]string, len(aliases)) + for _, canonical := range aliases { + targetByMorph[cmdutil.Morph(canonical)] = canonical + } + spellingsByTarget := make(map[string]map[string]bool) + hasAliasByTarget := make(map[string]bool) + for _, arg := range ctx.Args { + if arg == "--" { + break + } + bare, _, isFlag := splitFlagToken(arg) + if !isFlag { + continue + } + morphed := cmdutil.Morph(bare) + if ctx.IsFlagProtected(morphed) { + continue + } + canonical, isAlias := aliases[morphed] + if !isAlias { + canonical = targetByMorph[morphed] + } + if canonical == "" { + continue + } + if spellingsByTarget[canonical] == nil { + spellingsByTarget[canonical] = make(map[string]bool) + } + spellingsByTarget[canonical][morphed] = true + if isAlias { + hasAliasByTarget[canonical] = true + } + } + targets := make([]string, 0, len(spellingsByTarget)) + for canonical := range spellingsByTarget { + targets = append(targets, canonical) + } + sort.Strings(targets) + for _, canonical := range targets { + spellings := spellingsByTarget[canonical] + if !hasAliasByTarget[canonical] || len(spellings) < 2 { + continue + } + list := make([]string, 0, len(spellings)) + for spelling := range spellings { + list = append(list, spelling) + } + sort.Strings(list) + return &pipeline.FlagConflictError{ + Command: ctx.Command, + Canonical: canonical, + Spellings: list, + } + } + return nil +} + +// splitFlagToken splits a raw argv token into its bare flag name and any +// "=value" suffix. isFlag is false for anything that is not a "--flag" token +// (positional args, "-x" short flags, the bare "--" separator, or "--=v"). +func splitFlagToken(arg string) (bare, suffix string, isFlag bool) { + if !strings.HasPrefix(arg, "--") { + return "", "", false + } + body := arg[2:] + if body == "" { + return "", "", false + } + if idx := strings.IndexByte(body, '='); idx >= 0 { + if idx == 0 { + return "", "", false + } + return body[:idx], body[idx:], true + } + return body, "", true +} diff --git a/internal/pipeline/handlers/semantic_alias_test.go b/internal/pipeline/handlers/semantic_alias_test.go new file mode 100644 index 00000000..f59f4681 --- /dev/null +++ b/internal/pipeline/handlers/semantic_alias_test.go @@ -0,0 +1,184 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "errors" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// fakeLookup returns a fixed table for the "dws demo cmd" command and ok=false +// for anything else, standing in for cli.LookupParamAlias in tests. +func fakeLookup(aliases map[string]string, blocked, ambiguous []string) func(string) (map[string]string, []string, []string, bool) { + return func(raw string) (map[string]string, []string, []string, bool) { + if raw != "dws demo cmd" { + return nil, nil, nil, false + } + return aliases, blocked, ambiguous, true + } +} + +func newSemanticHandler() SemanticAliasHandler { + return SemanticAliasHandler{ + Lookup: fakeLookup( + map[string]string{"keyword": "query", "page-size": "limit"}, + []string{"count"}, + []string{"user-id"}, + ), + } +} + +func TestSemanticAliasHandlerRewritesSynonym(t *testing.T) { + ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--keyword", "hello"}} + if err := newSemanticHandler().Handle(ctx); err != nil { + t.Fatalf("Handle() error = %v", err) + } + if want := []string{"--query", "hello"}; !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("Args = %v, want %v", ctx.Args, want) + } + if len(ctx.Corrections) != 1 || ctx.Corrections[0].Kind != "semantic" || ctx.Corrections[0].Corrected != "--query" { + t.Fatalf("correction = %#v", ctx.Corrections) + } +} + +func TestSemanticAliasHandlerNameAndPhase(t *testing.T) { + h := SemanticAliasHandler{} + if h.Name() != "semantic-alias" || h.Phase() != pipeline.PreParse { + t.Fatalf("handler identity = %q / %s", h.Name(), h.Phase()) + } +} + +func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) { + ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}} + if err := newSemanticHandler().Handle(ctx); err != nil { + t.Fatalf("Handle() error = %v", err) + } + // --pageSize morphs to page-size, which the table aliases to limit. + if want := []string{"--limit=50"}; !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("Args = %v, want %v", ctx.Args, want) + } +} + +func TestSemanticAliasHandlerLeavesBlockedAndAmbiguous(t *testing.T) { + ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--count", "10", "--user-id", "u1"}} + if err := newSemanticHandler().Handle(ctx); err != nil { + t.Fatalf("Handle() error = %v", err) + } + if want := []string{"--count", "10", "--user-id", "u1"}; !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("blocked/ambiguous names must not be rewritten: Args = %v, want %v", ctx.Args, want) + } + if len(ctx.Corrections) != 0 { + t.Fatalf("no corrections expected, got %#v", ctx.Corrections) + } + if ctx.ProtectedFlags["count"] != pipeline.FlagProtectionBlocked || ctx.ProtectedFlags["user-id"] != pipeline.FlagProtectionAmbiguous { + t.Fatalf("protections = %#v", ctx.ProtectedFlags) + } +} + +func TestSemanticAliasHandlerProtectsWithoutAliases(t *testing.T) { + h := SemanticAliasHandler{Lookup: fakeLookup(nil, []string{"limt"}, nil)} + ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--limt", "10"}} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle() error = %v", err) + } + if !ctx.IsFlagProtected("limt") { + t.Fatalf("blocked-only entry did not populate pipeline protection: %#v", ctx.ProtectedFlags) + } +} + +func TestSemanticAliasHandlerRejectsMixedAliasAndCanonical(t *testing.T) { + for _, args := range [][]string{ + {"--keyword", "one", "--query", "two"}, + {"--query=two", "--keyword=one"}, + } { + ctx := &pipeline.Context{Command: "dws demo cmd", Args: args} + err := newSemanticHandler().Handle(ctx) + var conflict *pipeline.FlagConflictError + if !errors.As(err, &conflict) { + t.Fatalf("Handle(%v) error = %v, want FlagConflictError", args, err) + } + if conflict.Canonical != "query" || !reflect.DeepEqual(conflict.Spellings, []string{"keyword", "query"}) { + t.Fatalf("conflict = %#v", conflict) + } + } +} + +func TestSemanticAliasHandlerStopsAtDoubleDash(t *testing.T) { + ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "one", "--", "--keyword", "two"}} + if err := newSemanticHandler().Handle(ctx); err != nil { + t.Fatalf("Handle() error = %v", err) + } + if want := []string{"--query", "one", "--", "--keyword", "two"}; !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("Args = %v, want %v", ctx.Args, want) + } +} + +func TestSemanticAliasHandlerNoOpCases(t *testing.T) { + h := newSemanticHandler() + + // Unknown command → Lookup returns ok=false. + ctx := &pipeline.Context{Command: "dws other", Args: []string{"--keyword", "x"}} + _ = h.Handle(ctx) + if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) || len(ctx.Corrections) != 0 { + t.Fatalf("unknown command must be a no-op: %v / %#v", ctx.Args, ctx.Corrections) + } + + // Empty command path. + ctx = &pipeline.Context{Command: "", Args: []string{"--keyword", "x"}} + _ = h.Handle(ctx) + if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) { + t.Fatalf("empty command must be a no-op: %v", ctx.Args) + } + + // Nil Lookup (handler not wired). + ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--keyword", "x"}} + _ = SemanticAliasHandler{}.Handle(ctx) + if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) { + t.Fatalf("nil Lookup must be a no-op: %v", ctx.Args) + } + + // A real flag that also appears nowhere in the table is left alone. + ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "x", "--unknown", "y", "positional", "-n"}} + _ = h.Handle(ctx) + if !reflect.DeepEqual(ctx.Args, []string{"--query", "x", "--unknown", "y", "positional", "-n"}) { + t.Fatalf("canonical/positional/short tokens must be untouched: %v", ctx.Args) + } +} + +func TestSplitFlagToken(t *testing.T) { + cases := []struct { + arg string + bare string + suffix string + isFlag bool + }{ + {"--query", "query", "", true}, + {"--limit=50", "limit", "=50", true}, + {"--pageSize", "pageSize", "", true}, + {"positional", "", "", false}, + {"-n", "", "", false}, + {"--", "", "", false}, + {"--=v", "", "", false}, + } + for _, c := range cases { + bare, suffix, isFlag := splitFlagToken(c.arg) + if bare != c.bare || suffix != c.suffix || isFlag != c.isFlag { + t.Fatalf("splitFlagToken(%q) = (%q,%q,%v), want (%q,%q,%v)", + c.arg, bare, suffix, isFlag, c.bare, c.suffix, c.isFlag) + } + } +} diff --git a/internal/pipeline/handlers/sticky.go b/internal/pipeline/handlers/sticky.go index add1e773..16a86328 100644 --- a/internal/pipeline/handlers/sticky.go +++ b/internal/pipeline/handlers/sticky.go @@ -14,15 +14,13 @@ package handlers import ( - "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) -// StickyHandler detects glued flag-value pairs in raw argv and splits -// them into separate tokens. For example, "--limit100" becomes -// "--limit", "100" when "limit" is a known flag name. +// StickyHandler detects glued flag-value pairs in raw argv and normalizes +// them. For example, "--limit100" becomes "--limit", "100" while a boolean +// such as "--verbosefalse" becomes the single safe token "--verbose=false". // // The handler only operates on tokens that start with "--" and do not // contain "=". It tries to match the longest known flag name prefix @@ -45,11 +43,25 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error { specByName := buildFlagSpecIndex(ctx.FlagSpecs) result := make([]string, 0, len(ctx.Args)) - for _, arg := range ctx.Args { + for i, arg := range ctx.Args { + if arg == "--" { + result = append(result, ctx.Args[i:]...) + break + } + if bare, _, isFlag := splitFlagToken(arg); isFlag && ctx.IsFlagProtected(cmdutil.Morph(bare)) { + result = append(result, arg) + continue + } split, ok := trySplitSticky(arg, specByName) if ok { - ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky") - result = append(result, split.flag, split.value) + if split.inline { + corrected := split.flag + "=" + split.value + ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, corrected, "sticky") + result = append(result, corrected) + } else { + ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky") + result = append(result, split.flag, split.value) + } } else { result = append(result, arg) } @@ -60,8 +72,9 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error { } type stickyPair struct { - flag string - value string + flag string + value string + inline bool } // trySplitSticky checks if arg looks like a glued flag-value (e.g. @@ -79,69 +92,8 @@ type stickyPair struct { // that camelCase+glued values like "--pageSize50" are correctly // split to "--page-size", "50". func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) { - if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") { - return stickyPair{}, false - } - - // Strip "--" prefix to work with the bare token. - bare := arg[2:] - if bare == "" { - return stickyPair{}, false - } - - // If the whole token is a known flag, it is not sticky — it is - // a normal flag expecting a separate value token. - if _, ok := specByName[bare]; ok { - return stickyPair{}, false - } - if _, ok := specByName[toKebabCase(bare)]; ok { - return stickyPair{}, false - } - - // Try longest-prefix match: walk from len-1 down to 1, looking - // for the longest known flag that is a prefix of bare. For each - // candidate prefix, try both the raw form and kebab-case form. - bestLen := 0 - bestFlag := "" - for i := len(bare) - 1; i >= 1; i-- { - prefix := bare[:i] - - matchedFlag := "" - if _, ok := specByName[prefix]; ok { - matchedFlag = prefix - } else { - kebab := toKebabCase(prefix) - if kebab != "" { - if _, ok := specByName[kebab]; ok { - matchedFlag = kebab - } - } - } - - if matchedFlag != "" && i > bestLen { - bestLen = i - bestFlag = matchedFlag - break // longest first since we walk from the end - } - } - if bestFlag == "" { - return stickyPair{}, false - } - - suffix := bare[bestLen:] - // Guard: only split if the suffix plausibly looks like a value - // for this flag's declared type/format/enum. Otherwise leave the - // token untouched so Cobra reports "unknown flag" instead of - // silently corrupting the value. - fi := specByName[bestFlag] - if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) { - return stickyPair{}, false - } - - return stickyPair{ - flag: "--" + bestFlag, - value: suffix, - }, true + pair, ok := pipeline.SplitStickyFlag(arg, specByName) + return stickyPair{flag: pair.Flag, value: pair.Value, inline: pair.Inline}, ok } // buildFlagSpecIndex creates an index of known flag names (without "--" diff --git a/internal/pipeline/handlers/sticky_test.go b/internal/pipeline/handlers/sticky_test.go index b46b57de..294e734e 100644 --- a/internal/pipeline/handlers/sticky_test.go +++ b/internal/pipeline/handlers/sticky_test.go @@ -157,10 +157,24 @@ func TestStickyHandler(t *testing.T) { corrections: 0, }, { - name: "boolean-like value splits when type is bool", + name: "boolean-like value normalizes inline when type is bool", args: []string{"--verbosetrue"}, flags: specs(flagSpec{name: "verbose", typ: "bool"}), - want: "--verbose true", + want: "--verbose=true", + corrections: 1, + }, + { + name: "confirmation false normalizes to an inline false value", + args: []string{"--yesfalse"}, + flags: specs(flagSpec{name: "yes", typ: "bool"}), + want: "--yes=false", + corrections: 1, + }, + { + name: "model-friendly boolean no normalizes to inline false", + args: []string{"--yesno"}, + flags: specs(flagSpec{name: "yes", typ: "bool"}), + want: "--yes=false", corrections: 1, }, { diff --git a/internal/pipeline/phase.go b/internal/pipeline/phase.go index 2f3efbaa..fe4eb9b9 100644 --- a/internal/pipeline/phase.go +++ b/internal/pipeline/phase.go @@ -13,6 +13,12 @@ package pipeline +import ( + "fmt" + "sort" + "strings" +) + // Phase represents a named stage in the CLI execution pipeline. // Handlers are grouped by phase and executed in chain order within // each phase. Phases themselves execute in a fixed order defined @@ -77,8 +83,11 @@ type Context struct { // PreParse handlers may rewrite this in place. Args []string - // Command is the resolved product.tool canonical path - // (available from PostParse onward). + // Command identifies the resolved command. RunPreParse fills it with + // Cobra's raw CommandPath() (e.g. "dws chat message send-by-bot") so + // PreParse handlers can key per-command tables; the PostParse pipeline + // fills it with the resolved product.tool canonical path. The two phases + // use independent Context instances, so the differing forms never mix. Command string // Params holds structured key→value parameters after Cobra @@ -105,11 +114,82 @@ type Context struct { // handlers use this to match against raw argv tokens. FlagSpecs []FlagInfo + // ProtectedFlags carries reviewed semantic guard decisions across the + // complete PreParse chain. Keys are morphed flag names. Sticky and fuzzy + // handlers must not reinterpret a name classified as blocked or ambiguous + // by the semantic alias table. + ProtectedFlags map[string]FlagProtection + // Corrections records every correction applied by handlers, // enabling downstream logging and debugging. Corrections []Correction } +// FlagProtection identifies why an emitted flag name must not be automatically +// rewritten. +type FlagProtection string + +const ( + FlagProtectionBlocked FlagProtection = "blocked" + FlagProtectionAmbiguous FlagProtection = "ambiguous" +) + +// ProtectFlag records a reviewed no-touch decision for the remainder of the +// current pipeline context. +func (c *Context) ProtectFlag(morphed string, protection FlagProtection) { + if c == nil || morphed == "" { + return + } + if c.ProtectedFlags == nil { + c.ProtectedFlags = make(map[string]FlagProtection) + } + c.ProtectedFlags[morphed] = protection +} + +// IsFlagProtected reports whether a morphed flag name is guarded from further +// automatic interpretation. +func (c *Context) IsFlagProtected(morphed string) bool { + if c == nil { + return false + } + _, ok := c.ProtectedFlags[morphed] + return ok +} + +// FlagConflictError is returned when multiple distinct spellings that reduce +// to one scalar canonical flag are present in the same argv. Rejecting the +// command makes the outcome independent of argument order. +type FlagConflictError struct { + Command string + Canonical string + Spellings []string +} + +func (e *FlagConflictError) Error() string { + spellings := append([]string(nil), e.Spellings...) + sort.Strings(spellings) + for i := range spellings { + spellings[i] = "--" + strings.TrimPrefix(spellings[i], "--") + } + return fmt.Sprintf("conflicting parameter spellings for --%s on %q: %s; pass exactly one spelling", e.Canonical, e.Command, strings.Join(spellings, ", ")) +} + +// BoolValueConflictError is returned when one canonical boolean flag receives +// both true and false in the same argv. Rejecting contradictory values keeps +// the outcome independent of argument order while allowing repeated identical +// spellings to retain Cobra's native behaviour. +type BoolValueConflictError struct { + Command string + Flag string + Values []string +} + +func (e *BoolValueConflictError) Error() string { + values := append([]string(nil), e.Values...) + sort.Strings(values) + return fmt.Sprintf("conflicting boolean values for --%s on %q: %s; pass exactly one value", strings.TrimPrefix(e.Flag, "--"), e.Command, strings.Join(values, ", ")) +} + // FlagInfo describes a single CLI flag derived from a tool's input // schema. PreParse handlers use this to recognise valid flag names // when performing fuzzy matching or alias resolution. @@ -117,6 +197,11 @@ type FlagInfo struct { // Name is the canonical kebab-case flag name (e.g. "user-id"). Name string + // Shorthand is the optional single-character pflag shorthand (e.g. "y" + // for --yes). PreParse uses exact shorthand tokens when normalising + // explicit boolean values; shorthand clusters retain native pflag syntax. + Shorthand string + // PropertyName is the original schema property key (e.g. "userId"). PropertyName string diff --git a/pkg/cmdutil/flagfix.go b/pkg/cmdutil/flagfix.go index ccd09c83..8c9277f5 100644 --- a/pkg/cmdutil/flagfix.go +++ b/pkg/cmdutil/flagfix.go @@ -150,7 +150,12 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult { if v := lf.Annotations["x-cli-enum"]; len(v) > 0 { enumCopy = append([]string{}, v...) } - if SuffixLooksLikeValue(bestValue, lf.Value.Type(), fmtStr, enumCopy) { + flagType := lf.Value.Type() + if SuffixLooksLikeValue(bestValue, flagType, fmtStr, enumCopy) { + if flagType == "bool" || flagType == "boolean" { + normalized, _ := NormalizeBoolLiteral(bestValue) + return FlagFixResult{Suggestion: fmt.Sprintf("Use an explicit boolean value: --%s=%s", bestFlag, normalized)} + } suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue) return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue} } diff --git a/pkg/cmdutil/flagfix_test.go b/pkg/cmdutil/flagfix_test.go index e68b2509..0cdbfcc5 100644 --- a/pkg/cmdutil/flagfix_test.go +++ b/pkg/cmdutil/flagfix_test.go @@ -54,6 +54,28 @@ func TestSuggestFlagFix_trueGlue_isoDateSuffix(t *testing.T) { } } +func TestSuggestFlagFix_boolGlueNeverAutoFixes(t *testing.T) { + cmd := &cobra.Command{Use: "delete"} + cmd.Flags().Bool("yes", false, "confirm destructive operation") + + for _, test := range []struct { + body string + want string + }{ + {body: "yesfalse", want: "--yes=false"}, + {body: "yestrue", want: "--yes=true"}, + {body: "yesno", want: "--yes=false"}, + } { + fix := SuggestFlagFix(cmd, errUnknownFlag(test.body)) + if fix.AutoFixFlag != "" || fix.AutoFixValue != "" { + t.Fatalf("SuggestFlagFix(%q) auto-fix = %q/%q, want none", test.body, fix.AutoFixFlag, fix.AutoFixValue) + } + if strings.Contains(fix.Suggestion, "Space required") || !strings.Contains(fix.Suggestion, test.want) { + t.Fatalf("SuggestFlagFix(%q) suggestion = %q, want safe inline %q", test.body, fix.Suggestion, test.want) + } + } +} + func TestSuggestFlagFix_levenshteinAddsUsage(t *testing.T) { cmd := &cobra.Command{Use: "send"} cmd.Flags().String("conversation-id", "", "Conversation id") diff --git a/pkg/cmdutil/flagnorm.go b/pkg/cmdutil/flagnorm.go new file mode 100644 index 00000000..51f50327 --- /dev/null +++ b/pkg/cmdutil/flagnorm.go @@ -0,0 +1,69 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import "strings" + +// Morph normalizes a flag spelling to a table-free canonical form so that +// morphological variants collapse to one key: case is folded, '_', '.' and +// spaces are treated as '-', and camelCase boundaries become '-'. It is the +// single shared primitive used both at build time (when the parameter-alias +// generator intersects concept members with a command's real flags) and at +// runtime (when pflag's SetNormalizeFunc resolves an emitted name). Keeping one +// implementation is a hard invariant: if the two diverged, a name the generator +// believed reducible could fail to resolve at runtime, which is contract drift. +// +// Morph is idempotent and stable on already-canonical names: Morph("limit") == +// "limit" and Morph("page-size") == "page-size", so applying it to a command's +// real flags never changes their identity. +func Morph(name string) string { + s := camelToKebab(name) + s = strings.NewReplacer("_", "-", ".", "-", " ", "-").Replace(s) + s = strings.ToLower(s) + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + return strings.Trim(s, "-") +} + +// camelToKebab inserts a '-' at camelCase boundaries. A boundary is an +// uppercase rune that follows a lowercase letter or digit (fooBar -> foo-Bar), +// or an uppercase rune that starts a new word inside an acronym run +// (APIKey -> API-Key). Existing separators are left untouched; ToLower runs +// afterwards in Morph. +func camelToKebab(s string) string { + if s == "" { + return s + } + runes := []rune(s) + var b strings.Builder + b.Grow(len(runes) + 4) + for i, r := range runes { + if i > 0 && isUpper(r) { + prev := runes[i-1] + switch { + case isLower(prev) || isDigit(prev): + b.WriteByte('-') + case isUpper(prev) && i+1 < len(runes) && isLower(runes[i+1]): + b.WriteByte('-') + } + } + b.WriteRune(r) + } + return b.String() +} + +func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' } +func isLower(r rune) bool { return r >= 'a' && r <= 'z' } +func isDigit(r rune) bool { return r >= '0' && r <= '9' } diff --git a/pkg/cmdutil/flagnorm_test.go b/pkg/cmdutil/flagnorm_test.go new file mode 100644 index 00000000..984b2586 --- /dev/null +++ b/pkg/cmdutil/flagnorm_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import "testing" + +func TestMorphIsStableOnCanonicalNames(t *testing.T) { + for _, name := range []string{"limit", "page-size", "query", "conversation-id", "start", "id", "unified-app-id"} { + if got := Morph(name); got != name { + t.Fatalf("Morph(%q) = %q, want stable %q", name, got, name) + } + } +} + +func TestMorphNormalizesVariants(t *testing.T) { + cases := map[string]string{ + "pageSize": "page-size", + "page_size": "page-size", + "page.size": "page-size", + "PageSize": "page-size", + "maxResults": "max-results", + "startTime": "start-time", + "start_time": "start-time", + "APIKey": "api-key", + "userID": "user-id", + "conversationId": "conversation-id", + "LIMIT": "limit", + "--page--size": "page-size", + } + for in, want := range cases { + if got := Morph(in); got != want { + t.Fatalf("Morph(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMorphIsIdempotent(t *testing.T) { + for _, name := range []string{"pageSize", "start_time", "APIKey", "conversationId", "max.results"} { + once := Morph(name) + if twice := Morph(once); twice != once { + t.Fatalf("Morph not idempotent for %q: %q -> %q", name, once, twice) + } + } +} + +func TestMorphHandlesEmptyAndSeparators(t *testing.T) { + if got := Morph(""); got != "" { + t.Fatalf("Morph(\"\") = %q, want empty", got) + } + if got := Morph("-x-"); got != "x" { + t.Fatalf("Morph(%q) = %q, want %q", "-x-", got, "x") + } +} + +// TestMorphCoversLegacyToKebabCase locks the basis-A invariant: Morph is a +// strict superset of the pipeline handlers' former toKebabCase, which is now a +// shim delegating here. These are the exact cases the handler package used to +// assert, so a future change to Morph that regressed any of them (camelCase, +// PascalCase, snake_case, UPPER_CASE, acronym runs, spaces, or leading +// separators) would be caught before it could split the two implementations. +func TestMorphCoversLegacyToKebabCase(t *testing.T) { + cases := map[string]string{ + "userId": "user-id", + "UserName": "user-name", + "user_name": "user-name", + "USER_ID": "user-id", + "pageSize": "page-size", + "user-id": "user-id", + "limit": "limit", + "ID": "id", + "userID": "user-id", + "HTMLParser": "html-parser", + "getHTTPResponse": "get-http-response", + "a": "a", + "": "", + "ABC": "abc", + "already-kebab": "already-kebab", + "with spaces": "with-spaces", + "__leading_underscores": "leading-underscores", + "mixedCamel_and_snake": "mixed-camel-and-snake", + } + for in, want := range cases { + if got := Morph(in); got != want { + t.Fatalf("Morph(%q) = %q, want %q (legacy toKebabCase parity)", in, got, want) + } + } +} diff --git a/pkg/cmdutil/sticky_suffix.go b/pkg/cmdutil/sticky_suffix.go index ad1c721e..46970773 100644 --- a/pkg/cmdutil/sticky_suffix.go +++ b/pkg/cmdutil/sticky_suffix.go @@ -40,7 +40,8 @@ func SuffixLooksLikeValue(suffix, typ, format string, enum []string) bool { return startsWithNumericSuffix(suffix) case "bool", "boolean": - return isBoolLiteralSuffix(suffix) + _, ok := NormalizeBoolLiteral(suffix) + return ok case "duration": return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix) @@ -110,12 +111,17 @@ func startsWithNumericSuffix(s string) bool { return false } -func isBoolLiteralSuffix(s string) bool { - switch strings.ToLower(s) { - case "true", "false", "1", "0", "t", "f", "yes", "no", "on", "off", "y", "n": - return true +// NormalizeBoolLiteral reduces model-friendly boolean spellings to the exact +// values accepted by an unambiguous --flag=true/false pflag token. +func NormalizeBoolLiteral(s string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "1", "t", "yes", "on", "y": + return "true", true + case "false", "0", "f", "no", "off", "n": + return "false", true + default: + return "", false } - return false } func matchesEnumSuffix(s string, enum []string) bool { diff --git a/pkg/cmdutil/sticky_suffix_test.go b/pkg/cmdutil/sticky_suffix_test.go index 0539a3ee..8c006327 100644 --- a/pkg/cmdutil/sticky_suffix_test.go +++ b/pkg/cmdutil/sticky_suffix_test.go @@ -15,6 +15,28 @@ package cmdutil import "testing" +func TestNormalizeBoolLiteral(t *testing.T) { + tests := []struct { + input string + want string + ok bool + }{ + {input: "TRUE", want: "true", ok: true}, + {input: " yes ", want: "true", ok: true}, + {input: "0", want: "false", ok: true}, + {input: "Off", want: "false", ok: true}, + {input: "maybe"}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got, ok := NormalizeBoolLiteral(test.input) + if got != test.want || ok != test.ok { + t.Fatalf("NormalizeBoolLiteral(%q) = %q, %v; want %q, %v", test.input, got, ok, test.want, test.ok) + } + }) + } +} + // TestSuffixLooksLikeValue_UTF8FirstRune locks in the UTF-8 first-rune // reading contract on SuffixLooksLikeValue. The function previously read // suffix[0] (a single byte) which produced incorrect splits / matches for @@ -67,6 +89,32 @@ func TestSuffixLooksLikeValue_UTF8FirstRune(t *testing.T) { typ: "int", want: true, }, + { + // The caller must keep the normalized value inline as + // --flag=false; SuffixLooksLikeValue only validates the suffix. + name: "bool + false suffix is recognized", + suffix: "false", + typ: "bool", + want: true, + }, + { + name: "boolean + model-friendly no suffix is recognized", + suffix: "no", + typ: "boolean", + want: true, + }, + { + name: "boolean + unknown suffix is rejected", + suffix: "maybe", + typ: "boolean", + want: false, + }, + { + name: "boolean + true suffix is recognized", + suffix: "true", + typ: "boolean", + want: true, + }, { // Invalid UTF-8 (lone continuation byte 0x80) decodes as // utf8.RuneError; the RuneError guard makes the fallback diff --git a/scripts/policy/check-generated-drift.sh b/scripts/policy/check-generated-drift.sh index 70d25665..f42fceef 100755 --- a/scripts/policy/check-generated-drift.sh +++ b/scripts/policy/check-generated-drift.sh @@ -14,15 +14,22 @@ tmp="$(mktemp -d)" 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" 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 # CommandRegistry is a reviewed input, never a generated artifact. Keep an # independent byte-for-byte guard around the ordinary downstream generators. registry_guard="$tmp/schema_command_registry" cp -R internal/cli/schema_command_registry "$registry_guard" +# The parameter concept dictionary + its schema are reviewed inputs too. +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" # Also guard human-authored metadata + selection hint trees. metadata_guard="$tmp/metadata-hints" selection_guard="$tmp/selection-hints" @@ -33,6 +40,8 @@ metadata_tmp="$tmp/metadata" audit_tmp="$tmp/audit.json" 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" "$metadata_generator" \ -root . \ @@ -62,11 +71,29 @@ fi -root . \ -output "$catalog_tmp_second" +"$param_aliases_generator" \ + -root . \ + -output "$param_aliases_tmp" + +"$param_aliases_generator" \ + -root . \ + -output "$param_aliases_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 fi +if ! cmp -s internal/cli/param_concepts.json "$concepts_guard"; then + printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2 + exit 1 +fi + +if ! cmp -s internal/cli/param_concepts.schema.json "$concepts_schema_guard"; then + printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.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 @@ -90,4 +117,17 @@ if ! diff -qr internal/cli/schema_catalog "$catalog_tmp" >/dev/null; then exit 1 fi +if ! cmp -s "$param_aliases_tmp" "$param_aliases_tmp_second"; then + printf '%s\n' 'generated drift: consecutive parameter-alias generations are not byte-identical' >&2 + diff -u "$param_aliases_tmp" "$param_aliases_tmp_second" || true + exit 1 +fi + +if ! cmp -s internal/cli/param_aliases_generated.go "$param_aliases_tmp"; then + printf '%s\n' 'generated drift: internal/cli/param_aliases_generated.go is stale' >&2 + printf '%s\n' 'run: make generate-schema' >&2 + diff -u internal/cli/param_aliases_generated.go "$param_aliases_tmp" || true + exit 1 +fi + printf 'generated drift check: ok\n' diff --git a/scripts/policy/check-param-alias-cooccurrence.sh b/scripts/policy/check-param-alias-cooccurrence.sh new file mode 100755 index 00000000..81941bef --- /dev/null +++ b/scripts/policy/check-param-alias-cooccurrence.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +# Scan every runnable command for parameter-concept co-occurrence. A concept +# whose members intersect two or more VISIBLE real flags on one command cannot +# be auto-reduced (picking one would silently mis-map the other) and must be an +# explicitly reviewed `ambiguous` entry in param_concepts.json, or reduction +# fails. This re-runs the single reduction algorithm — cli.ReduceParamAliases, +# invoked by the parameter-alias generator — against the live Cobra tree built +# by app.NewRootCommand(), so a future command or flag that introduces an +# unreviewed co-occurrence (or a scoped_aliases/bind override targeting a flag +# that is not real) turns this gate red before it can ship a silent mis-map. +# +# It is intentionally distinct from check-generated-drift.sh: drift proves the +# committed table is byte-current, whereas this gate isolates and reports the +# co-occurrence / override-target contract with a focused failure message. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" +. "$ROOT/scripts/policy/policy-runtime.sh" +policy_prepare_runtime "$ROOT" + +exec_tmp="$(policy_runtime_mktemp_dir dws-param-cooccurrence)" +# The generator refuses to write inside the repository tree, so the throwaway +# output goes to a system temp dir (mirrors check-generated-drift.sh). +out_tmp="$(mktemp -d)" +generator="$exec_tmp/param-aliases" +out="$out_tmp/param_aliases_generated.go" +err="$exec_tmp/err.log" +trap 'rm -rf "$exec_tmp" "$out_tmp"' EXIT HUP INT TERM + +"${GO:-go}" build -o "$generator" ./internal/generator/cmd_param_aliases + +if ! "$generator" -root . -output "$out" 2>"$err"; then + printf '%s\n' 'param-alias co-occurrence gate: FAIL' >&2 + printf '%s\n' 'an unreviewed concept co-occurrence (>=2 visible real flags) or an invalid scoped/bind override target was found:' >&2 + cat "$err" >&2 + exit 1 +fi + +printf 'param-alias co-occurrence gate: ok\n' diff --git a/scripts/policy/check-param-concepts.sh b/scripts/policy/check-param-concepts.sh new file mode 100755 index 00000000..bc963a8c --- /dev/null +++ b/scripts/policy/check-param-concepts.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +# Validate the reviewed parameter concept dictionary +# (internal/cli/param_concepts.json) against its closed schema and every loader +# semantic invariant: globally unique concept members, reviewed exact command +# scopes, members disjoint from their own excludes, bind targets referencing a +# declared concept, no unresolved confirm/investigate decisions, audited +# single/list and ID/name boundaries, closed (unknown-field-rejecting) decoding +# at every level, and the fixture did-you-mean sentinel allow-list. These +# assertions run through the real +# embedded loader (cli.LoadParamConcepts / decodeParamConcepts), not a +# re-implementation, so the shell gate and the runtime agree by construction. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" +. "$ROOT/scripts/policy/policy-runtime.sh" +policy_prepare_runtime "$ROOT" + +if ! "${GO:-go}" test -count=1 ./internal/cli -run \ + '^(TestParamConceptsJSONSchemaDocumentsClosedShape|TestEmbeddedParamConceptsLoadsAndSatisfiesInvariants|TestParamConceptRiskAuditBoundaries|TestDecodeParamConceptsRejectsUnknownFieldsAtEveryLevel|TestDecodeParamConceptsEnforcesReviewedConstraints|TestGeneratedParamAliasesAreWellFormed)$'; then + printf '%s\n' 'param concepts check: FAIL (reviewed dictionary violates its schema or a loader invariant)' >&2 + exit 1 +fi + +printf 'param concepts check: ok\n'