From eec64bdf35ba20e033773be91d6864c1f335e3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Fri, 10 Jul 2026 17:13:29 +0800 Subject: [PATCH] fix: align schema aliases and one-of coverage --- internal/app/schema_visible_commands_test.go | 41 ++++ internal/helpers/chat_file.go | 11 +- internal/helpers/chat_test.go | 37 ++++ internal/helpers/doc.go | 4 + internal/helpers/sheet.go | 13 ++ internal/pat/chmod.go | 4 + scripts/dev/schema_command_smoke.py | 199 +++++++++++++++---- 7 files changed, 269 insertions(+), 40 deletions(-) diff --git a/internal/app/schema_visible_commands_test.go b/internal/app/schema_visible_commands_test.go index 290204ed..491bec9c 100644 --- a/internal/app/schema_visible_commands_test.go +++ b/internal/app/schema_visible_commands_test.go @@ -80,6 +80,47 @@ func TestSchemaCoversVisibleProductLeafCommands(t *testing.T) { } } +func TestSchemaHidesChatFileUploadCompatibilityAliases(t *testing.T) { + root := NewRootCommand() + var out, errOut bytes.Buffer + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs([]string{"schema", "chat.upload_conversation_file", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatalf("schema Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + + var payload struct { + Parameters map[string]json.RawMessage `json:"parameters"` + Constraints struct { + RequireOneOf [][]string `json:"require_one_of"` + } `json:"constraints"` + } + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String()) + } + for _, name := range []string{"conversation-id", "id", "file-path"} { + if _, ok := payload.Parameters[name]; ok { + t.Fatalf("compatibility alias %q leaked into schema parameters", name) + } + } + for _, name := range []string{"group", "user", "open-dingtalk-id", "file", "url"} { + if _, ok := payload.Parameters[name]; !ok { + t.Fatalf("canonical parameter %q missing from schema", name) + } + } + wantGroups := map[string]bool{ + "group,user,open-dingtalk-id": true, + "file,url": true, + } + for _, group := range payload.Constraints.RequireOneOf { + delete(wantGroups, strings.Join(group, ",")) + } + if len(wantGroups) != 0 { + t.Fatalf("require_one_of missing groups: %#v", wantGroups) + } +} + func TestSchemaProductIDsMatchRootHelpServices(t *testing.T) { root := NewRootCommand() helpProducts := map[string]bool{} diff --git a/internal/helpers/chat_file.go b/internal/helpers/chat_file.go index 8a4bb4c7..b46d4a6e 100644 --- a/internal/helpers/chat_file.go +++ b/internal/helpers/chat_file.go @@ -164,12 +164,12 @@ func newChatFileUploadCommand(runner executor.Runner) *cobra.Command { } preferLegacyLeaf(cmd) cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)") - cmd.Flags().String("conversation-id", "", "--group 的别名") - cmd.Flags().String("id", "", "--group 的别名") + addChatFileHiddenStringFlag(cmd, "conversation-id", "--group 的别名") + addChatFileHiddenStringFlag(cmd, "id", "--group 的别名") cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)") cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)") cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)") - cmd.Flags().String("file-path", "", "--file 的别名") + addChatFileHiddenStringFlag(cmd, "file-path", "--file 的别名") cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)") cmd.Flags().String("file-name", "", "文件名(可选)") cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)") @@ -189,6 +189,11 @@ func newChatFileUploadCommand(runner executor.Runner) *cobra.Command { return cmd } +func addChatFileHiddenStringFlag(cmd *cobra.Command, name, usage string) { + cmd.Flags().String(name, "", usage) + _ = cmd.Flags().MarkHidden(name) +} + func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) { group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id")) user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user")) diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go index 3ed0d190..fb518e67 100644 --- a/internal/helpers/chat_test.go +++ b/internal/helpers/chat_test.go @@ -3,6 +3,8 @@ package helpers import ( "bytes" "context" + "os" + "path/filepath" "strings" "testing" @@ -15,6 +17,41 @@ type captureRunner struct { last executor.Invocation } +func TestChatFileUploadCompatibilityAliasesRemainCallable(t *testing.T) { + for _, name := range []string{"conversation-id", "id", "file-path"} { + cmd := newChatFileUploadCommand(&captureRunner{}) + flag := cmd.Flags().Lookup(name) + if flag == nil || !flag.Hidden { + t.Fatalf("compatibility flag --%s must remain registered and hidden", name) + } + } + + runner := &captureRunner{} + cmd := newChatFileUploadCommand(runner) + cmd.SetArgs([]string{ + "--conversation-id", "cid-alias", + "--url", "https://example.com/report.pdf", + "--file-name", "report.pdf", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("conversation alias Execute() error = %v", err) + } + if got := runner.last.Params["openConversationId"]; got != "cid-alias" { + t.Fatalf("openConversationId = %#v, want cid-alias", got) + } + + fixture := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(fixture, []byte("schema alias test\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + dryRun := newChatFileUploadCommand(&captureRunner{}) + dryRun.Flags().Bool("dry-run", false, "dry run") + dryRun.SetArgs([]string{"--id", "cid-id-alias", "--file-path", fixture, "--dry-run"}) + if err := dryRun.Execute(); err != nil { + t.Fatalf("id/file-path alias Execute() error = %v", err) + } +} + func (r *captureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { r.last = invocation return executor.Result{Invocation: invocation}, nil diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index a74a7d26..67b139de 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -1066,6 +1066,8 @@ func newDocBlockInsertCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml")) cmd.Flags().String("parent-block", "", i18n.T("父容器块 ID(JSONML 场景透传)")) addDocJSONMLControlFlags(cmd) + annotateFlagEnum(cmd, "type", "paragraph", "heading", "blockquote", "callout", "code", "orderedList", "unorderedList", "columns", "divider") + annotateFlagExample(cmd, "type", "divider") annotateFlagConstraints(cmd, nil, [][]string{{"text", "heading", "element", "type"}}, nil) return cmd } @@ -1130,6 +1132,8 @@ func newDocBlockUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().Int("level", 1, i18n.T("标题级别")) cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml")) addDocJSONMLControlFlags(cmd) + annotateFlagEnum(cmd, "type", "paragraph", "heading", "blockquote", "callout", "code", "orderedList", "unorderedList", "columns", "divider") + annotateFlagExample(cmd, "type", "divider") annotateFlagConstraints(cmd, nil, [][]string{{"text", "heading", "element", "type"}}, nil) return cmd } diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index aadf9bb1..1548450f 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -1111,6 +1111,7 @@ func newSheetCondFormatCreateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象") cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象") annotateFlagExample(cmd, "ranges", `["A1:B2"]`) + annotateFlagExample(cmd, "condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`) return cmd } @@ -1132,6 +1133,7 @@ func newSheetCondFormatUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象") cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象") annotateFlagExample(cmd, "ranges", `["A1:B2"]`) + annotateFlagExample(cmd, "condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`) annotateFlagConstraints(cmd, nil, [][]string{{ "ranges", "condition", "cell-style", "data-bar-style", }}, nil) @@ -2102,6 +2104,17 @@ func bindSheetStyleFlags(cmd *cobra.Command) { cmd.Flags().String("font-weights-json", "", "字体粗细二维 JSON 数组") cmd.Flags().String("word-wrap", "", "换行方式") cmd.Flags().String("number-format", "", "数字格式 code") + + annotateFlagEnum(cmd, "h-align", "left", "center", "right", "general") + annotateFlagEnum(cmd, "v-align", "top", "middle", "bottom") + annotateFlagEnum(cmd, "font-weight", "bold", "normal") + annotateFlagEnum(cmd, "word-wrap", "overflow", "clip", "autoWrap") + annotateFlagExample(cmd, "bg-colors-json", `[["#FFFFFF","#FFFFFF"],["#FFFFFF","#FFFFFF"]]`) + annotateFlagExample(cmd, "font-sizes-json", `[[12,12],[12,12]]`) + annotateFlagExample(cmd, "h-aligns-json", `[["left","left"],["left","left"]]`) + annotateFlagExample(cmd, "v-aligns-json", `[["top","top"],["top","top"]]`) + annotateFlagExample(cmd, "font-colors-json", `[["#000000","#000000"],["#000000","#000000"]]`) + annotateFlagExample(cmd, "font-weights-json", `[["normal","normal"],["normal","normal"]]`) } func sheetReadStyleSpec(cmd *cobra.Command) *sheetStyleSpec { diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 2a42b541..0b874e1d 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -414,6 +414,10 @@ agentCode 配置: chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes") cli.AnnotateRuntimeFlagEnum(chmodCmd, "grant-type", "once", "session", "permanent") + cli.AnnotateRuntimeFlagExample(chmodCmd, "product", "calendar") + cli.AnnotateRuntimeFlagExample(chmodCmd, "products", "calendar,aitable") + cli.AnnotateRuntimeFlagExample(chmodCmd, "domain", "calendar") + cli.AnnotateRuntimeFlagExample(chmodCmd, "domains", "calendar,chat") cli.AnnotateRuntimePositionals(chmodCmd, cli.RuntimeSchemaPositional{ Name: "scope", Type: "array", diff --git a/scripts/dev/schema_command_smoke.py b/scripts/dev/schema_command_smoke.py index 7f8ae2a6..5364d13c 100644 --- a/scripts/dev/schema_command_smoke.py +++ b/scripts/dev/schema_command_smoke.py @@ -3,13 +3,15 @@ The script treats `dws schema --all --format json` as the command inventory, expands each leaf schema, synthesizes flag values from parameter metadata, and executes -the runnable CLI path with `--dry-run --yes --format json`. +the runnable CLI path with `--dry-run --yes --format json`. Every available +combination of `require_one_of` branches is exercised independently. """ from __future__ import annotations import argparse import concurrent.futures +import itertools import json import os import re @@ -26,6 +28,7 @@ from typing import Any @dataclass class SmokeResult: canonical_path: str + case_name: str cli_path: str command: list[str] status: str @@ -74,13 +77,13 @@ def kebab_to_words(value: str) -> str: def scalar_value(flag: str, param: dict[str, Any], canonical_path: str) -> str: + if param.get("example") not in (None, ""): + return str(param["example"]) + enum = param.get("enum") or [] if enum: return str(enum[0]) - if param.get("example") not in (None, ""): - return str(param["example"]) - value_format = str(param.get("format", "")).lower() if value_format in {"numeric-id", "positive-integer"}: return "1" @@ -182,6 +185,9 @@ def value_for(flag: str, param: dict[str, Any], canonical_path: str) -> str | No if canonical_path == "sheet.range_batch_set_style" and flag == "batch": return str(Path(tempfile.gettempdir()) / "dws-schema-smoke-style.json") + if param.get("example") not in (None, ""): + return str(param["example"]) + typ = str(param.get("type", "string")).lower() prop = str(param.get("property", "")) haystack = " ".join([kebab_to_words(flag), kebab_to_words(prop)]) @@ -223,7 +229,43 @@ def constraint_groups(leaf: dict[str, Any], name: str) -> list[list[str]]: return [[str(item) for item in group] for group in groups if group] -def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[str], set[str]]: +def available_one_of_groups(leaf: dict[str, Any]) -> list[list[str]]: + params = leaf.get("parameters") or {} + positional_names = { + str(item.get("name")) + for item in (leaf.get("positionals") or []) + if item.get("name") + } + available = set(params) | positional_names + return [ + [name for name in group if name in available] + for group in constraint_groups(leaf, "require_one_of") + if any(name in available for name in group) + ] + + +def one_of_cases(leaf: dict[str, Any]) -> list[tuple[str, ...]]: + groups = available_one_of_groups(leaf) + if not groups: + return [()] + cases: list[tuple[str, ...]] = [] + seen: set[tuple[str, ...]] = set() + mutually_exclusive = constraint_groups(leaf, "mutually_exclusive") + for choices in itertools.product(*groups): + selected = set(choices) + if any(sum(name in selected for name in group) > 1 for group in mutually_exclusive): + continue + if choices not in seen: + seen.add(choices) + cases.append(choices) + return cases or [()] + + +def selected_inputs( + leaf: dict[str, Any], + include_optional: bool, + one_of_choices: tuple[str, ...] = (), +) -> tuple[set[str], set[str]]: params = leaf.get("parameters") or {} positionals = { str(item.get("name")): item @@ -251,12 +293,14 @@ def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[s return True return False - for group in constraint_groups(leaf, "require_one_of"): - if any(is_selected(name) for name in group): - continue + one_of_groups = available_one_of_groups(leaf) + choices = one_of_choices or tuple(group[0] for group in one_of_groups) + preferred = set(choices) + for group, choice in zip(one_of_groups, choices): for name in group: - if select(name): - break + selected_params.discard(name) + selected_positionals.discard(name) + select(choice) changed = True while changed: @@ -270,20 +314,30 @@ def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[s for group in constraint_groups(leaf, "mutually_exclusive"): selected = [name for name in group if is_selected(name)] - for name in selected[1:]: + keep = next((name for name in selected if name in preferred), selected[0] if selected else "") + for name in selected: + if name == keep: + continue selected_params.discard(name) selected_positionals.discard(name) return selected_params, selected_positionals -def build_command(binary: str, leaf: dict[str, Any], include_optional: bool) -> list[str]: +def build_command( + binary: str, + leaf: dict[str, Any], + include_optional: bool, + one_of_choices: tuple[str, ...] = (), +) -> list[str]: cli_path = str(leaf["cli_path"]) canonical_path = str(leaf.get("canonical_path", "")) extra_flags = smoke_extra_flags(canonical_path) argv = [binary, *shlex.split(cli_path)] params = leaf.get("parameters") or {} - selected_params, selected_positionals = selected_inputs(leaf, include_optional) + selected_params, selected_positionals = selected_inputs( + leaf, include_optional, one_of_choices + ) positionals = sorted( (leaf.get("positionals") or []), @@ -326,24 +380,18 @@ def help_flags(binary: str, cwd: Path, cli_path: str, timeout: int) -> set[str]: return set(re.findall(r"--([a-zA-Z0-9][a-zA-Z0-9_.-]*)", proc.stdout)) -def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: bool) -> SmokeResult: +def run_smoke_case( + binary: str, + cwd: Path, + path: str, + leaf: dict[str, Any], + timeout: int, + include_optional: bool, + choices: tuple[str, ...], +) -> SmokeResult: + case_name = "default" if not choices else "one_of:" + "+".join(choices) try: - leaf = run_json([binary, "schema", path, "--format", "json"], cwd, timeout) - cli_path = str(leaf.get("cli_path", "")) - declared = set((leaf.get("parameters") or {}).keys()) - missing = sorted(declared - help_flags(binary, cwd, cli_path, timeout)) - if missing: - return SmokeResult( - canonical_path=path, - cli_path=cli_path, - command=[binary, *shlex.split(cli_path), "--help"], - status="schema_flag_mismatch", - exit_code=3, - stdout="", - stderr="", - error="schema parameters missing from command help: " + ", ".join(missing), - ) - command = build_command(binary, leaf, include_optional) + command = build_command(binary, leaf, include_optional, choices) proc = subprocess.run( command, cwd=cwd, @@ -355,6 +403,7 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b status = "pass" if proc.returncode == 0 else "fail" return SmokeResult( canonical_path=path, + case_name=case_name, cli_path=str(leaf.get("cli_path", "")), command=command, status=status, @@ -365,6 +414,7 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b except subprocess.TimeoutExpired as exc: return SmokeResult( canonical_path=path, + case_name=case_name, cli_path="", command=exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)], status="timeout", @@ -376,6 +426,7 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b except Exception as exc: # noqa: BLE001 - record all smoke failures. return SmokeResult( canonical_path=path, + case_name=case_name, cli_path="", command=[], status="error", @@ -386,9 +437,79 @@ def run_one(binary: str, cwd: Path, path: str, timeout: int, include_optional: b ) +def run_one( + binary: str, + cwd: Path, + path: str, + timeout: int, + include_optional: bool, +) -> list[SmokeResult]: + try: + leaf = run_json([binary, "schema", path, "--format", "json"], cwd, timeout) + cli_path = str(leaf.get("cli_path", "")) + declared = set((leaf.get("parameters") or {}).keys()) + missing = sorted(declared - help_flags(binary, cwd, cli_path, timeout)) + if missing: + return [ + SmokeResult( + canonical_path=path, + case_name="schema", + cli_path=cli_path, + command=[binary, *shlex.split(cli_path), "--help"], + status="schema_flag_mismatch", + exit_code=3, + stdout="", + stderr="", + error="schema parameters missing from command help: " + + ", ".join(missing), + ) + ] + return [ + run_smoke_case( + binary, + cwd, + path, + leaf, + timeout, + include_optional, + choices, + ) + for choices in one_of_cases(leaf) + ] + except subprocess.TimeoutExpired as exc: + return [ + SmokeResult( + canonical_path=path, + case_name="schema", + cli_path="", + command=exc.cmd if isinstance(exc.cmd, list) else [str(exc.cmd)], + status="timeout", + exit_code=124, + stdout=exc.stdout or "", + stderr=exc.stderr or "", + error=str(exc), + ) + ] + except Exception as exc: # noqa: BLE001 - record all smoke failures. + return [ + SmokeResult( + canonical_path=path, + case_name="schema", + cli_path="", + command=[], + status="error", + exit_code=1, + stdout="", + stderr="", + error=str(exc), + ) + ] + + def result_to_dict(result: SmokeResult) -> dict[str, Any]: return { "canonical_path": result.canonical_path, + "case": result.case_name, "cli_path": result.cli_path, "command": result.command, "status": result.status, @@ -399,7 +520,7 @@ def result_to_dict(result: SmokeResult) -> dict[str, Any]: } -def write_markdown(results: list[SmokeResult], output: Path) -> None: +def write_markdown(results: list[SmokeResult], output: Path, tool_count: int) -> None: counts: dict[str, int] = {} for result in results: counts[result.status] = counts.get(result.status, 0) + 1 @@ -408,7 +529,8 @@ def write_markdown(results: list[SmokeResult], output: Path) -> None: "# Schema Command Smoke Results", "", f"- Generated: {datetime.now(timezone.utc).isoformat()}", - f"- Total: {len(results)}", + f"- Tools: {tool_count}", + f"- Cases: {len(results)}", ] for status in sorted(counts): lines.append(f"- {status}: {counts[status]}") @@ -418,8 +540,8 @@ def write_markdown(results: list[SmokeResult], output: Path) -> None: if not failures: lines.append("No failures.") else: - lines.append("| canonical_path | cli_path | status | exit | issue |") - lines.append("| --- | --- | --- | --- | --- |") + lines.append("| canonical_path | case | cli_path | status | exit | issue |") + lines.append("| --- | --- | --- | --- | --- | --- |") for result in failures: issue = result.error or result.stderr.strip() or result.stdout.strip() issue = issue.replace("\n", "
") @@ -430,6 +552,7 @@ def write_markdown(results: list[SmokeResult], output: Path) -> None: + " | ".join( [ f"`{result.canonical_path}`", + f"`{result.case_name}`", f"`{result.cli_path}`", result.status, str(result.exit_code), @@ -469,14 +592,15 @@ def main() -> int: paths = [tool["canonical_path"] for product in listing["products"] for tool in product["tools"]] with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as executor: - results = list( + result_groups = list( executor.map( lambda p: run_one(args.binary, cwd, p, args.timeout, args.include_optional), paths, ) ) + results = [result for group in result_groups for result in group] - results.sort(key=lambda item: item.canonical_path) + results.sort(key=lambda item: (item.canonical_path, item.case_name)) jsonl_path = Path(args.output_jsonl) jsonl_path.parent.mkdir(parents=True, exist_ok=True) @@ -486,9 +610,10 @@ def main() -> int: md_path = Path(args.output_md) md_path.parent.mkdir(parents=True, exist_ok=True) - write_markdown(results, md_path) + write_markdown(results, md_path, len(paths)) failures = [result for result in results if result.status != "pass"] + print(f"tools={len(paths)}") print(f"total={len(results)}") print(f"failures={len(failures)}") print(f"jsonl={jsonl_path}")