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:
1 parent
2733f510af
commit
c26cbbbbb8
10 files changed
+598
-99
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user