feat(param): wire semantic alias table into PreParse; pilot calendar event list

Unify runtime morphology on pkg/cmdutil.Morph (same function the generator uses),
add a SemanticAliasHandler that looks up the embedded generated table after
morphological normalization and rewrites synonyms to the command's canonical flag
(leaving blocked/ambiguous synonyms untouched for the did-you-mean path), and
thread the command CLIPath through the pipeline Context. Pilot the mechanism on
'calendar event list' by removing its hand-written hidden spelling variants; a
behaviour-preservation test locks the outcome.
This commit is contained in:
克谨 committed 2026-07-29 10:01:53 +08:00
1 parent 2733f510af
commit c26cbbbbb8
10 files changed
+598 -99

No files matched your search

+7 -42
View File
@@ -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)
}
@@ -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
}
@@ -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)
}
}
}