Compare commits

..
Author SHA1 Message Date
修雨 fc9acb9007 fix: align smart category args and runtime network errors (#591)
* fix: align smart category args and remove eval fixtures

* fix: classify runtime network failures

* fix: validate smart category inputs
2026-07-10 21:40:36 +08:00
aa6abc5ed6 feat(event): add personal event subscriptions (#589)
* dws event

* fix consume fail

* test: add stream ticket injection probe

* feat: add portal ticket stream mode

* user event

* fix: allow portal ticket normal without app secret

* event

* user event

* eventType filter

* refactor(event): 优化IPC端点路径处理和改进相关测试

- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖

* opt

* event skill

* default value

* install script event

* fix: remove subscribe id event fanout filter

* fix(personal): 修正指定发送人消息描述错误

* more im event

* filter subId

* fix: align personal event schema with stream payload

* fix: avoid duplicate app helper name

* feat: simplify personal event schemas

* feat: simplify event schema output

* docs: refine dingtalk event skill references

* feat: align personal event consume flags

* fix event stop and status visibility

* hide app event public entrypoints

* hide incomplete personal sender event

* remove external event reference comments

* chore(event): prepare official release

* fix(event): harden personal stream lifecycle

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
2026-07-10 17:54:43 +08:00
修雨 ea6fd16d11 chore(changelog): prepare v1.0.51 stable (#595) 2026-07-10 17:35:12 +08:00
20 changed files with 540 additions and 872 deletions
+3
View File
@@ -42,5 +42,8 @@ dws.zip
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
/auto-test/
/eval-runs/
/.qoder/
.vercel
.env*
+20
View File
@@ -6,9 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.51] - 2026-07-10
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
### Added
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
### Changed
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
### Fixed
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
## [1.0.50] - 2026-07-08
-16
View File
@@ -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.
@@ -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,
}
@@ -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"]}
@@ -1,8 +0,0 @@
import pytest
from test_utils import DWSRunner
@pytest.fixture(scope="session")
def dws():
return DWSRunner()
@@ -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
@@ -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"}
@@ -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"],
}
@@ -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
@@ -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)
+17 -7
View File
@@ -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)
},
@@ -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)
}
})
}
}
@@ -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)
}
}
+2
View File
@@ -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...",
+2
View File
@@ -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 服务可访问;若持续失败请稍后重试",
"等待用户授权...": "等待用户授权...",
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
"网络错误,继续重试...": "网络错误,继续重试...",
+45 -15
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+122 -1
View File
@@ -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) {
+12
View File
@@ -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'