From 4db117893ec9fb5b195de305cfa6fc778bb3cbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 22 Jul 2026 21:07:04 +0800 Subject: [PATCH] fix(param): freeze reviewed normalization baseline Restore calendar helper behavior to main, finalize reviewed alias/guard decisions, cover payload and dry-run paths, and record the local migration freeze checkpoint. --- internal/app/param_alias_e2e_test.go | 382 +++++++++++++++--- internal/app/param_alias_fixture_test.go | 4 +- .../app/param_concept_calendar_pilot_test.go | 117 +++--- internal/cli/param_aliases_generated.go | 54 +-- internal/cli/param_concepts.json | 32 +- internal/helpers/calendar.go | 77 +++- internal/pipeline/cobra.go | 71 +++- internal/pipeline/cobra_test.go | 68 ++++ specs/param-concept-normalization-freeze.md | 50 +++ specs/param-concept-normalization-spec.md | 25 +- 10 files changed, 696 insertions(+), 184 deletions(-) create mode 100644 specs/param-concept-normalization-freeze.md diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index 14afc7c6..cabbb5b5 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -5,10 +5,12 @@ package app import ( "context" + "encoding/json" stderrors "errors" "io" "os" "reflect" + "sort" "strings" "testing" @@ -88,6 +90,81 @@ func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Inv 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 @@ -129,8 +206,8 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) { if err != nil { t.Fatalf("calendar alias E2E error = %v", err) } - if len(ctx.Corrections) != 5 { - t.Fatalf("calendar corrections = %#v, want 5", ctx.Corrections) + 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) @@ -181,6 +258,180 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) { } } +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) + } + + for _, alias := range []string{"group-id", "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 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{ @@ -212,59 +463,98 @@ func TestAllReviewedParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T 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) + guardCounts := map[pipeline.FlagProtection]int{} - for _, fixture := range concepts.Fixture { - var wantProtection pipeline.FlagProtection - var wantReason string - switch fixture.Expect { - case "did-you-mean:blocked": - wantProtection = pipeline.FlagProtectionBlocked - wantReason = "blocked_flag" - case "did-you-mean:ambiguous": - wantProtection = pipeline.FlagProtectionAmbiguous - wantReason = "ambiguous_flag" - default: + testedGuards := make(map[string]pipeline.FlagProtection) + for _, path := range orderedPaths { + entry, ok := cli.LookupParamAlias(path) + if !ok { continue } - guardCounts[wantProtection]++ - t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { - value := "FIXTURE_VALUE" - args := append(strings.Fields(fixture.Command), "--"+fixture.Emitted, value) - caller := ¶mAliasCaptureCaller{} - ctx, executeErr := executeParamAliasE2E(t, caller, args...) + for _, protectionCase := range []struct { + protection pipeline.FlagProtection + reason string + emitted []string + }{ + {protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag", emitted: entry.Blocked}, + {protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag", 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]++ - morphed := cmdutil.Morph(fixture.Emitted) - if ctx == nil || ctx.ProtectedFlags[morphed] != wantProtection { - t.Fatalf("guard protection = %#v, want %s for %q", ctx, wantProtection, morphed) - } - assertLeftUnchanged(t, ctx, fixture.Emitted, value) + t.Run(path+"/"+emitted, func(t *testing.T) { + value := "FIXTURE_VALUE" + args := append(strings.Fields(path), "--"+emitted, value) + caller := ¶mAliasCaptureCaller{} + ctx, executeErr := executeParamAliasE2E(t, caller, args...) - var appErr *apperrors.Error - if !stderrors.As(executeErr, &appErr) { - t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr) + 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) + + 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 != protectionCase.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), protectionCase.reason) + } + if !strings.Contains(appErr.Message, "unknown flag: --"+emitted) || !strings.Contains(appErr.Message, "See 'dws "+path+" --help' for usage.") { + t.Fatalf("final error message = %q", appErr.Message) + } + if !strings.Contains(appErr.Hint, "--"+emitted) || !strings.Contains(appErr.Hint, "--help") { + t.Fatalf("final error hint = %q", appErr.Hint) + } + wantAction := "Run 'dws " + 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) + } + }) } - if appErr.Category != apperrors.CategoryValidation || appErr.Reason != wantReason || 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), wantReason) - } - if !strings.Contains(appErr.Message, "unknown flag: --"+fixture.Emitted) || !strings.Contains(appErr.Message, "See 'dws "+fixture.Command+" --help' for usage.") { - t.Fatalf("final error message = %q", appErr.Message) - } - if !strings.Contains(appErr.Hint, "--"+fixture.Emitted) || !strings.Contains(appErr.Hint, "--help") { - t.Fatalf("final error hint = %q", appErr.Hint) - } - wantAction := "Run 'dws " + fixture.Command + " --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) - } - }) + } } - if guardCounts[pipeline.FlagProtectionBlocked] != 9 || guardCounts[pipeline.FlagProtectionAmbiguous] != 3 { - t.Fatalf("reviewed guard coverage = blocked %d ambiguous %d, want 9/3", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous]) + 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]) } } diff --git a/internal/app/param_alias_fixture_test.go b/internal/app/param_alias_fixture_test.go index 16b2e0c8..bab55600 100644 --- a/internal/app/param_alias_fixture_test.go +++ b/internal/app/param_alias_fixture_test.go @@ -96,8 +96,8 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) { // 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. These are - // the un-migrated commands pending §5 hidden-flag cleanup. + // maps to the same entity via its fallback wiring. Native + // compatibility flags intentionally remain command-owned. 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) diff --git a/internal/app/param_concept_calendar_pilot_test.go b/internal/app/param_concept_calendar_pilot_test.go index a5fabda9..11ef25a2 100644 --- a/internal/app/param_concept_calendar_pilot_test.go +++ b/internal/app/param_concept_calendar_pilot_test.go @@ -20,54 +20,47 @@ import ( "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) { +// 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 // spelling the model/user typed (dedicated flag removed) + emitted string value string - canonical string // real flag the pipeline must reduce it to + canonical string isInt bool + native 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}, + // 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 { @@ -85,39 +78,37 @@ func TestCalendarEventListParamConceptPilotBehaviorUnchanged(t *testing.T) { 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) - } + 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) } } - // 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) + got, err := target.Flags().GetInt(parsedFlag) if err != nil || got != 50 { - t.Fatalf("flag --%s = %d (err %v), want 50", tc.canonical, got, err) + t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err) } } else { - got, err := target.Flags().GetString(tc.canonical) + got, err := target.Flags().GetString(parsedFlag) if err != nil || got != tc.value { - t.Fatalf("flag --%s = %q (err %v), want %q", tc.canonical, got, err, tc.value) + t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value) } } }) diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 98d4f5c4..733bae69 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -82,29 +82,14 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "calendar event list", Aliases: map[string]string{ "begin": "start", - "calendar": "calendar-id", "date": "start", - "end-date": "end", - "end-time": "end", "from": "start", "from-date": "start", "max-result": "limit", - "max-results": "limit", - "max-time": "end", - "min-time": "start", - "next-cursor": "cursor", "next-page-token": "cursor", - "next-token": "cursor", - "page-size": "limit", - "page-token": "cursor", "per-page": "limit", "since": "start", - "size": "limit", - "start-date": "start", - "start-time": "start", "take": "limit", - "time-max": "end", - "time-min": "start", "top": "limit", }, Blocked: []string{"offset", "page", "time"}, @@ -160,12 +145,22 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"conversation-ids", "group-ids"}, }, { - CLIPath: "chat message add-emoji", - Ambiguous: []string{"chat-id", "conversation-ids", "group-id", "open-conversation-id"}, + CLIPath: "chat message add-emoji", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group-id": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids"}, }, { - CLIPath: "chat message add-text-emotion", - Ambiguous: []string{"chat-id", "conversation-ids", "group-id", "open-conversation-id"}, + CLIPath: "chat message add-text-emotion", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group-id": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids"}, }, { CLIPath: "chat message list", @@ -198,12 +193,22 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"time"}, }, { - CLIPath: "chat message remove-emoji", - Ambiguous: []string{"chat-id", "conversation-ids", "group-id", "open-conversation-id"}, + CLIPath: "chat message remove-emoji", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group-id": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids"}, }, { - CLIPath: "chat message remove-text-emotion", - Ambiguous: []string{"chat-id", "conversation-ids", "group-id", "open-conversation-id"}, + CLIPath: "chat message remove-text-emotion", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group-id": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids"}, }, { CLIPath: "chat message send", @@ -216,8 +221,7 @@ var generatedParamAliases = []ParamAliasEntry{ Aliases: map[string]string{ "user-ids": "users", }, - Blocked: []string{"staff-id", "uid", "user", "user-id", "userid"}, - Ambiguous: []string{"to-user-id"}, + Blocked: []string{"staff-id", "to-user-id", "uid", "user", "user-id", "userid"}, }, { CLIPath: "contact +dept-members", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index a58ccace..e52730ca 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -35,10 +35,10 @@ "chat group members": {"bind": {"id": "group_id"}}, "chat group members add": {"bind": {"id": "group_id"}}, "chat group members remove": {"bind": {"id": "group_id"}}, - "chat message add-emoji": {"ambiguous": ["group-id", "conversation-ids", "chat-id", "open-conversation-id"], "note": "both --conversation-id and --group are real; CO-OCCURRENCE guard -> did-you-mean"}, - "chat message add-text-emotion": {"ambiguous": ["group-id", "conversation-ids", "chat-id", "open-conversation-id"], "note": "both --conversation-id and --group are real; CO-OCCURRENCE guard -> did-you-mean"}, - "chat message remove-emoji": {"ambiguous": ["group-id", "conversation-ids", "chat-id", "open-conversation-id"], "note": "both --conversation-id and --group are real; CO-OCCURRENCE guard -> did-you-mean"}, - "chat message remove-text-emotion": {"ambiguous": ["group-id", "conversation-ids", "chat-id", "open-conversation-id"], "note": "both --conversation-id and --group are real; CO-OCCURRENCE guard -> did-you-mean"}, + "chat message add-emoji": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final add_emoji_reaction payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, + "chat message add-text-emotion": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final add_text_emotion payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, + "chat message remove-emoji": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final remove_emoji_reaction payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, + "chat message remove-text-emotion": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final remove_text_emotion payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, "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"}, @@ -47,7 +47,7 @@ "mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"}, "contact dept list-children": {"scoped_aliases": {"parent": "dept", "parent-id": "dept"}, "note": "reviewed against command payload: --dept is the parent department 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", "calendar": "calendar-id"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; values are passed unchanged before the canonical parser converts time to milliseconds"}, + "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 message list-by-sender": {"block": ["time"], "note": "--time does not state the RFC3339 start-boundary semantics required by this command"}, "contact +resolve-dept": {"scoped_aliases": {"query": "name"}, "note": "reviewed against shortcut source: --name is passed unchanged as search_dept_by_keyword.query"}, "contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"}, @@ -55,7 +55,7 @@ "chat message send": {"scoped_aliases": {"to-user": "user"}, "note": "reviewed write mapping: both names denote one direct-message recipient user id; final payload is asserted by an end-to-end test"}, "chat +group-members": {"scoped_aliases": {"group-name": "group"}, "note": "reviewed against shortcut source: --group is explicitly the group-name search keyword"}, "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": {"block": ["user-id"], "ambiguous": ["to-user-id"], "note": "--users is a recipient list, so singular --user-id is blocked; --to-user-id may mean recipient or @ target"}, + "chat message send-by-bot": {"block": ["user-id", "to-user-id"], "note": "--users is a recipient list, so singular --user-id/--to-user-id are not silently promoted to list cardinality; group mentions must use the explicit --at-user-ids"}, "attendance +check-result": {"block": ["user-id"], "note": "--users is a list; singular --user-id is not silently promoted to list cardinality"}, "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"}, @@ -79,14 +79,14 @@ {"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": "concept:time_start", "occ": 2}, - {"command": "calendar event list", "emitted": "min-time", "expect": "start", "via": "concept:time_start", "occ": 2}, - {"command": "calendar event list", "emitted": "time-min", "expect": "start", "via": "concept:time_start", "occ": 2}, - {"command": "calendar event list", "emitted": "end-time", "expect": "end", "via": "concept:time_end", "occ": 2}, - {"command": "calendar event list", "emitted": "time-max", "expect": "end", "via": "concept:time_end", "occ": 2}, - {"command": "calendar event list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, - {"command": "calendar event list", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor", "occ": 2}, - {"command": "calendar event list", "emitted": "calendar", "expect": "calendar-id", "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}, @@ -109,7 +109,7 @@ {"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:ambiguous", "via": "override:ambiguous", "occ": 1}, + {"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}, @@ -126,8 +126,6 @@ {"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(group_id)", "occ": 2}, {"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2}, - {"command": "chat message add-emoji", "emitted": "chat-id", "expect": "did-you-mean:ambiguous", "via": "guard:co_occurrence(conversation-id,group)", "occ": 2}, - {"command": "drive list", "emitted": "space", "expect": "did-you-mean:ambiguous", "via": "guard:co_occurrence(space-id,workspace)", "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}, diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index 453118c8..89184278 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -246,7 +246,7 @@ func newCalendarCommand() *cobra.Command { toolArgs := map[string]any{} var startTime, endTime int64 var now time.Time - if v, _ := cmd.Flags().GetString("start"); v != "" { + if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" { var err error startTime, err = parseISOTimeToMillis("start", v) if err != nil { @@ -258,7 +258,7 @@ func newCalendarCommand() *cobra.Command { startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli() toolArgs["startTime"] = startTime } - if v, _ := cmd.Flags().GetString("end"); v != "" { + if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" { var err error endTime, err = parseISOTimeToMillis("end", v) if err != nil { @@ -275,18 +275,22 @@ func newCalendarCommand() *cobra.Command { if err := validateTimeRange(startTime, endTime); err != nil { return err } - if v, _ := cmd.Flags().GetString("calendar-id"); v != "" { + if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" { toolArgs["calendarId"] = v } - if v, _ := cmd.Flags().GetString("cursor"); v != "" { + if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" { toolArgs["cursor"] = v } - // Alias forms (size/page-size/max-results/maxResults) are normalized to - // --limit in the PreParse pipeline, so only --limit and --count reach the - // handler. --count stays a deliberately separate flag (pagination_size - // excludes it) that still maps onto the limit argument. if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 { toolArgs["limit"] = lim + } else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 { + toolArgs["limit"] = lim + } else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 { + toolArgs["limit"] = lim + } else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 { + toolArgs["limit"] = lim + } else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 { + toolArgs["limit"] = lim } else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 { toolArgs["limit"] = lim } @@ -1054,15 +1058,60 @@ 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)") - // 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("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") 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 2cdbd70c..d8703587 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -16,6 +16,7 @@ package pipeline import ( "log/slog" "os" + "strings" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -48,8 +49,13 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co 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 nil, nil } @@ -90,6 +96,67 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co 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 + } + + 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 := persistentFlagToken(flags, argument) + if !matched { + out = append(out, argument) + continue + } + if !inlineValue && flag.NoOptDefVal == "" && index+1 < len(rawArgs) { + index++ + } + } + return out +} + +func persistentFlagToken(flags *pflag.FlagSet, argument string) (*pflag.Flag, bool, bool) { + if flags == nil || argument == "" || argument == "-" || argument == "--" { + return nil, false, false + } + if strings.HasPrefix(argument, "--") { + body := strings.TrimPrefix(argument, "--") + name, _, inlineValue := strings.Cut(body, "=") + flag := flags.Lookup(name) + return flag, inlineValue, flag != nil + } + if !strings.HasPrefix(argument, "-") { + return nil, false, false + } + + body := strings.TrimPrefix(argument, "-") + if body == "" { + return nil, false, false + } + shorthands := []rune(body) + for index, shorthand := range shorthands { + flag := flags.ShorthandLookup(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 flags.ShorthandLookup(string(shorthands[0])), true, true +} + // FlagInfoFromCommand extracts FlagInfo entries from a Cobra // command's registered flags (both local and inherited). // diff --git a/internal/pipeline/cobra_test.go b/internal/pipeline/cobra_test.go index 88996039..def1ed19 100644 --- a/internal/pipeline/cobra_test.go +++ b/internal/pipeline/cobra_test.go @@ -114,3 +114,71 @@ func TestCrossPlatformCoverageRunPreParseAppliesCorrectionsOnlyOnSuccess(t *test t.Fatalf("failed preparse execute = %q, %v", *value, err) } } + +func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "clustered attached shorthand", args: []string{"-vfjson", "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().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 err := root.Execute(); err != nil { + t.Fatalf("corrected command failed: %v", err) + } + if value != "2026-03-10" { + t.Fatalf("canonical --start value = %q", value) + } + }) + } +} diff --git a/specs/param-concept-normalization-freeze.md b/specs/param-concept-normalization-freeze.md new file mode 100644 index 00000000..08ca56d8 --- /dev/null +++ b/specs/param-concept-normalization-freeze.md @@ -0,0 +1,50 @@ +# 参数幻觉治理冻结清单 + +- 冻结名称:`param-hallucination-freeze-20260722` +- 基线:`origin/main` @ `076d77d` +- 工作分支:`fix/param-hallucination` +- 发布状态:仅本地冻结,不推送、不合并 +- 规范入口:`specs/param-concept-normalization-spec.md` +- 既有评审:GitHub PR #744 已关闭,远端源分支保留 + +## 冻结目的 + +本检查点固定开源仓库中参数概念归一化的最终候选版本,作为后续迁移到内部 `dws-wukong` 仓库时的唯一代码基线。迁移时以本检查点的 tracked diff 为准,不从历史 CR、工作区草稿或评测报告反向拼装代码。 + +## 冻结范围 + +1. reviewed 参数概念源、closed schema、加载与校验。 +2. 逐命令 alias / blocked / ambiguous 生成器及确定性生成表。 +3. PreParse 中的语义归一化、保护态传播和冲突拦截。 +4. 与原有格式归一、粘连拆分、拼写纠错链路的集成。 +5. fixture、最终 payload、最终错误、dry-run 等测试与 policy 门禁。 +6. 实现规范及本冻结清单。 + +## 明确不纳入 + +- `internal/helpers/calendar.go` 的试点改造:最终版本保持与 `origin/main` 一致。 +- 未跟踪的参数字典草稿和工程汇报:它们是分析材料,不是运行时或 reviewed source。 +- `DWS Skill 优化产出汇报-0721.md`:与本次代码冻结无关。 +- 任何远端推送、PR/CR 或 `main` 合并。 + +## 迁移使用方式 + +1. 以本地 annotated tag `param-hallucination-freeze-20260722` 定位精确提交。 +2. 使用 `origin/main...param-hallucination-freeze-20260722` 获取完整 tracked diff。 +3. 在内部仓库重新绑定真实 Cobra 命令树并重新生成 alias 表,不直接假设生成物可跨仓库复制。 +4. 重新执行本文件记录的验证项;内部仓库存在差异时,以 reviewed 源和测试语义为准适配。 + +## 冻结验证 + +冻结提交创建前的验证结果: + +| 验证 | 结果 | +|---|---| +| `go generate ./internal/cli` | 通过;参数 alias、Agent metadata、Schema Catalog 均成功生成 | +| `./scripts/policy/check-generated-drift.sh` | 通过 | +| `./scripts/policy/check-param-concepts.sh` | 通过 | +| `./scripts/policy/check-param-alias-cooccurrence.sh` | 通过 | +| 参数归一化 focused tests | 通过;覆盖 fixture、读写 payload、保护错误、冲突、Calendar 原生兼容和选择性 dry-run | +| `go build -o /private/tmp/dws-param-freeze-build ./cmd` | 通过;显式输出路径用于避免二进制名 `cmd` 与源码目录重名 | +| `./scripts/policy/check-schema-catalog.sh` | 通过;22 products、572 tools | +| `DWS_PACKAGE_VERSION=0.0.0-test go test ./...` | 全部通过 | diff --git a/specs/param-concept-normalization-spec.md b/specs/param-concept-normalization-spec.md index 12bfc99b..0d471b14 100644 --- a/specs/param-concept-normalization-spec.md +++ b/specs/param-concept-normalization-spec.md @@ -1,6 +1,6 @@ # Spec:参数幻觉治理 —— 受审命令范围内的概念归一化 -- 状态:Implemented,待最终回归 +- 状态:Frozen,最终回归通过 - 分支:`fix/param-hallucination` - 评测来源:`param_alias_map_merged_20260720`(440 条 badcase)及参数幻觉分析报告 @@ -157,21 +157,16 @@ SemanticAlias 命中保护项时: 同一个拼写重复出现仍保持 Cobra 原有语义,本次不额外改变。 -## 6. Calendar 试点结论 +## 6. Calendar 边界结论 -保留 `calendar event list` 试点,不回退,也不扩面。 +`internal/helpers/calendar.go` 保持 `main` 原有实现,不迁移或删除它手写的 hidden compatibility flags 与 `flagOrFallback` 链。现存真实 flag 继续由 Cobra/RunE 原生处理,中央体系不得覆盖或重新解释。 -该试点已经移除一组手写 hidden spelling flag,改为在 PreParse 统一归一到: +中央归一化只补充该命令没有注册的 reviewed spelling,例如: -- `start` -- `end` -- `calendar-id` -- `cursor` -- `limit` +- `date` → `start` +- `from` / `since` → `start` -保留 `count` 原生兼容 flag,因为它不被概念层认定为通用 `limit` 同义词。端到端测试验证 Calendar 幻觉参数最终只形成正确的 `startTime`、`endTime`、`calendarId`、`cursor`、`limit` payload。 - -除这一已提交试点外,本次不继续修改 `internal/helpers/calendar.go`,也不要求其他 skill/helper 迁移 hidden flag 与 fallback。 +Calendar 原生兼容参数和中央 alias 的端到端测试都必须验证最终仍只形成正确的 `startTime`、`endTime`、`calendarId`、`cursor`、`limit` payload。本次不要求任何 skill/helper 迁移 hidden flag 与 fallback。 ## 7. 测试与门禁 @@ -193,7 +188,7 @@ fixture 只有“被测参数名”而没有每个业务命令的完整必填业 允许且有收益的影响: - 受审命令开始接受明确安全的语义别名。 -- Calendar 试点由 hidden flag/fallback 迁移到 PreParse canonical 读取。 +- Calendar 保留原生 hidden flag/fallback,同时接受不覆盖真实 flag 的 reviewed 中央 alias。 - alias/canonical 混传从顺序覆盖变为确定性报错。 - 某些曾被普通 fuzzy handler 猜中的危险名字现在明确失败。 @@ -207,12 +202,12 @@ fixture 只有“被测参数名”而没有每个业务命令的完整必填业 ## 9. 回退 -本次修改只提交在 `fix/param-hallucination` 本地分支,不推送、不合并、不修改 main。 +最终冻结检查点只提交在本地 `fix/param-hallucination` 分支,不推送、不合并、不修改 main。此前已经存在的同名远端分支予以保留;GitHub PR #744 已关闭,最终冻结提交与标签均不发布到远端。 - 本地提交后发现问题:对本次 commit 执行 `git revert `,保留完整可审计历史。 - 如果日后已经合入 main:仍可在 main 上 `git revert `,再走正常审核与发布流程。 - reviewed 源与 `param_aliases_generated.go` 必须一起回退,或回退源后重新生成并通过 drift gate。 -- Calendar 试点是更早的独立提交;若只回退本次安全加固,不会自动撤回 Calendar 试点。若需撤回试点,应单独 revert 对应提交。 +- `calendar.go` 不属于本次最终交付改动;回退中央体系时无需再单独恢复 Calendar helper。 ## 10. 验收标准