diff --git a/.gitignore b/.gitignore index 8c98ebf1..6e5a1506 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,8 @@ dws.zip # 功能测试运行产物 results.jsonl test/dev_functional/results.jsonl +/auto-test/ +/eval-runs/ +/.qoder/ .vercel .env* diff --git a/auto-test/cli_to_mcp/README.md b/auto-test/cli_to_mcp/README.md deleted file mode 100644 index b2bdd695..00000000 --- a/auto-test/cli_to_mcp/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# cli_to_mcp smoke tests - -This directory contains lightweight command-to-tool contract tests for hardcoded -DWS commands synced from `dws-wukong`. - -The tests do not call live DingTalk APIs. They exercise command help, validation, -and `--dry-run` output so command paths and MCP argument mappings stay stable. - -Run with an already built binary: - -```bash -DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases -``` - -If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo -root. diff --git a/auto-test/cli_to_mcp/testcases/agoal/test_01_agoal_commands.py b/auto-test/cli_to_mcp/testcases/agoal/test_01_agoal_commands.py deleted file mode 100644 index bc40a452..00000000 --- a/auto-test/cli_to_mcp/testcases/agoal/test_01_agoal_commands.py +++ /dev/null @@ -1,193 +0,0 @@ -from test_utils import combined_output, dry_run_args - - -def assert_ok(result): - output = combined_output(result) - assert result.returncode == 0, output - return output - - -def test_agoal_strategy_and_contract_cli_to_mcp(dws): - output = assert_ok( - dws.run_raw( - "agoal", - "strategy", - "list", - "--scope-type", - "PERSONAL", - "--scope-id", - "user123", - "--request-id", - "req-1", - "--dry-run", - ) - ) - assert "list_strategy_decodings" in output - assert dry_run_args(output) == { - "scopeType": "PERSONAL", - "openId": "user123", - "requestId": "req-1", - } - - output = assert_ok( - dws.run_raw( - "agoal", - "strategy", - "update", - "--profile-id", - "profile123", - "--content", - '[{"id":"e1","title":{"title":"new"}}]', - "--dry-run", - ) - ) - assert "update_strategy_decoding" in output - assert dry_run_args(output) == { - "profileId": "profile123", - "content": [{"id": "e1", "title": {"title": "new"}}], - } - - output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run")) - assert "list_op_contract_fields" in output - assert dry_run_args(output) == {} - - output = assert_ok( - dws.run_raw( - "agoal", - "contract", - "update", - "--contract-id", - "contract123", - "--dimensions", - '[{"id":"dim1","title":"metric"}]', - "--audit-config", - '{"needAudit":true}', - "--objective-template", - '{"id":"tpl1"}', - "--dry-run", - ) - ) - assert "update_op_contract" in output - assert dry_run_args(output) == { - "contractId": "contract123", - "dimensions": [{"id": "dim1", "title": "metric"}], - "auditConfig": '{"needAudit":true}', - "objectiveTemplate": '{"id":"tpl1"}', - } - - -def test_agoal_scorecard_user_report_template_cli_to_mcp(dws): - output = assert_ok( - dws.run_raw( - "agoal", - "scorecard", - "detail", - "--selected-time", - "2026-01-01T00:00:00+08:00", - "--dept-id", - "dept123", - "--dry-run", - ) - ) - assert "get_score_card_detail" in output - args = dry_run_args(output) - assert args["deptId"] == "dept123" - assert args["selectedTime"] == 1767196800000 - - output = assert_ok( - dws.run_raw( - "agoal", - "scorecard", - "update", - "--dept-id", - "dept123", - "--selected-time", - "2026-01-01", - "--id", - "sc123", - "--tracking-period-type", - "MONTHLY", - "--content", - '[{"id":"dim1","items":[]}]', - "--dry-run", - ) - ) - assert "update_score_card" in output - args = dry_run_args(output) - assert args["selectedTime"] == 1767196800000 - assert args["content"] == [{"id": "dim1", "items": []}] - - output = assert_ok( - dws.run_raw( - "agoal", - "user", - "objectives", - "--user-id", - "user123", - "--rule-id", - "rule123", - "--period-ids", - "p1,p2", - "--dry-run", - ) - ) - assert "list_user_objectives" in output - assert dry_run_args(output) == { - "dingUserId": "user123", - "objectiveRuleId": "rule123", - "periodIds": ["p1", "p2"], - } - - output = assert_ok( - dws.run_raw( - "agoal", - "report", - "submit-detail", - "--template-id", - "tpl123", - "--submit-state", - "LATE", - "--query-date", - "2026-06-18T00:00:00+08:00", - "--page", - "1", - "--page-size", - "20", - "--keyword", - "alice", - "--dry-run", - ) - ) - assert "get_submit_detail" in output - assert dry_run_args(output) == { - "templateId": "tpl123", - "submitState": "LATE", - "queryDate": "2026-06-18", - "page": 1, - "pageSize": 20, - "keyword": "alice", - } - - output = assert_ok( - dws.run_raw( - "agoal", - "obj-template", - "create-or-update", - "--title", - "tpl", - "--dimensions", - '[{"title":"dim"}]', - "--objective-weight", - "--dimension-weight", - "--compute-by-weight", - "--dry-run", - ) - ) - assert "create_or_update_obj_template" in output - assert dry_run_args(output) == { - "title": "tpl", - "dimensions": '[{"title":"dim"}]', - "objectiveWeight": True, - "dimensionWeight": True, - "computeByWeight": True, - } diff --git a/auto-test/cli_to_mcp/testcases/chat/test_29_group_notice_and_new_commands.py b/auto-test/cli_to_mcp/testcases/chat/test_29_group_notice_and_new_commands.py deleted file mode 100644 index 2fda8c65..00000000 --- a/auto-test/cli_to_mcp/testcases/chat/test_29_group_notice_and_new_commands.py +++ /dev/null @@ -1,163 +0,0 @@ -from test_utils import combined_output, dry_run_args - - -def assert_ok(result): - output = combined_output(result) - assert result.returncode == 0, output - return output - - -def test_group_notice_cli_to_mcp(dws): - output = assert_ok( - dws.run_raw( - "chat", - "group", - "notice", - "create", - "--group", - "cid123", - "--content", - "maintenance tonight", - "--sticky", - "--send-ding", - "--dry-run", - ) - ) - assert "create_group_notice" in output - assert dry_run_args(output) == { - "openConversationId": "cid123", - "content": "maintenance tonight", - "sticky": True, - "sendDing": True, - } - - output = assert_ok( - dws.run_raw( - "chat", - "group", - "notice", - "edit", - "--group", - "cid123", - "--notice-id", - "notice123", - "--content", - "updated", - "--dry-run", - ) - ) - assert "edit_group_notice" in output - assert dry_run_args(output) == { - "openConversationId": "cid123", - "dataId": "notice123", - "content": "updated", - } - - output = assert_ok( - dws.run_raw( - "chat", - "group", - "notice", - "get", - "--group", - "cid123", - "--notice-id", - "notice123", - "--dry-run", - ) - ) - assert "get_group_notice" in output - assert dry_run_args(output) == { - "openConversationId": "cid123", - "dataId": "notice123", - } - - output = assert_ok( - dws.run_raw( - "chat", - "group", - "notice", - "list", - "--group", - "cid123", - "--limit", - "20", - "--cursor", - "next", - "--scheduled", - "--dry-run", - ) - ) - assert "list_group_notices" in output - assert dry_run_args(output) == { - "openConversationId": "cid123", - "limit": 20, - "cursor": "next", - "scheduled": True, - } - - -def test_chat_misc_new_commands_cli_to_mcp(dws): - output = assert_ok( - dws.run_raw( - "chat", - "group", - "share-invite", - "--source", - "sourceCid", - "--target", - "targetCid", - "--expires-seconds", - "3600", - "--uuid", - "uuid-1", - "--dry-run", - ) - ) - assert "share_group_invite_url" in output - assert dry_run_args(output) == { - "sourceOpenConversationId": "sourceCid", - "targetOpenConversationId": "targetCid", - "expiresSeconds": 3600, - "uuid": "uuid-1", - } - - output = assert_ok( - dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run") - ) - assert "translate" in output - assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"} - - output = assert_ok( - dws.run_raw( - "chat", - "category", - "create-smart", - "--name", - "priority", - "--keywords", - "alpha,beta", - "--members", - "uid1,uid2", - "--dry-run", - ) - ) - assert "create_smart_conv_category" in output - assert dry_run_args(output) == { - "title": "priority", - "keywords": ["alpha", "beta"], - "memberOpenDingTalkIds": ["uid1", "uid2"], - } - - output = assert_ok( - dws.run_raw( - "chat", - "message", - "list-emotion-replies", - "--msg-ids", - "msg1,msg2", - "--dry-run", - ) - ) - assert "list_message_emotion_replies" in output - assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]} diff --git a/auto-test/cli_to_mcp/testcases/conftest.py b/auto-test/cli_to_mcp/testcases/conftest.py deleted file mode 100644 index c1ce4468..00000000 --- a/auto-test/cli_to_mcp/testcases/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from test_utils import DWSRunner - - -@pytest.fixture(scope="session") -def dws(): - return DWSRunner() diff --git a/auto-test/cli_to_mcp/testcases/doc/test_12_import.py b/auto-test/cli_to_mcp/testcases/doc/test_12_import.py deleted file mode 100644 index 80143d0f..00000000 --- a/auto-test/cli_to_mcp/testcases/doc/test_12_import.py +++ /dev/null @@ -1,54 +0,0 @@ -from test_utils import combined_output - - -def assert_ok(result): - output = combined_output(result) - assert result.returncode == 0, output - return output - - -def test_doc_import_help_and_validation(dws, tmp_path): - output = assert_ok(dws.run_raw("doc", "import", "--help")) - assert "dws doc import" in output - assert "--file string" in output - assert "--workspace string" in output - assert "--name string" in output - - result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run") - output = combined_output(result) - assert result.returncode != 0 - assert "cannot read file" in output - - bad = tmp_path / "bad.exe" - bad.write_text("bad", encoding="utf-8") - result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run") - output = combined_output(result) - assert result.returncode != 0 - assert "unsupported file format" in output - - -def test_doc_import_dry_run(dws, tmp_path): - source = tmp_path / "sample.md" - source.write_text("# Sample\n\nhello\n", encoding="utf-8") - - output = assert_ok( - dws.run_raw( - "doc", - "import", - "--file", - str(source), - "--name", - "Imported Sample", - "--workspace", - "workspace123", - "--dry-run", - ) - ) - assert "Imported Sample" in output - assert "sample.md" in output - assert "md" in output - - -def test_doc_import_get_dry_run(dws): - output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run")) - assert "task123" in output diff --git a/auto-test/cli_to_mcp/testcases/mail/test_11_mail_new_commands.py b/auto-test/cli_to_mcp/testcases/mail/test_11_mail_new_commands.py deleted file mode 100644 index 1a6bd5d0..00000000 --- a/auto-test/cli_to_mcp/testcases/mail/test_11_mail_new_commands.py +++ /dev/null @@ -1,143 +0,0 @@ -import os - -from test_utils import combined_output, dry_run_args - - -def mail_email() -> str: - return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com") - - -def assert_ok(result): - output = combined_output(result) - assert result.returncode == 0, output - return output - - -def assert_fails(result, expected: str): - output = combined_output(result) - assert result.returncode != 0, output - assert expected in output - - -def test_mailbox_profile_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help")) - assert "dws mail mailbox profile" in output - assert "--email string" in output - - assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email") - - output = assert_ok( - dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run") - ) - assert "get_mailbox_profile" in output - assert dry_run_args(output) == {"email": mail_email()} - - -def test_message_batch_get_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help")) - assert "dws mail message batch-get" in output - assert "--email string" in output - assert "--ids string" in output - - assert_fails( - dws.run_raw("mail", "message", "batch-get", "--email", mail_email()), - "ids", - ) - - too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21)) - assert_fails( - dws.run_raw( - "mail", - "message", - "batch-get", - "--email", - mail_email(), - "--ids", - too_many_ids, - "--dry-run", - ), - "20", - ) - - output = assert_ok( - dws.run_raw( - "mail", - "message", - "batch-get", - "--email", - mail_email(), - "--ids", - "msg_001,msg_002", - "--dry-run", - ) - ) - assert "get_email_by_message_id" in output - assert "msg_001" in output - assert "msg_002" in output - - -def test_sent_message_recall_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help")) - assert "dws mail sent-message recall" in output - assert "--subject string" in output - assert "--yes" in output - - assert_fails( - dws.run_raw( - "mail", - "sent-message", - "recall", - "--email", - mail_email(), - "--id", - "msg_001", - "--subject", - "subject", - ), - "--yes", - ) - - output = assert_ok( - dws.run_raw( - "mail", - "sent-message", - "recall", - "--email", - mail_email(), - "--id", - "msg_001", - "--subject", - "subject", - "--yes", - "--dry-run", - ) - ) - assert "recall_sent_message" in output - assert dry_run_args(output) == { - "email": mail_email(), - "id": "msg_001", - "subject": "subject", - } - - -def test_sent_message_recall_detail_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help")) - assert "dws mail sent-message recall-detail" in output - assert "--email string" in output - assert "--id string" in output - assert "FINISHED" in output - - output = assert_ok( - dws.run_raw( - "mail", - "sent-message", - "recall-detail", - "--email", - mail_email(), - "--id", - "task_001", - "--dry-run", - ) - ) - assert "get_recall_detail" in output - assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"} diff --git a/auto-test/cli_to_mcp/testcases/mail/test_12_mail_allowlist_blocklist_autoreply.py b/auto-test/cli_to_mcp/testcases/mail/test_12_mail_allowlist_blocklist_autoreply.py deleted file mode 100644 index 2046693c..00000000 --- a/auto-test/cli_to_mcp/testcases/mail/test_12_mail_allowlist_blocklist_autoreply.py +++ /dev/null @@ -1,133 +0,0 @@ -import os - -from test_utils import combined_output, dry_run_args - - -def mail_email() -> str: - return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com") - - -def assert_ok(result): - output = combined_output(result) - assert result.returncode == 0, output - return output - - -def test_auto_reply_update_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help")) - for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"): - assert flag in output - - output = assert_ok( - dws.run_raw( - "mail", - "auto-reply", - "update", - "--email", - mail_email(), - "--enabled", - "true", - "--start", - "2026/07/01 09:00:00 +0800", - "--end", - "2026/07/07 18:00:00 +0800", - "--scope", - "all", - "--content", - "out of office", - "--dry-run", - ) - ) - assert "update_auto_reply" in output - assert dry_run_args(output) == { - "email": mail_email(), - "enabled": True, - "startTime": "2026/07/01 09:00:00 +0800", - "endTime": "2026/07/07 18:00:00 +0800", - "scope": "all", - "content": "out of office", - } - - -def test_allow_list_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run")) - assert "list_mailbox_allowlist" in output - assert dry_run_args(output) == {"email": mail_email()} - - output = assert_ok( - dws.run_raw( - "mail", - "allow-list", - "add", - "--email", - mail_email(), - "--entries", - "partner@example.com,@example.org", - "--dry-run", - ) - ) - assert "add_mailbox_allowlist" in output - assert dry_run_args(output) == { - "email": mail_email(), - "entries": ["partner@example.com", "@example.org"], - } - - output = assert_ok( - dws.run_raw( - "mail", - "allow-list", - "remove", - "--email", - mail_email(), - "--entries", - "partner@example.com", - "--dry-run", - ) - ) - assert "remove_mailbox_allowlist" in output - assert dry_run_args(output) == { - "email": mail_email(), - "entries": ["partner@example.com"], - } - - -def test_block_list_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run")) - assert "list_mailbox_blocklist" in output - assert dry_run_args(output) == {"email": mail_email()} - - output = assert_ok( - dws.run_raw( - "mail", - "block-list", - "add", - "--email", - mail_email(), - "--entries", - "spam@example.com,@junk.example", - "--dry-run", - ) - ) - assert "add_mailbox_blocklist" in output - assert dry_run_args(output) == { - "email": mail_email(), - "entries": ["spam@example.com", "@junk.example"], - } - - output = assert_ok( - dws.run_raw( - "mail", - "block-list", - "remove", - "--email", - mail_email(), - "--entries", - "spam@example.com", - "--dry-run", - ) - ) - assert "remove_mailbox_blocklist" in output - assert dry_run_args(output) == { - "email": mail_email(), - "entries": ["spam@example.com"], - } diff --git a/auto-test/cli_to_mcp/testcases/sheet/test_36_group_dimension.py b/auto-test/cli_to_mcp/testcases/sheet/test_36_group_dimension.py deleted file mode 100644 index b2737490..00000000 --- a/auto-test/cli_to_mcp/testcases/sheet/test_36_group_dimension.py +++ /dev/null @@ -1,80 +0,0 @@ -from test_utils import combined_output, dry_run_args - - -def assert_ok(result): - output = combined_output(result) - assert result.returncode == 0, output - return output - - -def test_group_dimension_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help")) - assert "dws sheet group-dimension" in output - assert "--group-state string" in output - - output = assert_ok( - dws.run_raw( - "sheet", - "group-dimension", - "--node", - "node123", - "--sheet-id", - "Sheet1", - "--range", - "3:7", - "--group-state", - "fold", - "--dry-run", - ) - ) - assert "group_dimension" in output - assert dry_run_args(output) == { - "nodeId": "node123", - "sheetId": "Sheet1", - "range": "3:7", - "groupState": "fold", - } - - -def test_ungroup_dimension_cli_to_mcp(dws): - output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help")) - assert "dws sheet ungroup-dimension" in output - - output = assert_ok( - dws.run_raw( - "sheet", - "ungroup-dimension", - "--node", - "node123", - "--sheet-id", - "Sheet1", - "--range", - "C:F", - "--dry-run", - ) - ) - assert "ungroup_dimension" in output - assert dry_run_args(output) == { - "nodeId": "node123", - "sheetId": "Sheet1", - "range": "C:F", - } - - -def test_group_dimension_rejects_invalid_state(dws): - result = dws.run_raw( - "sheet", - "group-dimension", - "--node", - "node123", - "--sheet-id", - "Sheet1", - "--range", - "3:7", - "--group-state", - "invalid", - "--dry-run", - ) - output = combined_output(result) - assert result.returncode != 0 - assert "group-state" in output diff --git a/auto-test/cli_to_mcp/testcases/test_utils/__init__.py b/auto-test/cli_to_mcp/testcases/test_utils/__init__.py deleted file mode 100644 index 377beca2..00000000 --- a/auto-test/cli_to_mcp/testcases/test_utils/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -import json -import os -import re -import shlex -import subprocess -from pathlib import Path - - -def repo_root(start_file: str) -> Path: - current = Path(start_file).resolve() - for parent in [current, *current.parents]: - if (parent / "go.mod").exists(): - return parent - raise RuntimeError(f"cannot locate repo root from {start_file}") - - -def resolve_dws_cmd(start_file: str) -> list[str]: - root = repo_root(start_file) - if env_bin := os.environ.get("DWS_BIN"): - return shlex.split(env_bin) - - for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"): - candidate = root / rel - if candidate.exists() and os.access(candidate, os.X_OK): - return [str(candidate)] - - return ["go", "run", "./cmd"] - - -def combined_output(result: subprocess.CompletedProcess) -> str: - return (result.stdout or "") + (result.stderr or "") - - -def dry_run_args(output: str) -> dict: - match = re.search(r"Arguments:\s*(\{.*\})", output, re.S) - assert match, f"dry-run output does not contain Arguments JSON: {output}" - return json.loads(match.group(1)) - - -class DWSRunner: - def __init__(self): - self.root = repo_root(__file__) - self.cmd = resolve_dws_cmd(__file__) - - def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess: - return subprocess.run( - [*self.cmd, *args], - cwd=self.root, - text=True, - capture_output=True, - timeout=timeout, - ) - - def run(self, *args: str, timeout: int = 45): - result = self.run_raw(*args, timeout=timeout) - output = combined_output(result) - assert result.returncode == 0, output - return json.loads(result.stdout) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index b98f08b2..d31690d7 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -15,6 +15,7 @@ import ( "strings" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -4927,17 +4928,26 @@ status 可选值: dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2 dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "name"); err != nil { - return err + name := strings.TrimSpace(mustGetFlag(cmd, "name")) + if name == "" { + return apperrors.NewValidation("--name must not be blank") } toolArgs := map[string]any{ - "title": mustGetFlag(cmd, "name"), + "categoryName": name, } - if v, _ := cmd.Flags().GetString("keywords"); v != "" { - toolArgs["keywords"] = parseCSVValues(v) + if cmd.Flags().Changed("keywords") { + values := parseCSVValues(mustGetFlag(cmd, "keywords")) + if len(values) == 0 { + return apperrors.NewValidation("--keywords must contain at least one non-empty value") + } + toolArgs["groupNameKeywords"] = values } - if v, _ := cmd.Flags().GetString("members"); v != "" { - toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v) + if cmd.Flags().Changed("members") { + values := parseCSVValues(mustGetFlag(cmd, "members")) + if len(values) == 0 { + return apperrors.NewValidation("--members must contain at least one non-empty value") + } + toolArgs["memberOpenDingTalkIds"] = values } return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs) }, diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go new file mode 100644 index 00000000..f036ef71 --- /dev/null +++ b/internal/helpers/chat_message_search_test.go @@ -0,0 +1,129 @@ +// 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 helpers + +import ( + "context" + "io" + "os" + "reflect" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type chatMessageSearchCall struct { + productID string + toolName string + args map[string]any +} + +type chatMessageSearchCaller struct { + calls []chatMessageSearchCall +} + +func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args}) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (*chatMessageSearchCaller) Format() string { return "json" } +func (*chatMessageSearchCaller) DryRun() bool { return false } +func (*chatMessageSearchCaller) Fields() string { return "" } +func (*chatMessageSearchCaller) JQ() string { return "" } + +func TestChatMessageSearchUsesMCPContracts(t *testing.T) { + previousDeps := deps + previousArgs := os.Args + t.Cleanup(func() { + deps = previousDeps + os.Args = previousArgs + }) + + start := "2026-07-09T00:00:00+08:00" + end := "2026-07-11T00:00:00+08:00" + startTime, err := time.Parse(time.RFC3339, start) + if err != nil { + t.Fatalf("parse start time: %v", err) + } + endTime, err := time.Parse(time.RFC3339, end) + if err != nil { + t.Fatalf("parse end time: %v", err) + } + + tests := []struct { + name string + args []string + productID string + toolName string + wantToolArg map[string]any + }{ + { + name: "keyword search", + args: []string{"message", "search", "--query", "categoryName", "--group", "cid-1", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"}, + productID: "chat", + toolName: "search_messages_by_keyword", + wantToolArg: map[string]any{ + "keyword": "categoryName", + "openConversationId": "cid-1", + "startTime": startTime.UnixMilli(), + "endTime": endTime.UnixMilli(), + "limit": 100, + "cursor": "0", + }, + }, + { + name: "advanced search", + args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"}, + productID: "im", + toolName: "search_messages", + wantToolArg: map[string]any{ + "keyword": "categoryName", + "openConversationIds": []string{"cid-1", "cid-2"}, + "startTime": startTime.UnixMilli(), + "endTime": endTime.UnixMilli(), + "limit": 100, + "cursor": "0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessageSearchCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + os.Args = []string{"dws", "chat"} + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(tt.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat search returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != tt.productID || call.toolName != tt.toolName { + t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName) + } + if !reflect.DeepEqual(call.args, tt.wantToolArg) { + t.Fatalf("tool args = %#v, want %#v", call.args, tt.wantToolArg) + } + }) + } +} diff --git a/internal/helpers/chat_smart_category_test.go b/internal/helpers/chat_smart_category_test.go new file mode 100644 index 00000000..abb7d499 --- /dev/null +++ b/internal/helpers/chat_smart_category_test.go @@ -0,0 +1,187 @@ +// 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 helpers + +import ( + "context" + "io" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type chatSmartCategoryCall struct { + productID string + toolName string + args map[string]any +} + +type chatSmartCategoryCaller struct { + calls []chatSmartCategoryCall +} + +func (c *chatSmartCategoryCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, chatSmartCategoryCall{ + productID: productID, + toolName: toolName, + args: args, + }) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (*chatSmartCategoryCaller) Format() string { return "json" } +func (*chatSmartCategoryCaller) DryRun() bool { return false } +func (*chatSmartCategoryCaller) Fields() string { return "" } +func (*chatSmartCategoryCaller) JQ() string { return "" } + +func TestChatCategoryCreateSmartUsesMCPParameterNames(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &chatSmartCategoryCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{ + "category", "create-smart", + "--name", "priority", + "--keywords", "alpha, ,beta,", + "--members", "open-id-1, ,open-id-2,", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat category create-smart returned error: %v", err) + } + + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != "im" || call.toolName != "create_smart_conv_category" { + t.Fatalf("tool call = %s/%s, want im/create_smart_conv_category", call.productID, call.toolName) + } + want := map[string]any{ + "categoryName": "priority", + "groupNameKeywords": []string{"alpha", "beta"}, + "memberOpenDingTalkIds": []string{"open-id-1", "open-id-2"}, + } + if !reflect.DeepEqual(call.args, want) { + t.Fatalf("tool args = %#v, want %#v", call.args, want) + } +} + +func TestChatCategoryCreateSmartRejectsBlankInputs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "empty name", + args: []string{"--name", ""}, + wantErr: "--name must not be blank", + }, + { + name: "blank name", + args: []string{"--name", " \t "}, + wantErr: "--name must not be blank", + }, + { + name: "empty keywords", + args: []string{"--name", "priority", "--keywords", ""}, + wantErr: "--keywords must contain at least one non-empty value", + }, + { + name: "blank keywords CSV", + args: []string{"--name", "priority", "--keywords", " , , "}, + wantErr: "--keywords must contain at least one non-empty value", + }, + { + name: "empty keywords JSON array", + args: []string{"--name", "priority", "--keywords", "[]"}, + wantErr: "--keywords must contain at least one non-empty value", + }, + { + name: "empty members", + args: []string{"--name", "priority", "--members", ""}, + wantErr: "--members must contain at least one non-empty value", + }, + { + name: "blank members CSV", + args: []string{"--name", "priority", "--members", " , , "}, + wantErr: "--members must contain at least one non-empty value", + }, + { + name: "blank members JSON array", + args: []string{"--name", "priority", "--members", `["", " "]`}, + wantErr: "--members must contain at least one non-empty value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &chatSmartCategoryCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(append([]string{"category", "create-smart"}, tt.args...)) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + if got := apperrors.ExitCode(err); got != 3 { + t.Fatalf("exit code = %d, want validation code 3", got) + } + if len(caller.calls) != 0 { + t.Fatalf("tool call count = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestChatCategoryCreateSmartAllowsOmittedRulesAndTrimsName(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &chatSmartCategoryCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"category", "create-smart", "--name", " priority "}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat category create-smart returned error: %v", err) + } + + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + want := map[string]any{"categoryName": "priority"} + if !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want) + } +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 1b509b80..0e4e7f36 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -105,6 +105,7 @@ "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)", "执行者 userId 列表 (必填)": "Executor userId list (required)", "检查登录状态后重试": "Check login status and retry", + "检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command", "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.", "检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry", "检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry", @@ -117,6 +118,7 @@ "完成状态: true/false": "Done status: true/false", "完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)", "确认删除? (yes/no): ": "Confirm delete? (yes/no): ", + "确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later", "等待用户授权...": "Waiting for user authorization...", "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.", "网络错误,继续重试...": "Network error, retrying...", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index cd463211..d4e491db 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -105,6 +105,7 @@ "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)", "执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)", "检查登录状态后重试": "检查登录状态后重试", + "检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令", "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。", "检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试", "检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令", @@ -117,6 +118,7 @@ "完成状态: true/false": "完成状态: true/false", "完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)", "确认删除? (yes/no): ": "确认删除? (yes/no): ", + "确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试", "等待用户授权...": "等待用户授权...", "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。", "网络错误,继续重试...": "网络错误,继续重试...", diff --git a/internal/transport/client.go b/internal/transport/client.go index ed516765..8b9a2d8a 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -416,7 +416,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque } callStart := time.Now() - resp, err := c.doWithRetry(ctx, endpoint, body) + resp, err := c.doWithRetry(ctx, endpoint, body, request.Method) if err != nil { return err } @@ -493,15 +493,19 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque return nil } -func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) { +func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte, method string) (*http.Response, error) { endpoint = sanitizeJSONRPCEndpoint(endpoint) + operation := strings.TrimSpace(method) + if operation == "" { + operation = "jsonrpc" + } var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, apperrors.NewDiscovery( "failed to create JSON-RPC request", - apperrors.WithOperation("jsonrpc"), + apperrors.WithOperation(operation), apperrors.WithReason("request_build_failed"), apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")), apperrors.WithCause(&CallError{ @@ -559,37 +563,52 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) statusForLog = resp.StatusCode } delay := c.retryDelayForAttempt(attempt, retryAfter) - logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr) + logging.LogRetryAttempt(c.FileLogger, operation, c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr) if err := c.sleepForRetry(ctx, delay); err != nil { - return nil, apperrors.NewDiscovery( - "request cancelled during retry", - apperrors.WithOperation("jsonrpc"), + opts := []apperrors.Option{ + apperrors.WithOperation(operation), apperrors.WithReason("request_cancelled"), apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")), apperrors.WithCause(&CallError{ Stage: CallStageRequest, Cause: err, }), - ) + } + if operation == "tools/call" { + opts = append(opts, apperrors.WithActions(networkActions("")...)) + return nil, apperrors.NewAPI("request cancelled during retry", opts...) + } + opts = append(opts, apperrors.WithActions(discoveryActions("")...)) + return nil, apperrors.NewDiscovery("request cancelled during retry", opts...) } } } reason, hint := classifyRequestFailure(lastErr) - logging.LogErrorClassified(c.FileLogger, "jsonrpc", c.ExecutionId, - string(apperrors.CategoryDiscovery), reason, 0, 0, + category := apperrors.CategoryDiscovery + actions := discoveryActions("") + if operation == "tools/call" { + category = apperrors.CategoryAPI + actions = networkActions("") + } + logging.LogErrorClassified(c.FileLogger, operation, c.ExecutionId, + string(category), reason, 0, 0, !isTimeoutError(lastErr), "") - return nil, apperrors.NewDiscovery( - fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr), - apperrors.WithOperation("jsonrpc"), + opts := []apperrors.Option{ + apperrors.WithOperation(operation), apperrors.WithReason(reason), apperrors.WithRetryable(!isTimeoutError(lastErr)), apperrors.WithHint(hint), - apperrors.WithActions(discoveryActions("")...), + apperrors.WithActions(actions...), apperrors.WithCause(&CallError{ Stage: CallStageRequest, Cause: lastErr, }), - ) + } + message := fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr) + if category == apperrors.CategoryAPI { + return nil, apperrors.NewAPI(message, opts...) + } + return nil, apperrors.NewDiscovery(message, opts...) } func sanitizeJSONRPCEndpoint(endpoint string) string { @@ -1039,6 +1058,17 @@ func runtimeActions(snapshotPath string) []string { return actions } +func networkActions(snapshotPath string) []string { + actions := []string{ + i18n.T("检查网络、代理和 DNS 配置后重试原命令"), + i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"), + } + if snapshotPath != "" { + actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath)) + } + return actions +} + // validateCallArguments checks string values in tool call arguments for // control characters and dangerous Unicode at the transport boundary. func validateCallArguments(args map[string]any) error { diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go index 05177cbd..d9a1b070 100644 --- a/internal/transport/client_extra_test.go +++ b/internal/transport/client_extra_test.go @@ -103,7 +103,7 @@ func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) { })}) client.FileLogger = logger - resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`)) + resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`), "initialize") if err != nil { t.Fatalf("doWithRetry() error = %v", err) } diff --git a/internal/transport/recovery_metadata_test.go b/internal/transport/recovery_metadata_test.go index cacccd81..5dfcd7c3 100644 --- a/internal/transport/recovery_metadata_test.go +++ b/internal/transport/recovery_metadata_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "net/http" + "strings" "testing" + "time" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) @@ -53,7 +55,7 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) { })}) client.MaxRetries = 0 - _, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`)) + _, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`), "tools/call") if err == nil { t.Fatal("doWithRetry() error = nil, want request failure") } @@ -67,6 +69,125 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) { } } +func TestCallToolRequestFailureUsesAPIClassification(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })}) + client.MaxRetries = 0 + + _, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", map[string]any{"keyword": "marker"}) + if err == nil { + t.Fatal("CallTool() error = nil, want request failure") + } + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("CallTool() error = %T, want *errors.Error", err) + } + if typed.Category != apperrors.CategoryAPI { + t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI) + } + if typed.Operation != "tools/call" { + t.Fatalf("Operation = %q, want tools/call", typed.Operation) + } + if typed.Reason != "connection_refused" { + t.Fatalf("Reason = %q, want connection_refused", typed.Reason) + } + actions := strings.Join(typed.Actions, "\n") + if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") { + t.Fatalf("runtime network failure contains discovery-only actions: %q", actions) + } +} + +func TestCallToolRetryCancellationUsesAPIClassification(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: http.NoBody}, nil + })}) + client.MaxRetries = 1 + client.sleep = func(context.Context, time.Duration) error { return context.Canceled } + + _, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", nil) + if err == nil { + t.Fatal("CallTool() error = nil, want retry cancellation") + } + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("CallTool() error = %T, want *errors.Error", err) + } + if typed.Category != apperrors.CategoryAPI { + t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI) + } + if typed.Operation != "tools/call" { + t.Fatalf("Operation = %q, want tools/call", typed.Operation) + } + if typed.Reason != "request_cancelled" { + t.Fatalf("Reason = %q, want request_cancelled", typed.Reason) + } + actions := strings.Join(typed.Actions, "\n") + if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") { + t.Fatalf("runtime retry cancellation contains discovery-only actions: %q", actions) + } +} + +func TestDiscoveryRequestFailuresKeepDiscoveryClassification(t *testing.T) { + tests := []struct { + name string + failure error + operation string + wantReason string + call func(*Client) error + }{ + { + name: "initialize connection refused", + failure: errors.New("dial tcp: connection refused"), + operation: "initialize", + wantReason: "connection_refused", + call: func(client *Client) error { + _, err := client.Initialize(context.Background(), "https://mcp.dingtalk.com/server") + return err + }, + }, + { + name: "tools list dns failure", + failure: errors.New("dial tcp: lookup mcp.dingtalk.com: no such host"), + operation: "tools/list", + wantReason: "dns_resolution_failed", + call: func(client *Client) error { + _, err := client.ListTools(context.Background(), "https://mcp.dingtalk.com/server") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, tt.failure + })}) + client.MaxRetries = 0 + + err := tt.call(client) + if err == nil { + t.Fatal("request error = nil, want failure") + } + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("request error = %T, want *errors.Error", err) + } + if typed.Category != apperrors.CategoryDiscovery { + t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryDiscovery) + } + if typed.Operation != tt.operation { + t.Fatalf("Operation = %q, want %q", typed.Operation, tt.operation) + } + if typed.Reason != tt.wantReason { + t.Fatalf("Reason = %q, want %q", typed.Reason, tt.wantReason) + } + }) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/scripts/policy/open-source-audit.sh b/scripts/policy/open-source-audit.sh index 819ebf44..07b93d7a 100755 --- a/scripts/policy/open-source-audit.sh +++ b/scripts/policy/open-source-audit.sh @@ -36,6 +36,14 @@ require_no_match() { fi } +require_no_tracked_path() { + path="$1" + if git ls-files -- "$path" | grep -q .; then + git ls-files -- "$path" >&2 + err "found forbidden tracked path: $path" + fi +} + require_heading_if_linked() { label="$1" heading="$2" @@ -62,6 +70,10 @@ require_no_match 'scripts/test\.sh|scripts/check-semantic-fixtures\.sh|scripts/r require_no_match 'scripts/check-semantic-fixtures\.py|test/semantic/cli_to_mcp|test/semantic/model_to_cli|python3 -m pytest test/semantic' CONTRIBUTING.md require_no_match '\.github/CODEOWNERS|Tag-triggered GitHub Release asset publishing' CHANGELOG.md +require_no_tracked_path 'auto-test' +require_no_tracked_path 'eval-runs' +require_no_tracked_path '.qoder' + require_heading_if_linked '[Environment Variables](#environment-variables)' '## Environment Variables' require_heading_if_linked '[Exit Codes](#exit-codes)' '## Exit Codes'