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..a5fabda9 --- /dev/null +++ b/internal/app/param_concept_calendar_pilot_test.go @@ -0,0 +1,156 @@ +// 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" +) + +// TestCalendarEventListParamConceptPilotBehaviorUnchanged is the P2 pilot +// behaviour-preservation gate. The hand-written hidden spelling variants on +// `dws calendar event list` (start-time / min-time / end-time / max-results / +// next-cursor / calendar / ...) were removed from the Cobra command and now +// live only in the reviewed concept dictionary. This test drives the REAL +// PreParse pipeline (newPipelineEngine → AliasHandler + SemanticAliasHandler +// wired to cli.LookupParamAlias) exactly the way root.go's RunPreParse does, +// then proves the corrected argv still parses onto the canonical flag with the +// original value intact — i.e. every previously-accepted spelling keeps +// working end to end even though its dedicated hidden flag is gone. +func TestCalendarEventListParamConceptPilotBehaviorUnchanged(t *testing.T) { + engine := newPipelineEngine() + + cases := []struct { + emitted string // spelling the model/user typed (dedicated flag removed) + value string + canonical string // real flag the pipeline must reduce it to + isInt bool + }{ + // time_start concept members + scoped aliases → --start + {"start-time", "2026-03-10T14:00:00+08:00", "start", false}, + {"startTime", "2026-03-10T14:00:00+08:00", "start", false}, + {"start_time", "2026-03-10T14:00:00+08:00", "start", false}, + {"start-date", "2026-03-10T14:00:00+08:00", "start", false}, + {"min-time", "2026-03-10T14:00:00+08:00", "start", false}, + {"time-min", "2026-03-10T14:00:00+08:00", "start", false}, + {"from", "2026-03-10T14:00:00+08:00", "start", false}, + {"since", "2026-03-10T14:00:00+08:00", "start", false}, + {"date", "2026-03-10T14:00:00+08:00", "start", false}, + // end family (scoped aliases; no global time_end concept) → --end + {"end-time", "2026-03-10T18:00:00+08:00", "end", false}, + {"endTime", "2026-03-10T18:00:00+08:00", "end", false}, + {"end-date", "2026-03-10T18:00:00+08:00", "end", false}, + {"max-time", "2026-03-10T18:00:00+08:00", "end", false}, + {"time-max", "2026-03-10T18:00:00+08:00", "end", false}, + // pagination_size concept members → --limit (int) + {"max-results", "50", "limit", true}, + {"maxResults", "50", "limit", true}, + {"page-size", "50", "limit", true}, + {"size", "50", "limit", true}, + // page_cursor concept members → --cursor + {"next-cursor", "TOKEN123", "cursor", false}, + {"nextCursor", "TOKEN123", "cursor", false}, + {"page-token", "TOKEN123", "cursor", false}, + {"next-token", "TOKEN123", "cursor", false}, + // morphology / scoped alias → --calendar-id + {"calendar", "primary", "calendar-id", false}, + {"calendarId", "primary", "calendar-id", 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) + } + + joined := strings.Join(ctx.Args, " ") + if !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) { + t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args) + } + // Token-level check so a canonical that is a prefix of the emitted + // spelling (e.g. --calendar → --calendar-id) is not a false match. + if tc.emitted != tc.canonical { + for _, a := range ctx.Args { + if !strings.HasPrefix(a, "--") { + continue + } + bare := strings.SplitN(strings.TrimPrefix(a, "--"), "=", 2)[0] + if bare == tc.emitted { + t.Fatalf("emitted spelling --%s survived after reduction: args = %v", tc.emitted, ctx.Args) + } + } + } + + // Behaviour unchanged: Cobra parses the corrected argv onto the + // canonical flag, and the original value lands unmodified. + 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(tc.canonical) + if err != nil || got != 50 { + t.Fatalf("flag --%s = %d (err %v), want 50", tc.canonical, got, err) + } + } else { + got, err := target.Flags().GetString(tc.canonical) + if err != nil || got != tc.value { + t.Fatalf("flag --%s = %q (err %v), want %q", tc.canonical, 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/root.go b/internal/app/root.go index a929a0db..6cfcf615 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -1248,11 +1248,20 @@ 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 + // PreParse handlers run in order: alias → semantic → sticky → paramname. + // 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). 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{}, diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index 89184278..81246fa3 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -1058,60 +1058,15 @@ func newCalendarCommand() *cobra.Command { // ListEvent flags eventListCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T14:00:00+08:00)") eventListCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-10T18:00:00+08:00)") - eventListCmd.Flags().String("time-min", "", "") - _ = eventListCmd.Flags().MarkHidden("time-min") - eventListCmd.Flags().String("time-max", "", "") - _ = eventListCmd.Flags().MarkHidden("time-max") - eventListCmd.Flags().String("min-time", "", "") - _ = eventListCmd.Flags().MarkHidden("min-time") - eventListCmd.Flags().String("max-time", "", "") - _ = eventListCmd.Flags().MarkHidden("max-time") - eventListCmd.Flags().String("start-time", "", "") - _ = eventListCmd.Flags().MarkHidden("start-time") - eventListCmd.Flags().String("end-time", "", "") - _ = eventListCmd.Flags().MarkHidden("end-time") - eventListCmd.Flags().String("startTime", "", "") - _ = eventListCmd.Flags().MarkHidden("startTime") - eventListCmd.Flags().String("endTime", "", "") - _ = eventListCmd.Flags().MarkHidden("endTime") - eventListCmd.Flags().String("start_time", "", "") - _ = eventListCmd.Flags().MarkHidden("start_time") - eventListCmd.Flags().String("end_time", "", "") - _ = eventListCmd.Flags().MarkHidden("end_time") - eventListCmd.Flags().String("start-date", "", "") - _ = eventListCmd.Flags().MarkHidden("start-date") - eventListCmd.Flags().String("end-date", "", "") - _ = eventListCmd.Flags().MarkHidden("end-date") - eventListCmd.Flags().String("startDate", "", "") - _ = eventListCmd.Flags().MarkHidden("startDate") - eventListCmd.Flags().String("endDate", "", "") - _ = eventListCmd.Flags().MarkHidden("endDate") + // P2 param-concept pilot: the hand-written hidden spelling variants for + // start / end / calendar-id / cursor / limit were removed. The reviewed + // concept dictionary (param_concepts.json) now reduces those synonyms to + // the canonical flag in the PreParse pipeline (AliasHandler morphology + + // SemanticAliasHandler), so only the canonical flags are registered here. + // --count is kept: pagination_size deliberately excludes it (count != limit). eventListCmd.Flags().String("calendar-id", "", "日历 ID (默认 primary 主日历,仅在查询其他日历本时填写)") - eventListCmd.Flags().String("calendarId", "", "") - _ = eventListCmd.Flags().MarkHidden("calendarId") - eventListCmd.Flags().String("calendar", "", "") - _ = eventListCmd.Flags().MarkHidden("calendar") eventListCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)") - eventListCmd.Flags().String("next-cursor", "", "") - _ = eventListCmd.Flags().MarkHidden("next-cursor") - eventListCmd.Flags().String("nextCursor", "", "") - _ = eventListCmd.Flags().MarkHidden("nextCursor") - eventListCmd.Flags().String("page-token", "", "") - _ = eventListCmd.Flags().MarkHidden("page-token") - eventListCmd.Flags().String("pageToken", "", "") - _ = eventListCmd.Flags().MarkHidden("pageToken") - eventListCmd.Flags().String("next-token", "", "") - _ = eventListCmd.Flags().MarkHidden("next-token") - eventListCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)") - eventListCmd.Flags().Int("max-results", 0, "") - _ = eventListCmd.Flags().MarkHidden("max-results") - eventListCmd.Flags().Int("maxResults", 0, "") - _ = eventListCmd.Flags().MarkHidden("maxResults") - eventListCmd.Flags().Int("page-size", 0, "") - _ = eventListCmd.Flags().MarkHidden("page-size") - eventListCmd.Flags().Int("size", 0, "") - _ = eventListCmd.Flags().MarkHidden("size") eventListCmd.Flags().Int("count", 0, "") _ = eventListCmd.Flags().MarkHidden("count") // GetEvent flags diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index 67a444ad..efd71ca8 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -53,7 +53,11 @@ func RunPreParse(root *cobra.Command, engine *Engine) { } 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, } diff --git a/internal/pipeline/handlers/alias.go b/internal/pipeline/handlers/alias.go index f88cf595..548954d6 100644 --- a/internal/pipeline/handlers/alias.go +++ b/internal/pipeline/handlers/alias.go @@ -15,9 +15,9 @@ 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 @@ -95,8 +95,11 @@ func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) { return "--" + normalised + suffix, true } -// 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 +107,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/semantic_alias.go b/internal/pipeline/handlers/semantic_alias.go new file mode 100644 index 00000000..fbaf7830 --- /dev/null +++ b/internal/pipeline/handlers/semantic_alias.go @@ -0,0 +1,105 @@ +// 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 ( + "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 || len(aliases) == 0 { + return nil + } + + for i, arg := range ctx.Args { + 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 contains(blocked, morphed) || contains(ambiguous, 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 +} + +// 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 +} + +func contains(list []string, target string) bool { + for _, v := range list { + if v == target { + return true + } + } + return false +} diff --git a/internal/pipeline/handlers/semantic_alias_test.go b/internal/pipeline/handlers/semantic_alias_test.go new file mode 100644 index 00000000..bd06df84 --- /dev/null +++ b/internal/pipeline/handlers/semantic_alias_test.go @@ -0,0 +1,135 @@ +// 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 ( + "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 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) + } +} + +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", "positional", "-n"}} + _ = h.Handle(ctx) + if !reflect.DeepEqual(ctx.Args, []string{"--query", "x", "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/phase.go b/internal/pipeline/phase.go index 2f3efbaa..8138f30a 100644 --- a/internal/pipeline/phase.go +++ b/internal/pipeline/phase.go @@ -77,8 +77,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 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) + } + } +}