Compare commits

..
Author SHA1 Message Date
chichuan e9850a2e49 fix(ci): enforce stable Schema compatibility 2026-08-10 23:06:13 +08:00
chichuan 8097943e3a Merge branch 'main' into codex/interface-migration-governance 2026-08-10 22:31:16 +08:00
github-actions[bot] d5a9a72fa6 Merge pull request #931 from DingTalk-Real-AI/fix/schema-compat-policy-int
ci(schema): allow reviewed parameter type migrations
2026-08-10 22:22:35 +08:00
chichuan b7918be6f3 fix(ci): require stable flag migration reference 2026-08-10 22:16:00 +08:00
chichuan b84b56d9f8 Merge origin/main into interface migration governance 2026-08-10 22:03:18 +08:00
chichuan e66cd95c51 Merge remote-tracking branch 'origin/main' into fix/schema-compat-policy-int 2026-08-10 21:59:55 +08:00
chichuan 7e8b216e07 ci(schema): compare the full parameter contract for reviewed type migrations
Auto-CR (P1) correctly flagged that the carve-out's "nothing else changed"
guard was keyed on len(otherFailures) == 0, which only observes changes the
gate already judges incompatible. Several parameter contract changes are
individually compatible and so produce no failure at all: relaxing required or
cli_required, clearing required_when, widening enum, clearing interface_type,
and clearing property through a reviewed mapping exclusion. Any of those could
have ridden along with a reviewed type migration, leaving the exemption wider
than both its documentation and what the entry actually reviewed.

Replace the failure-list heuristic with a real equality check over every
published field except Type. Comparing the struct also means a field added to
parameterSchema later is covered automatically, instead of silently widening
every existing entry. The type check moves back ahead of the field loop because
it no longer needs to observe the other findings.

Add a rejection case for each individually-compatible direction. Each case first
asserts that the drift alone really is compatible, so it keeps exercising the
equality guard instead of quietly duplicating one of the incompatible-bundle
cases.

Verified against the previous implementation: with the old guard all six new
cases fail while the nine incompatible-bundle cases still pass, which is exactly
the gap that was reported.
2026-08-10 21:52:58 +08:00
github-actions[bot] 5501c9f1a5 Merge pull request #933 from pengzhihan47-star/codex/doc-shortcut_and_skill_opt
feat(doc): harden dingtalk-doc shortcuts, contracts, and verification
2026-08-10 21:11:23 +08:00
柏智 eefe6f05e1 fix(schema): review doc import constraint transition 2026-08-10 17:49:27 +08:00
柏智 6598292b1b fix(doc): allow import to default root 2026-08-10 17:32:23 +08:00
柏智 dea637228d fix(doc): preserve schema compatibility after review 2026-08-10 17:32:23 +08:00
柏智 a09467f1eb fix(doc): address PR 906 review feedback 2026-08-10 17:32:23 +08:00
柏智 34feb348af feat(doc): harden shortcuts and skill routing 2026-08-10 17:32:23 +08:00
chichuan 6b1a1a6201 Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 17:19:54 +08:00
github-actions[bot] 9f60cdeef1 Merge pull request #920 from Anonymity-0/feat/card-reply-mentions
feat(chat): support mentions in streaming card creation
2026-08-10 17:06:24 +08:00
前津 258caa5906 fix(chat): fail safely when card mention tag is missing 2026-08-10 16:39:51 +08:00
前津 a0d59a79aa feat(chat): prepend card mention tag to content 2026-08-10 16:39:51 +08:00
前津 c7148f3ebb docs(chat): clarify streaming card mention content 2026-08-10 16:39:51 +08:00
前津 ef29b48a55 fix(chat): keep skill within context budget 2026-08-10 16:39:51 +08:00
前津 5c33ea526e feat(chat): support mentions in streaming card creation 2026-08-10 16:39:51 +08:00
chichuan 6d3b54b25f Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 16:37:45 +08:00
github-actions[bot] 867bb44586 chore: update beta formula for v1.0.58-beta.2 [skip ci] 2026-08-10 08:22:55 +00:00
chichuan 4bbd42cc25 Merge pull request #930 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.2
chore(release): seal v1.0.58-beta.2 changelog
2026-08-10 16:04:27 +08:00
chichuan fd0c3350f3 ci(schema): allow reviewed parameter type migrations
schema-compatibility is the third check in the same Interface Integrity job,
after the two CLI interface gates. It also rejected every published parameter
type change outright. Because the earlier gates failed first and `set -e`
stopped the step from ever running, this one never surfaced in CI, so the
previous exemption only covered two thirds of the problem.

checkParameterCompatibility now consults a precise allowlist: the tool path,
parameter name and both type values must match exactly, making it
direction-sensitive by construction, and it applies only when nothing else the
gate checks about the parameter moved (default, interface_default, format,
property, interface_type, required, cli_required, required_when, enum). The type
check moved to the end of the function so the carve-out can see those findings;
ordering is unobservable because the result is sorted.

The only entry is "minutes/minutes.apply_minutes_permission" parameter "policy"
migrating from "string" to "integer" (for #912). That type is projected from the
Cobra flag type (provenance cobra_flag_type), so it describes how the CLI accepts
a value. Consumers build a command line from it, and "--policy 4" is the same
argv under either declaration — a quoted "--policy \"4\"" still reaches pflag as
4 — while RunE keeps enforcing the same [2,4] domain. The parameter maps to
property "policyId", which the command has always sent as a number, so "integer"
is closer to the actual request than "string" was.

Table values must be the canonical form schemaType emits: the JSON encoding of
the type keyword, so `"string"` with its quotes rather than a bare string. The
guard test recomputes both through schemaType and checks the decoded name
against the closed JSON Schema type set — reviewedInterfaceRefRedirect was
silently disabled twice by exactly this class of spelling mistake.
2026-08-10 15:57:15 +08:00
chichuan 2cc24de505 chore(release): seal v1.0.58-beta.2 changelog 2026-08-10 15:55:25 +08:00
github-actions[bot] 6575301a3a Merge pull request #926 from DingTalk-Real-AI/ci/reviewed-flag-type-exemption
ci(interface): allow reviewed flag type migrations
2026-08-10 15:22:40 +08:00
chichuan 43121f1d8f test(interface): cover the merge-path bundled-regression branches
mergedFlagContractOtherwiseChanged was only ever exercised on the path where
every condition holds still, because `||` short-circuits: with no reviewed
entry the first operand already decides the outcome and the function is never
called at all. That left its five regression branches uncovered and put
changed-code coverage at 88.0952% against a 100% target.

Add the merge-path counterpart of the checkCompatibility bundled-regression
table, pairing each of shorthand / required / hidden / no-opt / scope with the
reviewed type change and requiring the type failure to reappear. Changed-code
coverage is now 100%.
2026-08-10 15:02:08 +08:00
chichuan cd02fe71e6 ci(interface): allow reviewed flag type migrations
The authoritative interface baseline and command-compatibility gates
rejected every flag type change on a historical command, with no review
channel — even when the new type only moves the same validation from RunE
to flag parsing. Both now consult a precise allowlist.

An entry must match command path, flag name and both type names exactly,
so it is direction-sensitive by construction, and it applies only when
nothing else about the flag moved (shorthand, required, hidden, no-opt,
scope). A bundled regression re-reports the type change.

The first and only entry is "dws minutes permission apply --policy" moving
from string to int (for #912): the old RunE parsed with
strconv.ParseInt(v, 10, 64) and enforced [2,4], the new one lets pflag
parse with base 0 and still enforces [2,4], so the historical set of
successful invocations is a subset of the new one. Base 0 additionally
accepts spellings like "0x3", which widens rather than narrows. Defaults
are excluded from the guard because the migration necessarily changes one.

In the snapshot gate the exemption resolves against the canonical
Command.Path, never the alias-expanded accepted path: an aliased command is
compared once per accepted spelling, so keying on that would let every
alias bypass the table.

The table is duplicated because check-authoritative-interface-baselines.sh
copies the whole scripts/policy/interface-baseline directory into a
worktree checked out at a historical revision and builds it there, so that
copy cannot import a package this branch adds. A guard test fails if the
two copies drift.
2026-08-10 14:26:32 +08:00
github-actions[bot] 93a2071837 Merge pull request #914 from maoqxxmm/codex/align-sheet-skill-docs
docs(sheet): align mono and multi skill references
2026-08-10 14:11:48 +08:00
xiatian 33730337f3 Merge remote-tracking branch 'upstream/main' into codex/align-sheet-skill-docs 2026-08-10 12:06:16 +08:00
xiatian 6be12655dc fix(skills): address sheet review feedback 2026-08-10 12:06:07 +08:00
github-actions[bot] cf3bcb380f Merge pull request #897 from Anonymity-0/feat/chat-message-help-id-chain
docs(chat): document post-send message ID chain
2026-08-10 04:01:36 +00:00
Anonymity-0 89e8bd7015 Merge branch 'main' into feat/chat-message-help-id-chain 2026-08-10 11:42:02 +08:00
chichuan 64e1dcc150 test: make temp failure portable on Windows 2026-08-10 11:36:53 +08:00
chichuan f3ecac1ad1 ci: satisfy workflow shell lint 2026-08-10 11:14:23 +08:00
chichuan 46ae1c50fe ci: govern exact CLI flag migrations 2026-08-10 10:46:20 +08:00
xiatian 5de36d783a Merge remote-tracking branch 'upstream/main' into codex/align-sheet-skill-docs
# Conflicts:
#	CHANGELOG.md
#	skills/mono/references/products/sheet/sheet-dimension-operations.md
#	skills/mono/references/products/sheet/sheet-export.md
#	skills/mono/references/products/sheet/sheet-style-format.md
#	skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md
#	skills/multi/dingtalk-misc/references/sheet/sheet-export.md
2026-08-10 10:21:37 +08:00
github-actions[bot] 2bc4ded969 Merge pull request #883 from Huwenjiao/feat/sheet-sync-a1-a2
feat(sheet): CSV export, style extensions and create-with-data
2026-08-10 10:05:57 +08:00
xiatian 468f9200f6 Merge remote-tracking branch 'upstream/main' into codex/align-sheet-skill-docs
# Conflicts:
#	CHANGELOG.md
2026-08-10 09:52:53 +08:00
huwenjiao.hwjandClaude Opus 5 6f61183732 fix(sheet): reject sheet prefixes that are blank after trimming
--ranges validated the position of "!" in the raw string and then returned the
trimmed halves, so " !A1:B2" was accepted and produced a set_cell_range /
clear_range operation carrying sheetId: "". Depending on how the server treats
an empty sheetId, the whole batch_update fails, or — worse — the operation lands
on the default worksheet instead of the one the user named, while the command
reports success.

Both halves must now be non-empty *after* trimming. batch-clear grew the same
hole independently (it duplicated the split inline); it now shares
splitSheetPrefixedRange, so the invariant holds by construction rather than by
being repeated correctly in two places.

batch-set-style --batch had the same gap at the JSON level: it only rejected
sheetId == "", so "   " passed. It now judges the trimmed value but still sends
the raw one — sheetId may be a worksheet *name*, and names may legitimately
carry leading or trailing spaces, so trimming on the user's behalf would target
a different sheet. The --ranges form cannot express such a name anyway, which is
what --batch is for.

TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall covers all three entry
points with calls == 0; TestBatchStyleSheetIDIsSentVerbatimNotTrimmed pins the
no-normalisation half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:33:56 +08:00
huwenjiao.hwjandClaude Opus 5 332b74e8ce refactor(sheet): split create-with-data and export-csv onto their own leaves
Both capabilities were added as flags on an existing leaf, and in both cases
the leaf's published interface stopped describing what the command did:

- `sheet create --values/--sheets/--styles` orchestrates create → probe →
  resolve default worksheet → write → read back → optional styles, yet the
  leaf still published `interface_mode: mcp` + `create_workspace_sheet`.
- `sheet export --export-format csv` reads `get_range_as_csv` and never
  invokes `submit_export_job`, yet the leaf published `submit_export_job`.

Each moves to its own command, declaring the interface it actually uses:
`sheet create-with-data` is `composite` with a reviewed reason and no
`interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are
pinned in the interface-disposition contract test.

`sheet create` and `sheet export` are restored byte-for-byte to main, so the
compatibility gates see two `command_added` additions instead of four
locked-field changes. The split also removes a user-visible trap: `--range`
without `--export-format csv` used to be silently discarded and the whole
workbook exported; the cross-format flags no longer exist, pinned by
TestSheetExportAndExportCsvFlagsDoNotLeak.

Drops the 8 now-stale mapping-ledger exclusions that covered the flags on
`sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this
branch's exclusion surface. Skill references and CHANGELOG follow the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:47:50 +08:00
huwenjiao.hwjandClaude Opus 5 10bb2ec205 docs(changelog): record the composite-orchestration contract gap on both leaves
sheet create 带 --values/--sheets/--styles 时会依次执行探活、重命名、写入、回读与
可选样式操作,已不是一次 create_workspace_sheet 直接调用;sheet export
--export-format csv 实际读 get_range_as_csv、完全不碰 submit_export_job。两个叶子
却都仍声明 interface_mode: mcp 加单一 interface_ref,Schema 消费者会误判为单次
RPC,并把编排步骤或另一分支的参数当成该 RPC 的入参。

此前只有 csv 分支记了一条含糊的已知限制、create 侧完全没记。现在两条都写清楚:
不准确的具体表现、影响面(仅审计元数据,不影响执行),以及诚实的声明方式——拆成
独立叶子,或把叶子改为 interface_mode: composite。同时记下后者为何留待后续迁移:
对既有叶子而言 interface_mode 变更在 schema-compatibility 中是无条件失败,
checkToolCompatibility 对该字段没有任何豁免通道,而 interface_ref 的 reviewed
redirect 豁免明确要求 mode 保持 mcp 不变。

注意:这是把契约不准确记录成已知限制,不是修复。评审要求的是结构性修改。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:30:32 +08:00
huwenjiao.hwjandClaude Opus 5 ac0125e468 docs(changelog): record the stricter --length parsing as a behaviour change
--length 的解析由 fmt.Sscanf("%d") 改为 strconv.Atoi,影响 sheet
insert-dimension / delete-dimension / update-dimension 三个既有命令。这是对既有
命令的用户可见行为变更,此前只作为实现细节修掉,CHANGELOG 与 PR 描述都没记:原先
Sscanf 只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作(删除方向不可
回滚);现在整个值必须是合法正整数。原先依赖宽松解析、在传畸形 --length 的脚本
升级后会开始报错,用户需要能在发布说明里找到解释。

CHANGELOG 的 Unreleased ### Changed 补一条(含升级影响与 add-dimension 不受影响
的说明),PR 描述的 Summary 补一张 Behaviour change 表。

同时补一条回归测试钉住该行为:三个命令 × 六种畸形值(2x / 3foo / "1 2" / 0x10 /
abc / 空串)都必须报错且错误信息指明 --length。文档写了的行为需要有测试守着,否则
改回宽松解析不会被发现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:01:14 +08:00
huwenjiao.hwjandClaude Opus 5 f06a24ec2e fix(sheet): write export CSV atomically and reject cross-format flags
CSV 落盘从 os.WriteFile 改为仓库已有的 AtomicWrite:os.WriteFile 会先把
已存在的目标文件截断,写入中途失败(磁盘满、配额、I/O 错误)就把用户的原
文件毁掉了。改为写同目录临时文件再 rename,失败时原文件保持不变、临时文件
被清理。父目录仍先 stat 一次,保持与 xlsx 分支一致的「父目录不存在即报错」
语义,不让 AtomicWrite 的 MkdirAll 把拼错的路径悄悄建成目录。

格式分派后拒绝不属于当前分支的显式参数:--sheet-id / --range /
--value-render-option / --allow-truncated 只有 csv 分支消费,此前落到默认的
xlsx 分支会被静默忽略。自动化漏写 --export-format csv 时,用户要的 --range
被丢掉、导出的却是整篇工作簿,命令仍报成功。现在按 Flags().Changed 检测并
在提交导出任务之前报错,同时列出所有误用的参数。反向不需要检查:xlsx 分支
没有专属参数,node / output / export-format 两条分支共用。

测试覆盖 rename 失败后原文件完好且目录无临时文件残留、四个 csv 专属 flag 在
默认与显式 xlsx 下各自被拒且零远程调用、多个参数同时误用时全部列出、csv 分支
照常接受它们;另加一条登记表与实际绑定 flag 的一致性测试,防止新增 flag 漏登记
(漏登记会重新引入静默忽略,误登记共享 flag 会拒掉合法的 xlsx 调用)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:26:40 +08:00
huwenjiao.hwjandClaude Opus 5 8f135ecde6 fix(sheet): reject unknown and mistyped fields in border edge configs
parseBorderStyles read only style and a string color, so every other key and
any non-string color was silently dropped:
{"top":{"style":"solid","colour":"#f00"}} succeeded and drew a border with no
colour, and color: 123 did the same. That contradicts the unknown-key
rejection this PR applies to --sheets and --styles — a partially applied
style reported as success is harder to notice than an error.

Each edge now accepts only style/color, rejects near-miss spellings with the
canonical key, and fails when style or color is present with the wrong type
or empty. All three entry points (set-style --border-styles-json,
batch-set-style --ranges/--batch, and create --styles border_styles) share
parseBorderStyles, so one fix covers them; tests assert the rejection happens
before any MCP call on every path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:05:18 +08:00
huwenjiao.hwj a2e51f2b86 Merge remote-tracking branch 'upstream/main' into feat/sheet-sync-a1-a2 2026-08-08 14:13:02 +08:00
huwenjiao.hwjandClaude Opus 5 3d6e62fc08 docs(sheet): document the batch style caps and --styles size rules
Sweeping the same class the last review round hit: limits and behaviour this
PR added that only reached the Go long help, not the skill references an
agent actually reads.

- batch-set-style: the 200000-cell cumulative cap across all ranges was
  missing (the 100-range cap and the atomic rollback were already there).
- sheet create --styles: size must be a positive integer (a fraction is
  rejected rather than silently truncated) and the row/column range forms
  reject trailing characters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:17:45 +08:00
huwenjiao.hwj c6094e291e Merge remote-tracking branch 'upstream/main' into feat/sheet-sync-a1-a2
# Conflicts:
#	CHANGELOG.md
2026-08-08 00:03:17 +08:00
huwenjiao.hwjandClaude Opus 5 e026c754e0 fix(sheet): require EOF after the --values / --sheets JSON value
json.Decoder.Decode returns after one value, so `--values '[[1]] trailing'`
was accepted as a valid matrix and the document got created anyway. A paste
that ran long, leftover shell concatenation, or two JSON values glued
together would silently drop the tail and still create a document the user
never asked for — and creation cannot be rolled back atomically. Require
EOF after the first value on both flags, following decodeOARequest.

docs(sheet): document the fail-closed CSV export and --allow-truncated

The skill references still claimed an oversized table is truncated with a
warning on stderr, and omitted the flag. The command now aborts before
writing anything when the server reports hasMore, so an agent relying on the
skill would misread the result and had no way to learn how to opt in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 00:01:47 +08:00
github-actions[bot] 18030f1018 chore: update beta formula for v1.0.58-beta.1 [skip ci] 2026-08-07 15:59:04 +00:00
huwenjiao.hwjandClaude Opus 5 a097d57510 fix(sheet): validate the full --sheets contract before creating the document
sheet create promises that every structural check happens before the first
MCP request, but each sheet spec was only checked for object type and name:
columns/data/dtypes/formats/startCell were left to table_put, so a bad type
created and renamed the remote document first and failed at write time,
leaving behind a document the user never successfully asked for. Validate
every provided field against table_put's input contract up front, and reject
the malformed {"sheets":"bad"} wrapper instead of treating it as one spec.

Also fixed while auditing the same flow:
- unknown/misspelled keys are now rejected in both --sheets and --styles.
  The server DTOs are fixed beans, so a stray "datas" was silently dropped
  and the read-back probe landed on the header row: full data loss reported
  as success. Near-miss spellings get the canonical key in the message.
- columns is required (the server requires it), non-blank and trim-unique;
  dtypes/formats keys must resolve to a column, since the server looks them
  up by trimmed name and silently ignores the rest.
- sheetId inside a spec is rejected: the document does not exist yet.
- the read-back probe now honours header:false, mode:append (a fresh sheet
  appends at row 1, the startCell row is ignored) and $-absolute/lowercase
  startCell refs, which the server accepts after uppercasing.
- --values cells must be scalars; a map used to be written as "map[a:1]".
- the 30000-cell and 2000000-char write limits are enforced locally.

Docs: the --styles top level only accepts snake_case (camelCase aliases are
inner-field only), and the read-back probe is not pinned to A1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:37:15 +08:00
huwenjiao.hwj b566afe3e2 docs(changelog): record the csv-branch interface_ref limitation on sheet export
The --export-format csv branch reads get_range_as_csv, but the sheet export leaf
still declares interface_ref: submit_export_job, so discovering the csv
capability through Schema yields the wrong backing interface. Audit metadata
only — interface_ref is not read at runtime and routing is unaffected. Recorded
here so the limitation reaches release notes rather than living only in a code
comment; accurate attribution is tracked as follow-up.
2026-08-07 22:01:49 +08:00
huwenjiao.hwj 7405825294 fix(policy): register the reviewed interface_ref redirect in its canonical form
The allowlist added in the previous commit keyed the reviewed
sheet.range_set_style migration by bare RPC name ("update_range"), but
interface_ref holds the canonicalized JSON that parseTool produces via
canonicalRawJSON. The lookup therefore never matched, the carve-out was
effectively disabled, and the real gate failed with
`schema tool "sheet/sheet.range_set_style" changed interface_ref`.

The existing redirect test did not catch this: it registered the fixture entry
using its own value and then asserted with the same value, so any format would
have passed. That is the same mistake as keying a probe on the author's field
spelling instead of the wire contract.

- The allowlist entry now uses the compact canonical JSON, taken from the gate's
  own output rather than from pretty-printed `dws schema`.
- TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON recomputes every
  registered key through canonicalRawJSON, so a bare name or a pretty-printed
  variant fails locally instead of only in CI.

sheet export keeps its reviewed mcp + submit_export_job declaration. The comment
now records the known trade-off explicitly: --export-format csv is a mutually
exclusive branch that reads get_range_as_csv and never invokes the declared
submit_export_job, so this declaration does not cover the csv branch's backing
interface. Attributing that branch is left out of scope for this change.

Verified against the real gate, not just unit tests: all four Interface
Integrity checks pass (authoritative-interface-integrity,
check-command-compatibility, schema-compatibility, skill-command-integrity).
2026-08-07 21:37:23 +08:00
huwenjiao.hwj 7eb39ad5d6 fix(sheet): narrow the interface_ref carve-out, fail closed on truncated CSV
Two review findings, plus a same-class defect found by sweeping for it.

1. compatibleInterfaceRefRedirect accepted any mcp tool repointing from any
   non-empty interface_ref to any other, as long as no other check for that tool
   failed. Schema shape cannot prove two RPCs share business semantics,
   permissions, error behaviour, or side effects, so that would have let every
   future backend swap bypass the gate it exists to enforce. It is now keyed on
   an explicit reviewedInterfaceRefRedirect allowlist of exact tool + old→new
   pairs, currently holding only the reviewed
   sheet.range_set_style: update_range → set_cell_range migration. Every other
   ref change is reported again.

2. sheet export --export-format csv only printed a stderr warning when
   get_range_as_csv returned hasMore=true, then wrote --output and reported
   success with exit code 0. Automated callers, and anyone not watching stderr,
   would treat an incomplete file as a complete export, and an existing target
   file was overwritten with truncated data. Truncation now fails before the
   write (leaving any existing file untouched) unless --allow-truncated is
   passed; with the opt-in the success line states the data is incomplete.
   --allow-truncated is registered in the reviewed mapping ledger as a local
   policy input.

3. Swept for the same classes and found sheet_dimension.go repeating the
   fmt.Sscanf("%d") prefix-parse hole in three places: insert_dimension,
   delete_dimension, and update_dimension all accepted --length "3x" as 3, so a
   malformed value silently operated on the wrong row/column count — the delete
   direction is not rollbackable. All three now use strconv.Atoi. (Checked and
   cleared: sheet csv-get also surfaces hasMore, but it has no --output and only
   returns the flag in its JSON payload, so it is not the same fail-open shape.)

Tests: allowlist rejection cases (unreviewed target ref, and the same pair on a
tool absent from the allowlist, asserted outside the table so the registration
survives until checkCompatibility runs); truncation fail-closed with a
pre-existing output file asserted byte-for-byte unchanged; --allow-truncated
write-through; and an untruncated read needing no opt-in. Changed-code coverage
stays at 100% (939 statements).
2026-08-07 21:05:47 +08:00
huwenjiao.hwj eb73b944a1 fix(sheet): reject row/column ranges with trailing characters up front
parseRowColRange gates --styles row_sizes/col_sizes before the document is
created. The row branch parsed with fmt.Sscanf(a, "%d", &r1), which consumes
only the leading digits and does not require the whole token, so "1x:3" was
silently accepted as row 1 and "2foo" as row 2. Such input passed pre-flight,
then update_dimension was sent to the wrong row after the document and data had
already been created — an unrollbackable wrong edit, the opposite of the gate's
purpose. The row branch now parses with strconv.Atoi, which requires the entire
token to be a valid integer.

The column branch had the same class of hole via a different path: parseA1Cell
appends "1" to the column token, so "A5" became "A51" and was accepted as
column A. A new isAllLetters pre-check requires the column token to be non-empty
and letters-only before parsing; genuine multi-letter columns like "AX" still
pass. With that guarantee the subsequent parseA1Cell can no longer fail, so its
now-dead error branch is removed.

Adds trailing-character rejection cases to TestParseRowColRange: "1x:3",
"2foo", "1 2:3" (rows), "A5:C", "A1", ":C" (columns), plus "AX:C" to confirm
multi-letter columns remain valid. Changed-code coverage stays at 100%.
2026-08-07 20:26:12 +08:00
huwenjiao.hwj ecaa375be8 fix(sheet): validate merge range up front, fix startCell key, correct CHANGELOG
Three review P1s.

1. CHANGELOG no longer asserts a breaking Schema change this PR does not ship.
   sheet export / sheet create deliver main's mcp + interface_ref and
   schema-compatibility reports ok (0 changed fields), so the "declared as
   composite (breaking)" entry was false and is removed; the set-style entry
   drops the "breaking" framing (accepted as compatible by the reviewed
   mapping-exclusion carve-out); the duplicate ### Changed heading is merged.

2. firstNonEmptySheetSpecCell reads the start cell via
   pickStr(spec, "startCell", "start_cell"). Sheet specs are forwarded verbatim
   to table_put, whose wire fields are camelCase in this repo. The prior
   snake_case-only read meant a user passing the real startCell would have data
   written at the offset while the probe read A1 — a false "写入未生效" on a
   successful write. camelCase preferred, snake_case kept for tolerance.

3. planStyleOps now parses cell_merges range with parseA1Range, matching the
   cell_styles branch. planStyleOps is dry-run once before create_workspace_sheet
   as the up-front structural gate, so an invalid range like "not-a-range" is now
   rejected before any RPC instead of failing only at the final merge_cells call
   and leaving an unrollbackable partially-completed document. merge_cells' range
   contract is A1:B3-style, which parseA1Range covers (and it strips a Sheet1!
   prefix).

Tests: cell-merges-invalid-range added to TestSheetCreateValidatesBeforeCreating
Document (asserts calls == 0); TestFirstNonEmptySheetSpecCell covers both
startCell and start_cell. Changed-code coverage stays at 100%; targeted sheet
suites pass; CHANGELOG has a single Changed section with no false breaking claim.
2026-08-07 20:26:12 +08:00
huwenjiao.hwj dbecf23bc4 test(sheet): cover --sheets read-back error paths to reach 100% changed-code coverage
Adds coverage for the branches introduced by the per-sheet read-back:
resolveSheetIDsByName's RPC-error and unparseable-response paths, the create
--sheets path surfacing a list-fetch failure with the nodeId, and the
single-value data row in sheetSpecGrid. Changed-code coverage back to 100%.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj d73e199d97 fix(sheet): read back each sheet after --sheets table_put to catch silent data loss
The --values branch already reads back its first non-empty cell after writing,
to defend against the new-document initialization race where a write returns
success but the data does not land. The --sheets branch called table_put and
reported success with no read-back, so the same race would let the command exit
successfully while one or more sheets silently lost their initial data.

After table_put, the --sheets branch now:
- re-fetches get_all_sheets to build a name -> sheetId map (table_put reuses the
  renamed default sheet and auto-creates the rest by name), and
- for every spec that actually has content, reads back its first expected
  non-empty cell and fails if the read-back is empty or the sheet is missing.

firstNonEmptySheetSpecCell mirrors firstNonEmptyValuesCell: it treats columns as
the header row followed by data rows, honours start_cell, and returns
hasContent=false for a name-only spec so a legitimately empty sheet is not
misreported as data loss. Failures carry the nodeId and point at
sheet table-put for recovery, matching the --values branch's error shape.

Tests:
- TestSheetCreateWithSheetsVerifiesEachSheetLanded covers an empty read-back
  (errors, naming the sheet + nodeId + table-put), a sheet missing from the
  post-write listing, and a name-only sheet that must not trigger a read-back.
- TestFirstNonEmptySheetSpecCell covers header/data origins, an empty first
  header cell, a start_cell offset, and content-less specs.
- Existing --sheets tests updated for the added get_all_sheets + per-sheet
  read-back calls.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 7c9687094f refactor(sheet): declare create and export as mcp with their primary interface_ref
Reverts the interface_mode of sheet create and sheet export from composite back
to mcp with a single interface_ref, matching upstream/main and the existing
convention for multi-tool leaves (doc.create_document declares mcp +
create_document even though it also calls update_document).

Rationale:
- interface_ref is audit / traceability metadata; nothing reads it at runtime
  (verified: rebuilding with a bogus interface_ref still routes to the correct
  tool). Declaring the primary tool and treating the orchestration as an
  implementation detail is the pattern main already uses.
- sheet export was mcp + submit_export_job on main; it already orchestrated
  submit_export_job + query_export_job without declaring the poll. Adding an
  --export-format csv branch does not change that shape, so it does not warrant
  flipping to composite.
- sheet create was a genuine single-RPC command on main (create_workspace_sheet
  only). The --values / --sheets / --styles orchestration I added runs after the
  document exists; per the doc.create_document precedent it stays mcp.

This takes the sheet schema-compatibility failures from 4 to 0 without a waiver
or an admin override: the declarations now equal main's.

Also updates the wording of the seven new mapping-exclusion reasons for these
two commands (submit_export_job CSV params, create_workspace_sheet
values/sheets/styles) from "Composite ... input" to "Wrapper ... input", so the
reason text no longer collides with the interface_mode value now that both
leaves are mcp. The reasons are otherwise unchanged and still describe where
each value actually goes. range_batch_set_style keeps its "Composite" wording
because it genuinely stays interface_mode=composite.

Removes the two composite entries for these leaves from the interface
disposition contract test.

No execution path changes; targeted sheet suites, the disposition contract test,
and the schema-compat policy tests all pass; check-schema-catalog is green;
sheet-scoped schema-compatibility reports 0 failures.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 05d8c86177 policy(schema-compat): accept reviewed property clearing and mcp ref redirects
Two compatibility carve-outs, written alongside the existing interface_type
retirement allowance. Both cover declarative provenance metadata that nothing
reads at runtime: the tool a leaf invokes is decided in the CLI source, so a
stale interface_ref or property misinforms a reader rather than misrouting a
call. Neither carve-out can mask a change to the surface callers depend on.

Cleared property through a reviewed mapping exclusion. A leaf whose backing RPC
moves to a nested payload has no honest flat property to publish. The two
alternatives are worse: keep naming a field the request no longer contains, or
let assembly fall back to flag_name_inference and publish a name that appears in
no request at all. Accepted only when the old value was non-empty, the new value
is empty, and the new value resolved through reviewed_mapping_exclusion. A
redirect to a different non-empty value, a clearing by inference or native
annotation, a clearing with no recorded source, and populating a previously
empty property all stay incompatible. The exclusion table cannot be abused to
wave arbitrary clearing through: internal/cli/schema_parameter_bindings.go
verifies every parameter claiming an exclusion really does deliver an empty
property, and every entry carries a non-empty reviewed reason.

Redirected interface_ref with an unchanged CLI contract. Accepted only when
interface_mode is unchanged and stays mcp, both refs are non-empty, and no other
compatibility failure was recorded for that tool. That last condition is the
operative definition of "the contract is unchanged" — it is measured, not
asserted, so it automatically covers a lost parameter, a newly required one, a
moved type / default / format / enum, a tightened constraint, a positional or
dry_run change, and any effect / risk / confirmation / idempotency move. Any one
of them re-reports the redirect, so a surface change cannot ride along behind a
backend move. Moving to or from composite is a change in kind rather than a
redirect and stays reported; so does removing a ref outright.

Deliberately still incompatible: mcp -> composite with the ref dropped. That is
a leaf declaring it now orchestrates several RPCs, which is a semantic upgrade
rather than a like-for-like substitution, and it belongs in review.

For this branch the two carve-outs take schema-compatibility from 17 changed
fields to 4 — the twelve sheet.range_set_style property clearings and its
update_range -> set_cell_range redirect are now accepted. The remaining four are
the interface_mode plus interface_ref pairs on sheet.create_workspace_sheet and
sheet.submit_export_job.

Tests: TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion and
TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect assert the accepted
shapes plus twelve neighbouring shapes that must stay incompatible; the drift
table gains cases for clearing without an exclusion and redirecting despite one.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 115dad3b82 fix(sheet): keep JSON integer literals intact through both data channels
P1 from automated review. --values decoded with plain json.Unmarshal, so every
number became a float64 and integers beyond 2^53 were rounded before anything
was written. The read-back only checks that the probe cell is non-empty, so the
corruption was reported as a successful write. Order numbers and snowflake IDs
are ordinary spreadsheet data.

Measured before the fix:

  1234567890123456789   -> 1234567890123456768    snowflake id, tail rewritten
  12345678901234567890  -> 12345678901234567000   20-digit order number
  9007199254740993      -> 9007199254740992       2^53+1

--sheets had the same defect, which the review did not mention: its records and
data are forwarded verbatim to table_put, and the float64 round trip rewrote
1234567890123456789 as 1234567890123456800 before the request left the CLI.

Both channels now decode with json.Decoder.UseNumber, and cellToString emits a
json.Number through its String method so no float conversion happens on the way
to CSV. --styles keeps plain Unmarshal on purpose: its numbers are font sizes and
pixel dimensions, already constrained to int32 by pickNum, with no large-integer
case.

Tests assert the payload the CLI actually sends, not a recomputed decode:
- TestSheetCreatePreservesLargeIntegerLiterals checks the csv argument of
  set_range_from_csv and the marshalled table_put arguments. Both halves also
  assert the rounded forms are absent, so removing UseNumber fails the test
  instead of passing on a lucky substring match.
- TestCellToStringKeepsJSONNumberVerbatim covers large, negative, fractional and
  exponent literals, and keeps the existing float64 behaviour for other callers.

The shared scriptedToolCaller keeps only the last call, and the write is the
fourth of five, so the assertion needs an intermediate call. Rather than extend
that shared helper, this adds a callRecorder local to this file: InitDeps takes
the edition.ToolCaller interface, so embedding *scriptedToolCaller and
overriding CallTool is enough.

Changed-code coverage stays at 100.0000% (850 statements) per
check-coverage-gate.sh --changed-only.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj b8ac9810f4 fix(sheet): require unique --sheets names and cover the size error branches
Two things, both in the create-with-data path.

Unique worksheet names. parseCreateSheetSpecs accepted a --sheets payload with
repeated names, so table_put created several worksheets sharing one name. The
style tools locate a worksheet by "id or name", so --styles would then land on
whichever duplicate the server picked, and --styles runs after the document
already exists and cannot be rolled back. Duplicates are now refused before
anything is created, naming the first occurrence:

  --sheets[1].name="一月" 与 --sheets[0] 重复;工作表名必须唯一,...

Case-only differences are still accepted: the server distinguishes them and the
CLI should not tighten that. --styles needs no equivalent check because it
already requires name equality with the corresponding --sheets entry, and those
are now unique.

Coverage. The previous commit added a precise pickNum error path to the standard
and auto branches of planSizes, but no test reached it: the existing cases used
an integer size, which stops at "不能同时给 size" before the numeric check runs.
The CI coverage gate therefore reported 99.7619% on changed code
(sheet_create_with_data.go:512-514 and :521-523). Two cases now drive
type=standard and type=auto with size 28.5.

That pair is not only about the percentage. It pins the error precedence: a
fractional size must report "size=28.5 必须是整数", which points at the field
actually written wrong, rather than the generic "不能同时给 size". Swapping the
two checks would make the message misleading and now fails the tests.

Changed-code coverage measured locally at 100.0000% (845/845 statements), with
no uncovered blocks in the diff against upstream/main.

Tests:
- TestParseCreateSheetSpecsRejectsDuplicateNames covers adjacent, non-adjacent
  and {"sheets":[...]}-wrapped duplicates, plus three payloads that must pass.
- Three new cases in TestSheetCreateValidatesBeforeCreatingDocument
  (sheets-duplicate-name and the two fractional sizes), each asserting calls == 0.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj c1a90c0194 fix(sheet): reject fractional and out-of-range --styles sizes instead of truncating
P2 from automated review. pickNum ran int(n) straight on the float64 that JSON
decoding always produces, so font_size: 12.9 and row_sizes.size: 28.5 were
silently rewritten to 12 and 28 and then executed as a valid configuration. Both
the help text and the error messages state these fields must be positive
integers, and --styles is a non-atomic sequence that cannot be rolled back, so
truncation left a sheet that did not match what the caller asked for.

Measured before the fix:

  font_size=12.9  -> emitted fontSize=12
  size=28.5       -> emitted pixelSize=28
  size=1e20       -> emitted pixelSize=9223372036854775807

The overflow case was worse than reported: int(1e20) saturates to MaxInt64 and
was still sent.

pickNum now returns an error and rejects a non-integral value, a NaN or Inf, a
magnitude outside int32, and a non-numeric type. A missing key and an explicit
null still report "not provided" without an error, so optional fields keep
working. Every call site propagates the error, which means the whole --styles
payload is refused before the document is created. Confirmed through the real
CLI: font_size=12.9, col_sizes size=120.5 and size=1e20 all fail with a specific
message while font_size=12 still passes.

Tests:
- TestPickNumRejectsNonIntegralAndOutOfRange covers eight rejected inputs plus
  five accepted ones, the missing key, an explicit null, and alias-key lookup.
- Four new cases in TestSheetCreateValidatesBeforeCreatingDocument for
  cell_styles font_size, row_sizes size, col_sizes size and the overflow, each
  asserting calls == 0.
- TestPickStrAndPickNum updated: a bool value now surfaces a type error with
  ok=true rather than being reported as absent.
2026-08-07 20:25:21 +08:00
huwenjiao.hwj 0bb2b6ce98 feat(sheet): CSV export, style extensions and create-with-data
Adds four capabilities and, for the style surface, moves to the interface that
can actually express them.

Added:
- sheet create --values / --sheets / --styles: create a workbook and populate it
  in one command. --values takes a 2D array into the default sheet, --sheets
  takes typed tables across several sheets, --styles carries cell_styles /
  row_sizes / col_sizes / cell_merges. Every structure and enum is validated
  before the document is created, so an invalid config never leaves an orphan
  empty document behind.
- sheet export --export-format csv: synchronous single-sheet RFC4180 export with
  --sheet-id, --range and --value-render-option. --output writes to a file (a
  directory gets sheet-export.csv), otherwise the CSV goes to stdout while the
  truncation warning goes to stderr, keeping stdout pipeable.
- sheet update-dimension --size-type: pixel / standard (restore the default row
  height or column width) / auto (fit row height to content, ROWS only).
- sheet replace --match-formula: search and replace inside formula text.
- sheet range set-style --font-style / --font-line / --font-family /
  --border-styles-json.
- sheet range batch-set-style --ranges: stamp one style across several
  sheet-qualified ranges.

Changed (breaking Schema change, no CLI break):
- sheet range set-style moves from update_range to set_cell_range. The
  update_range style channel exposes exactly eight properties
  (backgroundColors, fontSizes, horizontalAlignments, verticalAlignments,
  fontColors, fontWeights, wordWrap, numberFormat) and has no slot for italic,
  underline/line-through, font family or borders, so the four new dimensions are
  not expressible there. interface_ref becomes set_cell_range and the twelve
  style flags stop publishing a flat property, because the value now lands in
  cells[i][j].cellStyles.* with no single top-level field to name.
- sheet range batch-set-style submits one atomic batch_update instead of looping
  update_range, so a partial failure no longer leaves half the ranges stamped.
  --continue-on-error becomes a server passthrough. Caps the fan-out at 100
  ranges and 200000 cells in aggregate.
- sheet export and sheet create declare interface_mode=composite: both route
  across several tools depending on the flags, so a single mcp ref was wrong.

Schema hygiene:
- Nineteen parameters that previously resolved through flag_name_inference into
  property names present in no request (bgColor, exportFormat, values, ...) are
  now reviewed mapping exclusions with a stated reason, so Schema omits the
  property instead of inventing one.

schema-compatibility reports 17 changed fields: 13 on sheet.range_set_style
(interface_ref plus twelve property mappings) and 2 each on
sheet.submit_export_job and sheet.create_workspace_sheet (interface_mode plus
interface_ref). No CLI flag is removed and no command path changes; the same
invocation runs on both the old and the new binary. Landing this needs a
decision on the Schema contract break.

Tests: targeted sheet suites in internal/helpers and the interface disposition
contract tests in internal/app pass; make build and gofmt clean;
check-schema-catalog, check-generated-drift, check-command-surface,
check-skill-commands and check-runtime-confirmation-truth all pass.
2026-08-07 20:25:21 +08:00
xiatian ea7c66b190 docs(sheet): align mono and multi skill references
Replace the oversized mono Sheet reference with the progressive routing layout, mirror all Sheet topic references across both bundles, and add a paired-tree drift guard.
2026-08-07 17:43:21 +08:00
前津 a3773c4384 Merge remote-tracking branch 'upstream/main' into feat/chat-message-help-id-chain 2026-08-06 18:11:41 +08:00
前津 4110330575 fix(skills): keep chat route within context budget 2026-08-06 18:00:32 +08:00
前津 2bd6b297b0 docs(chat): document post-send ID chain 2026-08-06 16:43:56 +08:00
150 changed files with 22030 additions and 6151 deletions
+28 -17
View File
@@ -1370,36 +1370,47 @@ jobs:
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
exit 1
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
. ./scripts/release/release-lib.sh
stable_ref=""
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
stable_ref="$tag"
break
done
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
printf '%s\n' \
"COMPATIBILITY_BASE_REF=$base_ref" \
"COMPATIBILITY_STABLE_REF=$stable_ref" \
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
- name: Check historical commands, help, and complete CLI compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete CLI command compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
./scripts/policy/check-command-compatibility.sh \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF"
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
run: |
make schema-compatibility \
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check skill command references
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
+35
View File
@@ -6,6 +6,41 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
## [1.0.58-beta.2] - 2026-08-10
### Added
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
### Changed
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
so callers can reuse returned task, message, and conversation IDs instead
of searching message history by content.
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
reference with the progressive routing layout, aligns all 20 Sheet topic
references across the mono and multi bundles, and adds a content-policy guard
that prevents the paired topic trees from drifting again.
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
## [1.0.58-beta.1] - 2026-08-07
### Added
+11 -3
View File
@@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI also runs
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
against both the target branch and latest stable release.
paths/flags change. CI resolves the exact merge-base, latest reachable
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
decision seam through
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57-beta.4"
version "1.0.58-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
end
def install
+25 -8
View File
@@ -24,13 +24,13 @@ help:
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@@ -103,10 +103,22 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@./scripts/policy/check-interface-baseline.sh
@base_ref="$(BASE_REF)"; \
candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$$base_ref" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
authoritative-interface-integrity:
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
@@ -121,7 +133,12 @@ reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-schema-compatibility.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
+91 -2
View File
@@ -17,18 +17,23 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
}
+447
View File
@@ -15,11 +15,16 @@ package main
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
}
}
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
return root
})
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "injected help failure") {
t.Fatalf("run(generate) stderr=%q", stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
}
}
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
dir := t.TempDir()
before := flagMigrationSnapshot(false)
after := flagMigrationSnapshot(true)
currentPath := writeSnapshot(t, dir, "current.json", after)
basePath := writeSnapshot(t, dir, "base.json", before)
stablePath := writeSnapshot(t, dir, "stable.json", before)
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
exitCode = run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", emptyApproved,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--approved-flag-migrations", approvedPath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
tests := []struct {
name string
args []string
}{
{
name: "missing stable",
args: []string{"--base", basePath},
},
{
name: "missing base",
args: []string{"--stable", stablePath},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"compare", "--current", currentPath}
args = append(args, test.args...)
args = append(args,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
)
var stdout, stderr bytes.Buffer
exitCode := run(args, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr []string
}{
{
name: "missing command",
args: nil,
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
},
{
name: "unknown command",
args: []string{"unknown"},
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
}
for _, want := range test.wantStderr {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
}
}
})
}
}
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
outputDirectory := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
missingTempRoot := filepath.Join(t.TempDir(), "missing")
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, missingTempRoot)
}
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate"}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
dir := t.TempDir()
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
missingPath := filepath.Join(dir, "missing.json")
tests := []struct {
name string
args []string
want string
}{
{
name: "current",
args: []string{"compare", "--current", missingPath, "--base", validPath},
want: "read current snapshot",
},
{
name: "main",
args: []string{"compare", "--current", validPath, "--base", missingPath},
want: "read main/development baseline snapshot",
},
{
name: "stable",
args: []string{"compare", "--current", validPath, "--stable", missingPath},
want: "read stable snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
tests := []struct {
name string
approved string
candidate string
want string
}{
{
name: "approved manifest",
approved: invalidManifest,
candidate: validManifest,
want: "read approved flag migrations",
},
{
name: "candidate manifest",
approved: validManifest,
candidate: invalidManifest,
want: "read candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
var stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
}, failingWriter{}, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
dir := t.TempDir()
missingPath := filepath.Join(dir, "missing.json")
invalidPath := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
t.Fatalf("write invalid fixture: %v", err)
}
if _, err := readSnapshot(missingPath); err == nil {
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
}
if _, err := readSnapshot(invalidPath); err == nil {
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(missingPath); err == nil {
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
err := validateHelpRendering(root, commandSnapshot("dws missing"))
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
t.Fatalf("validateHelpRendering resolve error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate(`{{index .Commands 99}}`)
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate("{{")
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template panic = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
t.Fatalf("validateHelpRendering custom stderr = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(*cobra.Command, []string) {})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("validateHelpRendering empty output = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
root := &cobra.Command{Use: "dws", Short: "root command"}
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
t.Fatalf("validateHelpRendering normal output: %v", err)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("injected write failure")
}
var _ io.Writer = failingWriter{}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
return path
}
func writeManifest(t *testing.T, dir, name, contents string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
legacy := interfacesnapshot.Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
Default: "",
NoOpt: "auto",
Required: true,
}
flags := []interfacesnapshot.Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []interfacesnapshot.Flag{
legacy,
{Name: "message-id", Type: "string", Default: "", Required: true},
}
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
},
}
}
func flagMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "STATE",
"reason": "reviewed exact migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+39 -8
View File
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
```sh
make build
make policy
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make skill-command-integrity
make cli-smoke
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
candidate SHA。
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
```sh
@@ -186,11 +204,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
a breaking change by editing a fixture. Schema additions are allowed;
historical products, tools, parameters, mappings, positional execution fields,
constraints, and safety semantics remain protected.
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
提交中的规范空清单;完整边界见下方治理文档。
精确的两阶段 flag 迁移生命周期见
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## Required GitHub repository settings
+176
View File
@@ -0,0 +1,176 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
## 唯一比较入口与信任边界
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
- `cmd/interface-snapshot` 生成和比较快照;
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
`scripts/policy/check-interface-baseline.sh` 只供
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
Smoke fixture,不参与迁移审批。
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
已审批清单固定为:
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
```json
{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
},
"after": {
"present": true,
"type": "string",
"hidden": true,
"scope": "local",
"alias_of": "message-id"
}
},
"canonical": {
"name": "message-id",
"before": { "present": false },
"after": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
}
},
"state": "pending",
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
}
]
}
```
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
- legacy 与 canonical 位于同一个可执行命令;
- canonical flag 确实存在;
- legacy 与 canonical 类型一致;
- legacy 不是指向自身,也不存在 alias chain;
- legacy 的 after 状态精确指向该记录中的 canonical flag。
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
- 删除 legacy、canonical、命令或其他 flag;
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
fail closed。
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
```sh
make interface-integrity \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
make schema-compatibility \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
```
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -101,5 +101,5 @@ skills/mono/
## 7. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
+13 -2
View File
@@ -2,7 +2,7 @@
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
## 1. 质检矩阵
@@ -11,7 +11,8 @@
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
@@ -37,6 +38,16 @@ orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
paired_files:
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
link_substitutions:
- mono: "../url-patterns.md"
multi: "../../dingtalk-shared/references/url-patterns.md"
- mono: "../intent-guide.md"
multi: "sheet-intent-guide.md"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
+1 -1
View File
@@ -4,7 +4,7 @@
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
+15 -2
View File
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
text := c.paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
for _, key := range []string{"jsonml", "markdown"} {
if content, ok := call.args[key].(string); ok {
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
return string(encoded)
}
}
}
return `{"revision":1}`
default:
return `{}`
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
{
canonical: "sheet.range_batch_set_style",
mode: "composite",
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
},
{
canonical: "sheet.create_with_data",
mode: "composite",
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
},
{
canonical: "sheet.range_read",
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
}
if required, _ := parameters["command"]["required"].(bool); required {
t.Errorf("--command required = true, want runtime custom validation")
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
if got := schemaContractString(parameters[name]["property"]); got != want {
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
}
if got := schemaContractString(parameter["required_when"]); got != "" {
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
}
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
commentParameters := schemaContractMap(comment["parameters"])
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
if _, ok := commentParameters[name]; !ok {
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
}
}
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
if got := schemaContractString(commentParameters[name]["property"]); got != want {
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
}
}
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
exportFormat := schemaContractMap(export["parameters"])["export-format"]
if required, _ := exportFormat["required"].(bool); required {
t.Fatalf("export --export-format required = true, want compatibility default")
}
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
}
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
constraints, _ := importLeaf["constraints"].(map[string]any)
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
t.Helper()
root := NewRootCommand()
+8 -19
View File
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "doc +version-list",
Aliases: map[string]string{
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"max-result": "limit",
"max-results": "limit",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
"url": "node",
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"node-id": "node",
"url": "node",
},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
CLIPath: "doc +version-revert",
+1
View File
@@ -44,6 +44,7 @@
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
@@ -592,10 +592,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.",
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.",
"sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.",
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
+12
View File
@@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
for _, alias := range flag.Aliases {
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
if registered := cmd.Flags().Lookup(alias); registered != nil {
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
flag.Name,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
+7 -1
View File
@@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
}
}
// Aliases are registered with the main kind and hidden.
for _, alias := range []string{"i-alias", "sl-alias"} {
for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} {
f := cmd.Flags().Lookup(alias)
if f == nil || !f.Hidden {
t.Fatalf("alias %q = %#v, want registered+hidden", alias, f)
}
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical {
t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical)
}
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got)
}
}
if cmd.Flags().Lookup("i-alias").Value.Type() != "int" {
t.Fatal("int alias must be registered as int")
@@ -0,0 +1,25 @@
// 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 runtimeannotate
// CLI flag alias evidence is kept in this narrow file so the base-owned
// Interface Snapshot helper can add the protocol constants to an older stable
// worktree without replacing that revision's complete runtimeannotate package.
// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a
// Schema synonym or final payload-equivalence proof.
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
+21 -6
View File
@@ -2042,6 +2042,11 @@ func newChatCommand() *cobra.Command {
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
返回值与后续操作:
发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用
dws chat message query-send-status --open-task-id <openTaskId>
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
本地图片 / 文件消息:
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
@@ -3304,7 +3309,10 @@ func newChatCommand() *cobra.Command {
chatMessageQuerySendStatusCmd := &cobra.Command{
Use: "query-send-status",
Short: "查询消息发送状态",
Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。`,
Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。
发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为
chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。`,
Example: ` dws chat message query-send-status --open-task-id <openTaskId>
# openTaskId 由 dws chat message send 返回`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3351,8 +3359,11 @@ func newChatCommand() *cobra.Command {
Short: "撤回用户发送的消息",
Long: `撤回当前用户发送的消息。需要指定会话 ID 和消息 ID。`,
Example: ` dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取`,
# 发送后撤回:send -> query-send-status -> recall
dws chat message send --group <openConversationId> --text "待撤回的内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
return err
@@ -3409,12 +3420,16 @@ func newChatCommand() *cobra.Command {
推荐使用 --text 和可选 --title,CLI 会按 Markdown 消息规则生成 content:{"title":"标题","text":"正文"}。
也可以直接使用 --content 传入完整 Markdown content JSON。--text 和 --content 二选一。`,
Example: ` dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
# 发送后编辑:send -> query-send-status -> edit
dws chat message send --group <openConversationId> --text "原始内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId> --text "更新后的内容"
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --title "标题" --text "更新后的内容"
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@all> 请查看" --at-all
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids <openDingTalkId1>
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取`,
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
return err
@@ -0,0 +1,92 @@
// 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 (
"bytes"
"strings"
"testing"
)
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
tests := []struct {
name string
command string
contains []string
notContain string
}{
{
name: "send returns task ID",
command: "send",
contains: []string{
"openTaskId",
"query-send-status --open-task-id <openTaskId>",
},
},
{
name: "query returns message and conversation IDs",
command: "query-send-status",
contains: []string{
"openTaskId",
"openMessageId",
"openConversationId",
"chat message edit",
"chat message recall",
},
},
{
name: "edit includes post-send workflow",
command: "edit",
contains: []string{
"send -> query-send-status -> edit",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
{
name: "recall includes post-send workflow",
command: "recall",
contains: []string{
"send -> query-send-status -> recall",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", test.command, "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String())
}
help := output.String()
for _, want := range test.contains {
if !strings.Contains(help, want) {
t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help)
}
}
if test.notContain != "" && strings.Contains(help, test.notContain) {
t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help)
}
})
}
}
+4 -3
View File
@@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
_ = isEmptyDataValidation(value)
_ = isEmptyHyperlink(value)
}
_ = fillIntMatrix(2, 3, 4)
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
_ = maxColLen2D([][]int{{1}, {2, 3}})
_ = checkMatrixShape(1, 2, 1, 2, "x")
@@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
{styleSpec{}, 1, 1},
}
for _, style := range styles {
_ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
}
for _, tc := range []struct {
scalar, raw string
@@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
{"", `[[""]]`, hAlignEnum},
{"", `[["left"]]`, hAlignEnum},
} {
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
}
}
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
_, _ = findFilterViewByID(views, "one")
+66 -18
View File
@@ -532,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
fileSize := fileInfo.Size()
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
deps.Out.PrintKeyValue("文档", nodeID)
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", fileName)
deps.Out.PrintKeyValue("类型", mimeType)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"dry_run": true,
"preview_kind": "plan",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"executed": false, "nodeId": nodeID, "file": filePath,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
},
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
})
}
ctx := context.Background()
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
@@ -568,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"Content-Type": mimeType,
}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return err
return apperrors.NewAPI(
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_upload_unknown"),
apperrors.WithFailureStage("upload_oss"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
}),
apperrors.WithCause(err),
)
}
// Step 3: insert block into document
@@ -625,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
return err
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_partial"),
apperrors.WithFailureStage("insert_block"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "unknown"},
},
}),
apperrors.WithCause(err),
)
}
if strings.HasPrefix(mimeType, "image/") {
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
} else {
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
}
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
},
})
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
@@ -3831,8 +3879,8 @@ CLI 内部自动完成全部流程:
},
}
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
importCmd.Flags().String("folder-id", "", "")
_ = importCmd.Flags().MarkHidden("folder-id")
+36 -30
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -17,9 +18,6 @@ const (
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
initialChunkSize = 10000
// minChunkSize is the floor; below this we report an error instead of retrying.
minChunkSize = 5000
// longContentWarningThreshold triggers a hint to use --content-file.
longContentWarningThreshold = 2048
)
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
// Single write path
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
chunksWritten = 1
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
// Timeout on single write — fallback to chunked with halved size
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
if writeErr != nil && isTimeoutError(writeErr.Error()) {
// The server may have committed the write before the client observed the
// timeout. Replaying create/append here can duplicate a document or
// content, so fail closed and require inspection before any retry.
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
}
} else {
// Chunked write path
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0])))
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
}
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
}
nodeID = extractNodeIDFromResult(resultText)
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
}
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
}
lastResponse = resultText
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
newSize := chunkSize / 2
if newSize < minChunkSize {
return nodeID, writtenCount, resultText, &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
writtenCount, chunkSize, minChunkSize),
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
nodeID, nodeID),
Operation: operation,
}
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
chunkSize = newSize
var remaining strings.Builder
remaining.WriteString(chunk)
for j := i + 1; j < len(chunks); j++ {
remaining.WriteString(chunks[j])
}
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
chunks = append(chunks[:i], newChunks...)
i-- // retry current index
continue
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
)
}
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
}
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
return nodeID, writtenCount, lastResponse, nil
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
details := map[string]any{
"status": "unknown",
"nodeId": nodeID,
"chunksWritten": written,
"chunksTotal": total,
"failedStage": stage,
}
return apperrors.NewAPI(
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
apperrors.WithOperation(operation),
apperrors.WithReason("doc_write_commit_unknown"),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
// isTimeoutError checks if an error message indicates a server-side timeout.
func isTimeoutError(msg string) bool {
lower := strings.ToLower(msg)
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
t.Fatalf("long literal single write: %v", err)
}
fallback := strings.Repeat("x", minChunkSize+100)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
t.Fatalf("single timeout fallback: %v", err)
fallback := strings.Repeat("x", 5100)
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
installScriptedCaller(t, timeoutCaller)
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
}
chunked := strings.Repeat("x", initialChunkSize+100)
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
if err != nil || written < 3 {
t.Fatalf("adaptive retry written=%d err=%v", written, err)
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
}
}
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
tests := []struct {
name string
tool string
args map[string]any
wantNode string
}{
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
}
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown commit metadata = %#v", typed)
}
})
}
}
+7 -1
View File
@@ -44,6 +44,7 @@ func newSheetCommand() *cobra.Command {
命令结构:
dws sheet create 创建钉钉表格文档
dws sheet create-with-data 创建表格文档并写入初始数据(可选样式)
dws sheet list 获取全部工作表列表
dws sheet info 获取指定工作表详情
dws sheet new 新建工作表
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
dws sheet template list 获取表格模板列表
dws sheet template search 搜索表格模板
@@ -159,6 +161,10 @@ func newSheetCommand() *cobra.Command {
},
},
})
// 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite
// 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。
createWithDataCmd := newSheetCreateWithDataCmd()
exportCsvCmd := newSheetExportCsvCmd()
importCmd := newSheetImportCmd()
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
@@ -238,7 +244,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
for _, cmd := range standaloneCmds {
+5 -5
View File
@@ -517,12 +517,12 @@ func newRangeBatchClearCmd() *cobra.Command {
}
operations := make([]any, 0, len(ranges))
for i, rng := range ranges {
idx := strings.Index(rng, "!")
if idx <= 0 || idx == len(rng)-1 {
return fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", i, rng)
// 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里
// ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
if err != nil {
return err
}
sheetName := strings.TrimSpace(rng[:idx])
rangeAddr := strings.TrimSpace(rng[idx+1:])
operations = append(operations, map[string]any{
"toolName": "clear_range",
"input": map[string]any{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5
View File
@@ -129,6 +129,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
--match-case 区分大小写(默认 false)
--match-entire-cell 完整单元格匹配(默认 false)
--use-regexp 使用正则表达式匹配(默认 false)
--match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值)
--include-hidden 包含隐藏行/列(默认 false)
--range 限定替换范围,A1 表示法(不传时在整个工作表中替换)
@@ -166,6 +167,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
toolArgs["matchEntireCell"] = matchEntireCell
useRegExp, _ := cmd.Flags().GetBool("use-regexp")
toolArgs["useRegExp"] = useRegExp
matchFormula, _ := cmd.Flags().GetBool("match-formula")
toolArgs["matchFormulaText"] = matchFormula
includeHidden, _ := cmd.Flags().GetBool("include-hidden")
toolArgs["includeHidden"] = includeHidden
return callMCPTool("replace_all", toolArgs)
@@ -198,6 +201,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
},
Parameters: []contract.ParamDecl{
{Name: "find", Property: "text"},
{Name: "match-formula", Property: "matchFormulaText"},
{Name: "node", Property: "nodeId"},
{Name: "replacement", Property: "replaceText"},
{Name: "use-regexp", Property: "useRegExp"},
@@ -212,6 +216,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
replaceCmd.Flags().Bool("match-case", false, "区分大小写 (默认 false)")
replaceCmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
replaceCmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配")
replaceCmd.Flags().Bool("match-formula", false, "在公式文本中查找替换(默认 false)")
replaceCmd.Flags().Bool("include-hidden", false, "包含隐藏行/列")
appendCmd := &cobra.Command{
+59 -9
View File
@@ -3,12 +3,21 @@ package helpers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// sizeTypeEnumHint 返回该维度支持的尺寸模式列表(行高含 auto,列宽不含,与飞书一致)。
func sizeTypeEnumHint(dimension string) string {
if dimension == "ROWS" {
return "pixel / standard / auto"
}
return "pixel / standard"
}
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
func newDimensionCmds() []*cobra.Command {
@@ -43,8 +52,11 @@ func newDimensionCmds() []*cobra.Command {
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
}
lengthStr := mustGetFlag(cmd, "length")
var length int
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
// 调整——删除方向不可回滚。
length, lengthErr := strconv.Atoi(lengthStr)
if lengthErr != nil || length < 1 {
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
}
if length > 5000 {
@@ -428,8 +440,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
}
lengthStr := mustGetFlag(cmd, "length")
var length int
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
// 调整——删除方向不可回滚。
length, lengthErr := strconv.Atoi(lengthStr)
if lengthErr != nil || length < 1 {
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
}
if length > 5000 {
@@ -490,9 +505,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
--dimension COLUMNS 时,--start-index 为列字母,如 "A" 表示从 A 列开始、"AB" 表示从 AB 列开始
支持在 --start-index 中携带工作表前缀(如 "Sheet1!3" / "Sheet1!A"),此时将忽略 --sheet-id。
--hidden 与 --pixel-size 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)。
当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
--pixel-size 单位为像素,dimension=ROWS 时表示行高、dimension=COLUMNS 时表示列宽。
尺寸模式(--size-type,对齐飞书):
pixel 默认,按 --pixel-size 指定的像素值设置
standard 恢复默认行高/列宽,无需 --pixel-size
auto 按内容自适应行高,仅 ROWS 支持(列宽不提供自适应),无需 --pixel-size
常见场景:隐藏/显示指定连续行或列、批量调整行高/列宽、在同一次调用中同时修改尺寸与显隐。`,
Example: ` # 隐藏第 3~4 行
dws sheet update-dimension --node NODE_ID --sheet-id SHEET_ID --dimension ROWS --start-index "3" --length 2 --hidden
@@ -517,8 +538,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
}
lengthStr := mustGetFlag(cmd, "length")
var length int
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
// 调整——删除方向不可回滚。
length, lengthErr := strconv.Atoi(lengthStr)
if lengthErr != nil || length < 1 {
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
}
if length > 5000 {
@@ -526,8 +550,30 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
}
hiddenChanged := cmd.Flags().Changed("hidden")
pixelSizeChanged := cmd.Flags().Changed("pixel-size")
if !hiddenChanged && !pixelSizeChanged {
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个")
sizeType, _ := cmd.Flags().GetString("size-type")
sizeType = strings.ToLower(strings.TrimSpace(sizeType))
// 尺寸模式枚举按维度区分(与飞书一致):行高有 auto,列宽只有 pixel / standard
switch {
case sizeType == "" || sizeType == "pixel" || sizeType == "standard":
case sizeType == "auto" && dimension == "ROWS":
case sizeType == "auto":
return fmt.Errorf("--size-type 对 COLUMNS 仅支持 pixel / standard(列宽不提供自适应)")
default:
return fmt.Errorf("--size-type 必须为 %s,当前值: %s", sizeTypeEnumHint(dimension), sizeType)
}
// standard/auto 由服务端决定尺寸,无需 --pixel-size
sizeTypeDrivesSize := sizeType == "standard" || sizeType == "auto"
if !hiddenChanged && !pixelSizeChanged && !sizeTypeDrivesSize {
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)")
}
// standard/auto 说"尺寸交给服务端",--pixel-size 又指定固定像素,
// 两者语义直接冲突;同时下发会得到依赖服务端实现的结果,故先拒。
if sizeTypeDrivesSize && pixelSizeChanged {
return fmt.Errorf("--size-type %s 表示尺寸由服务端决定,不能同时指定 --pixel-size;要指定固定像素请用 --size-type pixel(或省略 --size-type)", sizeType)
}
// 反向:显式声明 pixel 模式却不给像素值,同样是不完整的请求。
if sizeType == "pixel" && !pixelSizeChanged {
return fmt.Errorf("--size-type pixel 必须配合 --pixel-size 指定像素值;若本次只改显隐,请省略 --size-type")
}
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
@@ -547,6 +593,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
}
toolArgs["pixelSize"] = pixelSize
}
if sizeType != "" {
toolArgs["sizeType"] = sizeType
}
return callMCPTool("update_dimension", toolArgs)
},
}
@@ -587,6 +636,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
updateDimensionCmd.Flags().String("length", "", "更新数量,正整数 (必填),最大 5000")
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
updateDimensionCmd.Flags().String("size-type", "", "尺寸模式(对齐飞书): pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)")
groupDimensionCmd := &cobra.Command{
Use: "group-dimension",
@@ -2,6 +2,7 @@ package helpers
import (
"io"
"strings"
"testing"
"github.com/spf13/cobra"
@@ -74,6 +75,84 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T)
}
}
// --length 必须整个值都是合法正整数。改用 strconv.Atoi 之前走的是
// fmt.Sscanf("%d"),只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作
// ——delete 方向不可回滚。这是对既有命令的用户可见行为变更(已记入 CHANGELOG),
// 用测试钉住,避免哪天改回宽松解析没人发现。
func TestDimensionLengthRejectsTrailingCharacters(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
common := []string{"--node", "node", "--sheet-id", "sheet", "--dimension", "ROWS"}
for _, cmdName := range []string{"insert-dimension", "delete-dimension", "update-dimension"} {
locator := []string{"--position", "1"}
if cmdName == "update-dimension" {
locator = []string{"--start-index", "1", "--hidden"}
}
for _, bad := range []string{"2x", "3foo", "1 2", "0x10", "abc", ""} {
args := append(append(append([]string{}, common...), locator...), "--length", bad)
err := executeDimensionCoverage(t, cmdName, args...)
if err == nil {
t.Errorf("%s --length %q 未报错,畸形长度被静默接受", cmdName, bad)
continue
}
if !strings.Contains(err.Error(), "--length") {
t.Errorf("%s --length %q: err = %v, want 指明 --length", cmdName, bad, err)
}
}
}
}
// --size-type 的枚举按维度区分:行高有 auto,列宽只有 pixel / standard(对齐飞书)。
func TestUpdateDimensionSizeTypeEnumIsDimensionScoped(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
common := []string{"--node", "node", "--sheet-id", "sheet", "--start-index", "1", "--length", "2"}
for _, tc := range []struct {
name string
dimension string
sizeType string
wantErr string
}{
// standard / auto 由服务端决定尺寸,不再要求 --hidden 或 --pixel-size
{"rows-standard", "ROWS", "standard", ""},
{"rows-auto", "ROWS", "auto", ""},
{"columns-standard", "COLUMNS", "standard", ""},
{"columns-auto-rejected", "COLUMNS", "auto", "仅支持 pixel / standard"},
// 非法枚举的提示文案必须按维度给出不同的合法值列表
{"rows-bogus-hint", "ROWS", "bogus", "必须为 pixel / standard / auto"},
{"columns-bogus-hint", "COLUMNS", "bogus", "必须为 pixel / standard"},
} {
t.Run(tc.name, func(t *testing.T) {
args := append(append([]string{}, common...), "--dimension", tc.dimension, "--size-type", tc.sizeType)
err := executeDimensionCoverage(t, "update-dimension", args...)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want contains %q", err, tc.wantErr)
}
})
}
// pixel 仍需 --hidden 或 --pixel-size 之一
args := append(append([]string{}, common...), "--dimension", "ROWS", "--size-type", "pixel")
if err := executeDimensionCoverage(t, "update-dimension", args...); err == nil {
t.Fatal("size-type=pixel 且未给 --pixel-size/--hidden 时应报错")
}
}
func TestSizeTypeEnumHint(t *testing.T) {
if got := sizeTypeEnumHint("ROWS"); got != "pixel / standard / auto" {
t.Fatalf("ROWS hint = %q", got)
}
if got := sizeTypeEnumHint("COLUMNS"); got != "pixel / standard" {
t.Fatalf("COLUMNS hint = %q", got)
}
}
func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
@@ -86,3 +165,80 @@ func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T)
t.Fatalf("multi-select dropdown: %v", err)
}
}
// --size-type standard/auto 与 --pixel-size 语义冲突(一个说交给服务端、一个
// 指定固定像素),必须在发请求前拒;反向 pixel 模式也必须带像素值。
func TestUpdateDimensionRejectsSizeTypeAndPixelSizeConflict(t *testing.T) {
for _, tc := range []struct {
name string
flags [][2]string
want string
}{
{
"standard-with-pixel-size",
[][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}, {"pixel-size", "40"}},
"不能同时指定 --pixel-size",
},
{
"auto-with-pixel-size",
[][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}, {"pixel-size", "40"}},
"不能同时指定 --pixel-size",
},
{
"explicit-pixel-without-size",
[][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"hidden", "true"}},
"--size-type pixel 必须配合 --pixel-size",
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
err := runUpdateDimensionForTest(t, caller, flags)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0(冲突必须在发请求前拒)", caller.calls)
}
})
}
// 合法组合仍然放行
for _, tc := range []struct {
name string
flags [][2]string
}{
{"standard-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}}},
{"auto-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}}},
{"pixel-with-size", [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"pixel-size", "40"}}},
{"pixel-size-without-size-type", [][2]string{{"dimension", "COLUMNS"}, {"pixel-size", "120"}}},
{"hidden-only", [][2]string{{"dimension", "ROWS"}, {"hidden", "true"}}},
} {
t.Run("ok/"+tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
if err := runUpdateDimensionForTest(t, caller, flags); err != nil {
t.Fatalf("合法组合被拒: %v", err)
}
})
}
}
func runUpdateDimensionForTest(t *testing.T, caller *scriptedToolCaller, flags [][2]string) error {
t.Helper()
installScriptedCaller(t, caller)
installSheetProductArgs(t)
for _, c := range newDimensionCmds() {
if c.Name() != "update-dimension" {
continue
}
for _, kv := range flags {
if err := c.Flags().Set(kv[0], kv[1]); err != nil {
t.Fatalf("set --%s: %v", kv[0], err)
}
}
return c.RunE(c, nil)
}
t.Fatal("update-dimension not found")
return nil
}
+241
View File
@@ -0,0 +1,241 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// ============================================================================
// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV
//
// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子
// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。
// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一
// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误
// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。
//
// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按
// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。
// ============================================================================
func newSheetExportCsvCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "export-csv",
Short: "导出单个工作表为纯 CSV(同步)",
Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。
整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。
参数说明:
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
--sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表)
--range 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
--value-render-option 取值模式: formatted_value(默认) / raw_value / formula
--output 本地保存路径(可选)。可为文件路径或目录(目录时存为
sheet-export.csv);不传则把 CSV 打到 stdout
--allow-truncated 允许数据被截断时仍然导出
截断行为:
表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、
或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。
落盘保证:
写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误)
不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。
权限要求:
当前用户对目标表格具备可查看权限。`,
Example: ` # 导出第一个工作表到 stdout(可管道处理)
dws sheet export-csv --node NODE_ID
# 导出指定工作表为本地 CSV 文件
dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv
# 只导出某个范围,取原始值
dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`,
RunE: runSheetExportCsv,
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "export_csv",
CanonicalPath: "sheet.export_csv",
CLIPath: "sheet export-csv",
PrimaryCLIPath: "sheet export-csv",
},
Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"},
},
Selection: contract.SelectionSpec{
AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"},
AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"},
Examples: []string{
"dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv",
"dws sheet export-csv --node <NODE_ID> --range A1:Z1000",
},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "range", Property: "range"},
{Name: "sheet-id", Property: "sheetId"},
{Name: "value-render-option", Property: "valueRenderOption"},
},
},
})
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)")
cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)")
cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula")
cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout")
cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出")
return cmd
}
// valueRenderOptionEnum 是 --value-render-option 的合法取值。
var valueRenderOptionEnum = map[string]bool{
"formatted_value": true, "raw_value": true, "formula": true,
}
// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。
func runSheetExportCsv(cmd *cobra.Command, _ []string) error {
nodeID := mustGetFlag(cmd, "node")
if nodeID == "" {
return fmt.Errorf("flag --node is required")
}
sheetID, _ := cmd.Flags().GetString("sheet-id")
rangeAddr, _ := cmd.Flags().GetString("range")
valueRenderOption, _ := cmd.Flags().GetString("value-render-option")
valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption))
if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] {
return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption)
}
outputPath, _ := cmd.Flags().GetString("output")
allowTruncated, _ := cmd.Flags().GetBool("allow-truncated")
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "导出工作表为 CSV")
deps.Out.PrintKeyValue("节点", nodeID)
if sheetID != "" {
deps.Out.PrintKeyValue("工作表", sheetID)
}
if outputPath != "" {
deps.Out.PrintKeyValue("输出", outputPath)
}
return nil
}
ctx := context.Background()
toolArgs := map[string]any{
"nodeId": nodeID,
"annotateRowNumbers": false,
}
if sheetID != "" {
toolArgs["sheetId"] = sheetID
}
if rangeAddr != "" {
toolArgs["range"] = rangeAddr
}
if valueRenderOption != "" {
toolArgs["valueRenderOption"] = valueRenderOption
}
// CSV 正文走 stdout,进度/警告一律不能污染它。
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs)
if err != nil {
return fmt.Errorf("读取 CSV 失败: %w", err)
}
csvContent, hasMore, err := parseGetRangeAsCsvResult(text)
if err != nil {
return err
}
// 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0,
// 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标
// 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果
// 必须显式加 --allow-truncated。
if hasMore && !allowTruncated {
return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+
"或确认可接受不完整数据后加 --allow-truncated",
firstNonEmpty(outputPath, "stdout"))
}
if hasMore {
deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" +
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。")
}
if outputPath == "" {
deps.Out.PrintRaw(csvContent)
return nil
}
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
outputPath = filepath.Join(outputPath, "sheet-export.csv")
}
// 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、
// 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename,
// 失败时原文件保持不变。
// AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录
// 不存在即报错」语义,避免把拼错的路径悄悄建成目录。
if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil {
return fmt.Errorf("写入 CSV 文件失败: %w", statErr)
}
if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil {
return fmt.Errorf("写入 CSV 文件失败: %w", err)
}
if hasMore {
deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath))
return nil
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
}
// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。
//
// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进
// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。
// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。
func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) {
var data map[string]any
if e := json.Unmarshal([]byte(text), &data); e != nil {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e)
}
if raw, wrapped := data["result"]; wrapped {
result, ok := raw.(map[string]any)
if !ok {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text)
}
data = result
}
raw, exists := data["csv"]
if !exists {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text)
}
csvVal, ok := raw.(string)
if !ok {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text)
}
csv = csvVal
if hm, ok := data["hasMore"].(bool); ok {
hasMore = hm
}
return csv, hasMore, nil
}
+353
View File
@@ -0,0 +1,353 @@
// 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.
package helpers
import (
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"github.com/spf13/pflag"
)
func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
oldArgs := os.Args
os.Args = []string{"dws", "sheet"}
t.Cleanup(func() { os.Args = oldArgs })
cmd := newSheetExportCsvCmd()
for index := 0; index < len(args); index += 2 {
if err := cmd.Flags().Set(args[index], args[index+1]); err != nil {
t.Fatalf("set %s: %v", args[index], err)
}
}
return runSheetExportCsv(cmd, nil)
}
// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里
// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报
// 成功。这里钉住两条命令的 flag 面互不渗透。
func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) {
xlsxOnly := []string{}
newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) })
sort.Strings(xlsxOnly)
if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) {
t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want)
}
csvFlags := map[string]bool{}
newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true })
for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} {
if !csvFlags[name] {
t.Errorf("sheet export-csv 缺少 flag --%s", name)
}
}
// 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。
if csvFlags["export-format"] {
t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)")
}
}
func TestSheetExportCsvRequiresNode(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeSheetExportCsv(t, caller)
if err == nil || !strings.Contains(err.Error(), "--node is required") {
t.Fatalf("err = %v, want required node", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0", caller.calls)
}
}
// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。
func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty")
if err == nil ||
!strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") {
t.Fatalf("err = %v, want enum rejection", err)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls)
}
for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} {
ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil {
t.Fatalf("value-render-option=%q 应被接受: %v", option, err)
}
}
}
func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
if err := executeSheetExportCsv(t, caller,
"node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil {
t.Fatalf("csv dry run: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run 不应发请求, calls = %d", caller.calls)
}
}
func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"csv":"a,b\n","hasMore":true}`},
}}
// 截断默认失败,所以要显式放行才能走到落盘/输出。
if err := executeSheetExportCsv(t, caller,
"node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1",
"value-render-option", "raw_value", "allow-truncated", "true"); err != nil {
t.Fatalf("csv export: %v", err)
}
for _, kv := range [][2]string{
{"range", "A1:Z1000"},
{"sheetId", "SHEET_1"},
{"valueRenderOption", "raw_value"},
} {
if got := caller.args[kv[0]]; got != kv[1] {
t.Errorf("%s 未透传: %#v", kv[0], got)
}
}
// csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。
if caller.args["annotateRowNumbers"] != false {
t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"])
}
}
// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把
// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。
func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.csv")
if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"csv":"a,b\n","hasMore":true}`},
}}
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
if err == nil {
t.Fatal("截断时未放行应报错,而非静默成功")
}
for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误信息缺少 %q: %v", want, err)
}
}
// 关键:既有文件必须保持原样,不能被截断数据覆盖。
body, readErr := os.ReadFile(out)
if readErr != nil {
t.Fatalf("read output: %v", readErr)
}
if string(body) != "PREEXISTING\n" {
t.Fatalf("目标文件被截断数据覆盖了: %q", string(body))
}
}
// 显式放行后允许落盘,但成功信息必须点明数据不完整。
func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.csv")
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"csv":"a,b\n","hasMore":true}`},
}}
if err := executeSheetExportCsv(t, caller,
"node", "NODE", "output", out, "allow-truncated", "true"); err != nil {
t.Fatalf("allow-truncated 应允许导出: %v", err)
}
body, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read output: %v", err)
}
if string(body) != "a,b\n" {
t.Fatalf("csv 内容 = %q", string(body))
}
}
// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。
func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out.csv")
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}}
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil {
t.Fatalf("完整读取不应要求放行: %v", err)
}
body, err := os.ReadFile(out)
if err != nil || string(body) != "a,b\n" {
t.Fatalf("csv 内容 = %q, err = %v", string(body), err)
}
}
func TestSheetExportCsvWritesIntoDirectory(t *testing.T) {
dir := t.TempDir()
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil {
t.Fatalf("csv export: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv"))
if err != nil {
t.Fatalf("read exported csv: %v", err)
}
if string(body) != "a,b\n" {
t.Fatalf("csv 内容 = %q", string(body))
}
}
// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、
// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。
func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) {
out := filepath.Join(t.TempDir(), "existing.csv")
const original = "原有重要数据\n1,2,3\n"
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
// 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。
previousRename := atomicRename
t.Cleanup(func() { atomicRename = previousRename })
atomicRename = func(string, string) error { return errors.New("no space left on device") }
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
t.Fatalf("err = %v, want write failure", err)
}
body, readErr := os.ReadFile(out)
if readErr != nil {
t.Fatalf("read output: %v", readErr)
}
if string(body) != original {
t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body))
}
// 临时文件不能留在目录里冒充导出产物。
entries, readDirErr := os.ReadDir(filepath.Dir(out))
if readDirErr != nil {
t.Fatalf("read dir: %v", readDirErr)
}
if len(entries) != 1 || entries[0].Name() != filepath.Base(out) {
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Fatalf("目录残留临时文件: %v", names)
}
}
func TestSheetExportCsvReportsWriteFailure(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv")
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing)
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
t.Fatalf("err = %v, want write failure", err)
}
}
func TestSheetExportCsvReportsReadFailure(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}}
err := executeSheetExportCsv(t, caller, "node", "NODE")
if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") {
t.Fatalf("err = %v, want read failure", err)
}
}
func TestParseGetRangeAsCsvResult(t *testing.T) {
// 兼容裸响应与 result 包装两种形状
for _, body := range []string{
`{"csv":"a,b\n","hasMore":true}`,
`{"result":{"csv":"a,b\n","hasMore":true}}`,
} {
csv, hasMore, err := parseGetRangeAsCsvResult(body)
if err != nil || csv != "a,b\n" || !hasMore {
t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err)
}
}
// 字段存在但为空串是合法的(真的空区域)
csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`)
if err != nil || csv != "" || hasMore {
t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err)
}
}
// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用
// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。
func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) {
for _, tc := range []struct{ body, want string }{
{"not json", "解析 get_range_as_csv 响应失败"},
{`{"message":"something odd"}`, "缺少 csv 字段"},
{`{"csv":123}`, "csv 字段不是字符串"},
{`{"csv":null}`, "csv 字段不是字符串"},
{`{"result":"not-an-object"}`, "result 不是对象"},
{`{"result":{"message":"odd"}}`, "缺少 csv 字段"},
} {
if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil ||
!strings.Contains(err.Error(), tc.want) {
t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want)
}
}
}
// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。
func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) {
out := filepath.Join(t.TempDir(), "existing.csv")
const original = "原有重要数据\n1,2,3\n"
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}}
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") {
t.Fatalf("err = %v, want missing csv field", err)
}
body, readErr := os.ReadFile(out)
if readErr != nil {
t.Fatal(readErr)
}
if string(body) != original {
t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body))
}
}
// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会
// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。
func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}}
installScriptedCaller(t, caller)
installSheetProductArgs(t)
var stdout, stderr bytes.Buffer
deps.Out.w, deps.Out.errW = &stdout, &stderr
cmd := newSheetExportCsvCmd()
// 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。
for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} {
if err := cmd.Flags().Set(kv[0], kv[1]); err != nil {
t.Fatalf("set --%s: %v", kv[0], err)
}
}
if err := runSheetExportCsv(cmd, nil); err != nil {
t.Fatalf("csv export: %v", err)
}
if got := stdout.String(); got != "a,b\nc,d\n" {
t.Fatalf("stdout = %q, want 纯 CSV 正文", got)
}
if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") {
t.Fatalf("stdout 混入了警告文本: %q", stdout.String())
}
if !strings.Contains(stderr.String(), "CSV 已被截断") {
t.Fatalf("stderr = %q, want 截断提示", stderr.String())
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
// 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.
package helpers
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。
//
// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的
// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。
// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。
const budgetTestRange = "A1:Z1000"
func budgetTestRanges(n int) []string {
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange))
}
return out
}
func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command {
t.Helper()
cmd := newRangeBatchSetStyleCmd()
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
t.Fatalf("set --bg-color: %v", err)
}
return cmd
}
func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) {
// 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。
within, err := json.Marshal(budgetTestRanges(7))
if err != nil {
t.Fatal(err)
}
ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within))
if err != nil {
t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err)
}
if len(ops) != 7 {
t.Fatalf("operations = %d, want 7", len(ops))
}
// 第 8 个把累计推到 208000,越过 200000 上限。
over, err := json.Marshal(budgetTestRanges(8))
if err != nil {
t.Fatal(err)
}
_, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over))
if err == nil {
t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝")
}
for _, want := range []string{"--ranges[7]", "208000", "200000"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want contains %q", err, want)
}
}
}
func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) {
writeBatch := func(t *testing.T, items []map[string]any) string {
t.Helper()
data, err := json.Marshal(items)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "styles.json")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
return path
}
item := func(i int) map[string]any {
return map[string]any{
"sheetId": fmt.Sprintf("Sheet%d", i+1),
"range": budgetTestRange,
"fontWeight": "bold",
}
}
// 累计预算:8 条 × 26000 = 208000 > 200000。
overBudget := make([]map[string]any, 0, 8)
for i := 0; i < 8; i++ {
overBudget = append(overBudget, item(i))
}
_, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget))
if err == nil {
t.Fatal("--batch 累计 208000 格必须被拒绝")
}
for _, want := range []string{"第 8/8 条", "208000", "200000"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want contains %q", err, want)
}
}
// 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。
tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1)
for i := 0; i < maxBatchStyleRanges+1; i++ {
tooMany = append(tooMany, map[string]any{
"sheetId": "Sheet1", "range": "A1", "fontWeight": "bold",
})
}
_, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany))
if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") {
t.Fatalf("error = %v, want --batch item cap", err)
}
// 预算内的正常配置仍然照常展开。
ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)}))
if err != nil {
t.Fatalf("2 条应在预算内: %v", err)
}
if len(ops) != 2 {
t.Fatalf("operations = %d, want 2", len(ops))
}
}
func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) {
var total int64
if err := addBatchStyleCells(&total, 1000, 26); err != nil {
t.Fatalf("first range: %v", err)
}
if total != 26000 {
t.Fatalf("total = %d, want 26000", total)
}
// 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。
var single int64
if err := addBatchStyleCells(&single, 1000, 1000); err == nil {
t.Fatal("1000x1000=1000000 必须超出累计预算")
}
}
// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致:
// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。
func TestStyleFlagNamesMatchBoundFlags(t *testing.T) {
probe := &cobra.Command{Use: "probe"}
bindStyleFlags(probe)
bound := map[string]bool{}
probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true })
listed := map[string]bool{}
for _, name := range styleFlagNames {
if listed[name] {
t.Fatalf("styleFlagNames 重复登记 %q", name)
}
listed[name] = true
if !bound[name] {
t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name)
}
}
for name := range bound {
if !listed[name] {
t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name)
}
}
}
func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchPath := filepath.Join(t.TempDir(), "styles.json")
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
cmd := newRangeBatchSetStyleCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"})
err := cmd.Execute()
if err == nil {
t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略")
}
for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want contains %q", err, want)
}
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
}
// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。
func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) {
tooMany := make([]string, 0, maxBatchStyleRanges+1)
for i := 0; i <= maxBatchStyleRanges; i++ {
tooMany = append(tooMany, `"Sheet1!A1"`)
}
for _, tc := range []struct {
name string
ranges string
style bool
want string
}{
{name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"},
{name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"},
{
name: "over-range-cap",
ranges: "[" + strings.Join(tooMany, ",") + "]",
style: true,
want: "--ranges 最多 100 项",
},
{
name: "missing-sheet-prefix",
ranges: `["A1:B2"]`,
style: true,
want: "必须包含工作表前缀",
},
{
name: "blank-sheet-prefix",
ranges: `[" !A1:B2"]`,
style: true,
want: "必须包含工作表前缀",
},
{
name: "blank-range-after-prefix",
ranges: `["Sheet1! "]`,
style: true,
want: "必须包含工作表前缀",
},
{
name: "bad-range-address",
ranges: `["Sheet1!zz"]`,
style: true,
want: "解析失败",
},
{
name: "no-style-flag",
ranges: `["Sheet1!A1:B2"]`,
style: false,
want: "至少需要指定一个样式参数",
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newRangeBatchSetStyleCmd()
if tc.style {
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
t.Fatalf("set --bg-color: %v", err)
}
}
_, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
})
}
}
// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。
//
// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着
// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是
// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理,
// 它此前只挡 == ""。三条路径都必须在发请求之前失败。
func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchDir := t.TempDir()
writeBatch := func(t *testing.T, name, body string) string {
t.Helper()
p := filepath.Join(batchDir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
return p
}
for _, tc := range []struct {
name string
cmd func() *cobra.Command
args func(t *testing.T) []string
want string
}{
{
name: "set-style-ranges-blank-sheet",
cmd: newRangeBatchSetStyleCmd,
args: func(*testing.T) []string {
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"}
},
want: "必须包含工作表前缀",
},
{
name: "set-style-batch-blank-sheet-id",
cmd: newRangeBatchSetStyleCmd,
args: func(t *testing.T) []string {
p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`)
return []string{"--node", "NODE_ID", "--batch", p}
},
want: "缺少 sheetId 或 range",
},
{
name: "set-style-batch-blank-range",
cmd: newRangeBatchSetStyleCmd,
args: func(t *testing.T) []string {
p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`)
return []string{"--node", "NODE_ID", "--batch", p}
},
want: "缺少 sheetId 或 range",
},
{
name: "batch-clear-ranges-blank-sheet",
cmd: rangeBatchClearCoverageCommand,
args: func(*testing.T) []string {
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`}
},
want: "必须包含工作表前缀",
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
cmd := tc.cmd()
cmd.SetArgs(tc.args(t))
cmd.SilenceUsage, cmd.SilenceErrors = true, true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
})
}
}
// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾
// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。
func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) {
p := filepath.Join(t.TempDir(), "padded.json")
if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
ops, err := buildBatchStyleOpsFromFile(p)
if err != nil {
t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err)
}
if len(ops) != 1 {
t.Fatalf("operations = %d, want 1", len(ops))
}
op, _ := ops[0].(map[string]any)
input, _ := op["input"].(map[string]any)
if input["sheetId"] != " Sheet1 " {
t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ")
}
if input["rangeAddress"] != " A1:B2 " {
t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ")
}
}
@@ -12,17 +12,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} {
_, _, _ = parseA1Range(raw)
}
_ = fillStringMatrix(2, 2, "x")
_ = fillIntMatrix(2, 2, 1)
for _, spec := range []*styleSpec{
{FontSize: 1, FontSizesJSON: `[[1]]`},
{FontSize: -1},
{FontSize: 12},
{FontSizesJSON: `{`},
{FontSizesJSON: `[[1,2]]`},
{FontSizesJSON: `[[1]]`},
for _, tc := range []struct {
scalar int
jsonStr string
}{
{1, `[[1]]`}, {-1, ""}, {12, ""}, {0, `{`}, {0, `[[1,2]]`}, {0, `[[1]]`}, {0, ""},
} {
_ = applyFontSize(spec, 1, 1, map[string]any{})
if get, err := intGrid(tc.scalar, tc.jsonStr, "font-size", 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
_, _ = get(9, 9)
}
}
for _, tc := range []struct {
scalar, raw string
@@ -32,7 +31,17 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
{"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil},
{"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum},
} {
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
_, _ = get(0, 0)
_, _ = get(9, 9)
}
}
for _, raw := range []string{"", `{`, `{}`, `{"nope":{"style":"solid"}}`, `{"top":{}}`,
`{"top":{"style":"bogus"}}`, `{"top":{"style":"solid"}}`, `{"top":{"style":"solid","color":"#000"}}`} {
_, _ = parseBorderStyles(raw)
}
for _, raw := range []string{"Sheet1!A1:B2", "A1:B2", "Sheet1!", "!A1"} {
_, _, _ = splitSheetPrefixedRange(raw, 0)
}
_ = maxColLenStr([][]string{{}, {"a", "b"}})
_ = maxColLen2D([][]int{{}, {1, 2}})
@@ -46,8 +55,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
{styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31},
{styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1},
{styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1},
{styleSpec{FontStyle: "bogus"}, 1, 1}, {styleSpec{FontStyle: "italic"}, 1, 1},
{styleSpec{FontLine: "bogus"}, 1, 1}, {styleSpec{FontLine: "underline"}, 1, 1},
{styleSpec{FontLine: "line-through"}, 1, 1}, {styleSpec{FontLine: "none"}, 1, 1},
{styleSpec{FontFamily: "Arial"}, 1, 1},
{styleSpec{BorderStylesJSON: `{"top":{"style":"solid"}}`}, 1, 1},
{styleSpec{BorderStylesJSON: `{`}, 1, 1},
{styleSpec{BgColor: "#FFF", FontSize: 12, HAlign: "center", VAlign: "top",
FontColor: "#000", FontWeight: "bold"}, 2, 2},
} {
_ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{})
_, _ = buildStyleCells(&tc.spec, tc.rows, tc.cols)
}
}
+114 -7
View File
@@ -62,16 +62,22 @@ func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testin
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
}
results, _ := payload["results"].([]any)
if len(results) != 1 {
t.Fatalf("JSON dry-run results = %#v", payload["results"])
// batch-set-style 现在组装为一次 batch_update 原子提交,
// dry-run 因此是单条标准记录,而不是本地循环产生的 results 数组。
if payload["tool"] != "batch_update" {
t.Fatalf("JSON dry-run tool = %#v, want batch_update", payload["tool"])
}
entry, _ := results[0].(map[string]any)
if entry["dryRun"] != true || entry["tool"] != "update_range" {
t.Fatalf("JSON dry-run result = %#v", entry)
args, _ := payload["arguments"].(map[string]any)
ops, _ := args["operations"].([]any)
if len(ops) != 1 {
t.Fatalf("JSON dry-run operations = %#v, want 1 item", args["operations"])
}
op, _ := ops[0].(map[string]any)
if op["toolName"] != "set_cell_range" {
t.Fatalf("JSON dry-run operation toolName = %#v", op["toolName"])
}
} else {
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
for _, want := range []string{"Tool:", "batch_update", "set_cell_range", "Arguments:"} {
if !strings.Contains(preview, want) {
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
}
@@ -104,3 +110,104 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
}
// 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默
// 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键
// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用
// parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。
func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) {
for _, tc := range []struct {
name string
json string
want string
}{
{"near-miss-color-key", `{"top":{"style":"solid","colour":"#f00"}}`, `未知字段 "colour"`},
{"unknown-key", `{"top":{"style":"solid","width":2}}`, `未知字段 "width"`},
{"camel-case-alias-hinted", `{"top":{"style":"solid","Color":"#f00"}}`, `应为 "color"`},
{"color-not-string", `{"top":{"style":"solid","color":123}}`, "color 必须是字符串,实际是 float64"},
{"color-empty-string", `{"top":{"style":"solid","color":""}}`, "color 不能为空字符串"},
{"style-not-string", `{"top":{"style":123}}`, "style 必须是字符串,实际是 float64"},
{"style-empty-string", `{"top":{"style":""}}`, "缺少 style"},
{"style-missing", `{"top":{"color":"#f00"}}`, "缺少 style"},
{"style-null", `{"top":{"style":null}}`, "缺少 style"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := parseBorderStyles(tc.json)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want contains %q", err, tc.want)
}
if err != nil && !strings.Contains(err.Error(), "--border-styles-json.top") {
t.Fatalf("err = %v, want the edge name in the message", err)
}
})
}
// 合法形态不能被误拒:只给 style、给 style+color、显式 null color(等同省略)
for _, tc := range []struct {
name string
json string
wantColor any
}{
{"style-only", `{"top":{"style":"solid"}}`, nil},
{"style-and-color", `{"top":{"style":"solid","color":"#FF0000"}}`, "#FF0000"},
{"explicit-null-color", `{"top":{"style":"solid","color":null}}`, nil},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := parseBorderStyles(tc.json)
if err != nil {
t.Fatalf("parseBorderStyles(%s) = %v, want accepted", tc.json, err)
}
edge, _ := out["top"].(map[string]any)
if edge["style"] != "solid" {
t.Fatalf("style = %#v, want solid", edge["style"])
}
if edge["color"] != tc.wantColor {
t.Fatalf("color = %#v, want %#v", edge["color"], tc.wantColor)
}
})
}
}
// 单区域与 batch 两条路径:非法边框配置必须在组装/下发之前失败。
func TestBorderStyleRejectionHappensBeforeAnyRemoteCall(t *testing.T) {
const badBorder = `{"top":{"style":"solid","colour":"#f00"}}`
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
batchPath := filepath.Join(t.TempDir(), "styles.json")
fixture := `[{"sheetId":"Sheet1","range":"A1:B2","borderStylesJson":"{\"top\":{\"style\":\"solid\",\"colour\":\"#f00\"}}"}]`
if err := os.WriteFile(batchPath, []byte(fixture), 0o600); err != nil {
t.Fatalf("write batch fixture: %v", err)
}
for _, tc := range []struct {
name string
args []string
}{
{"single-range", []string{"--node", "NODE_ID", "--sheet-id", "S", "--range", "A1:B2", "--border-styles-json", badBorder}},
{"batch-ranges", []string{"--node", "NODE_ID", "--ranges", `["Sheet1!A1:B2"]`, "--border-styles-json", badBorder}},
{"batch-file", []string{"--node", "NODE_ID", "--batch", batchPath}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &sheetStyleDryRunCaller{format: "json"}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
cmd := newRangeSetStyleCmd()
if tc.name != "single-range" {
cmd = newRangeBatchSetStyleCmd()
}
cmd.SetArgs(tc.args)
cmd.SilenceUsage, cmd.SilenceErrors = true, true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), `未知字段 "colour"`) {
t.Fatalf("err = %v, want the unknown border field rejected", err)
}
if caller.calls != 0 {
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
}
})
}
}
@@ -12,7 +12,7 @@ import (
)
func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T) {
if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil {
if _, err := buildStyleCells(&styleSpec{FontColorsJSON: "{"}, 1, 1); err == nil {
t.Fatal("invalid font colors JSON returned nil")
}
+27 -7
View File
@@ -73,6 +73,10 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
// become required; an existing path may not gain a new required flag,
// - new commands and flags are allowed.
//
// The single exception to the type rule is an individually reviewed migration
// listed in reviewedFlagTypeChanges, and only when nothing else about the flag
// changed. See reviewed.go.
//
// Comparing the effective local + inherited set is intentional. It catches a
// persistent flag whose scope is accidentally narrowed to its declaring
// command, while allowing a local flag to move to an ancestor without breaking
@@ -183,13 +187,20 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
continue
}
if oldFlag.Type != newFlag.Type {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_type_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Type,
After: newFlag.Type,
})
// Resolve the exemption against the canonical path, not acceptedPath.
// An aliased command reaches this function once per accepted spelling,
// so keying on acceptedPath would let every alias spelling re-report a
// reviewed migration.
if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) ||
flagContractOtherwiseChanged(oldFlag, newFlag) {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_type_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Type,
After: newFlag.Type,
})
}
}
if !oldFlag.Required && newFlag.Required {
result.Blocking = append(result.Blocking, Change{
@@ -218,6 +229,15 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
After: newFlag.NoOpt,
})
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_alias_target_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.AliasOf,
After: newFlag.AliasOf,
})
}
if !oldFlag.Hidden && newFlag.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_hidden",
+630 -1
View File
@@ -13,7 +13,11 @@
package interfacesnapshot
import "testing"
import (
"os"
"regexp"
"testing"
)
func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) {
base := testSnapshot(
@@ -247,6 +251,40 @@ func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testin
}
}
func TestCrossPlatformCoverageCompareBlocksFlagAliasTargetRegression(t *testing.T) {
baseLegacy := testFlag("legacy-id", "string", false)
baseLegacy.AliasOf = "message-id"
base := testSnapshot(
testCommand("dws"),
testCommand("dws send", baseLegacy, testFlag("message-id", "string", false)),
)
for _, target := range []string{"", "other-id"} {
t.Run("target_"+target, func(t *testing.T) {
currentLegacy := baseLegacy
currentLegacy.AliasOf = target
current := testSnapshot(
testCommand("dws"),
testCommand(
"dws send",
currentLegacy,
testFlag("message-id", "string", false),
testFlag("other-id", "string", false),
),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasFlagChange(
comparison.Blocking,
"flag_alias_target_changed",
"dws send",
"legacy-id",
) {
t.Fatalf("alias target regression was not blocked: %#v", comparison)
}
})
}
}
func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
base := testSnapshot(
testCommand("dws", testFlag("profile", "string", false)),
@@ -310,6 +348,511 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAuthorizesOnlyTheExactTransition(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
authority := testFlagMigrationManifest(FlagMigrationPending)
candidate := testFlagMigrationManifest(FlagMigrationConsumed)
report, err := CompareAllWithFlagMigrations(
testFlagMigrationSnapshot(true, true),
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
authority,
candidate,
)
if err != nil {
t.Fatalf("exact base-owned migration was rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("exact migration remained incompatible: %#v", report.Comparisons)
}
for _, comparison := range report.Comparisons {
if len(comparison.Blocking) != 0 {
t.Fatalf("reference %q retained blocking changes: %#v", comparison.Reference, comparison.Blocking)
}
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsComposesWithReviewedTypeChange(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
before := testFlagMigrationSnapshot(false, true)
before.Commands = append(before.Commands, testCommand("dws search", testFlag("query", "string", false)))
after := testFlagMigrationSnapshot(true, true)
after.Commands = append(after.Commands, testCommand("dws search", testFlag("query", "int", false)))
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
testFlagMigrationManifest(FlagMigrationPending),
testFlagMigrationManifest(FlagMigrationConsumed),
)
if err != nil {
t.Fatalf("composed reviewed migrations were rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("composed reviewed migrations remained incompatible: %#v", report.Comparisons)
}
}
// registerReviewedFixture puts a fixture command in the reviewed table for the
// duration of one test, so the behaviour tests exercise the real lookup instead
// of depending on whichever production entries happen to exist.
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
t.Helper()
if _, exists := reviewedFlagTypeChanges[change]; exists {
t.Fatalf("fixture %+v collides with a production entry", change)
}
reviewedFlagTypeChanges[change] = struct{}{}
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
}
func TestCrossPlatformCoverageCompareAcceptsReviewedFlagTypeChange(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "int", false)),
)
// Without an entry the migration is still a blocking change.
if comparison := Compare(current, base, "base"); !hasChangeKind(comparison.Blocking, "flag_type_changed") {
t.Fatalf("an unreviewed type change should block: %#v", comparison.Blocking)
}
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); !comparison.Compatible {
t.Fatalf("a reviewed string->int migration should pass: %#v", comparison.Blocking)
}
}
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
// The reverse migration shares command and flag but not direction, so the
// string->int entry must not admit it.
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "int", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("int->string must not be admitted by a string->int entry")
}
}
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "int", false)),
)
// Same flag and direction, different command.
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws status", Flag: "query", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("an entry for another command must not admit this migration")
}
// Same command and direction, different flag.
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "elsewhere", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("an entry for another flag must not admit this migration")
}
}
// A reviewed migration is only accepted when the rest of the flag's contract
// held still. Each case bundles one unrelated regression with the reviewed type
// change and requires the type failure to reappear, which also anchors
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
// drifts out of sync fails here instead of silently widening the exemption.
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
tests := []struct {
name string
base Flag
next Flag
kind string
}{
{
name: "became required",
base: Flag{Name: "query", Type: "string"},
next: Flag{Name: "query", Type: "int", Required: true},
kind: "flag_became_required",
},
{
name: "lost shorthand",
base: Flag{Name: "query", Type: "string", Shorthand: "q"},
next: Flag{Name: "query", Type: "int"},
kind: "flag_shorthand_changed",
},
{
name: "changed no-opt",
base: Flag{Name: "query", Type: "string", NoOpt: "all"},
next: Flag{Name: "query", Type: "int", NoOpt: "1"},
kind: "flag_no_opt_changed",
},
{
name: "became hidden",
base: Flag{Name: "query", Type: "string"},
next: Flag{Name: "query", Type: "int", Hidden: true},
kind: "flag_became_hidden",
},
{
name: "changed alias target",
base: Flag{Name: "query", Type: "string", AliasOf: "old-query"},
next: Flag{Name: "query", Type: "int", AliasOf: "new-query"},
kind: "flag_alias_target_changed",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
base := testSnapshot(testCommand("dws"), testCommand("dws search", test.base))
current := testSnapshot(testCommand("dws"), testCommand("dws search", test.next))
comparison := Compare(current, base, "base")
if !hasChangeKind(comparison.Blocking, test.kind) {
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
}
if !hasChangeKind(comparison.Blocking, "flag_type_changed") {
t.Fatalf("a bundled regression must re-report the type change: %#v", comparison.Blocking)
}
})
}
}
// The exemption is resolved against the canonical Command.Path, never the
// alias-expanded accepted path. An aliased command enters compareEffectiveFlags
// once per accepted spelling, so keying on the accepted path would let every
// alias spelling re-report a reviewed migration.
func TestCrossPlatformCoverageCompareResolvesReviewedFlagTypeChangeByCanonicalPath(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "int", false)),
)
// The canonical path admits the migration for every accepted spelling.
canonical := flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}
registerReviewedFixture(t, canonical)
if comparison := Compare(current, base, "base"); !comparison.Compatible {
t.Fatalf("canonical path should admit the migration on every alias: %#v", comparison.Blocking)
}
delete(reviewedFlagTypeChanges, canonical)
// An entry spelled with the alias path matches nothing, because the lookup
// never sees the accepted spelling.
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws find", Flag: "query", From: "string", To: "int"})
if comparison := Compare(current, base, "base"); comparison.Compatible {
t.Fatal("an alias-spelled entry must not admit the migration")
}
}
// A string -> int migration always changes the recorded default ("" to "0").
// Defaults are not part of what this gate enforces, and folding them into
// flagContractOtherwiseChanged would make every table entry dead on arrival.
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeToleratesDefaultDrift(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", Flag{Name: "query", Type: "string", Default: ""}),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", Flag{Name: "query", Type: "int", Default: "0"}),
)
if comparison := Compare(current, base, "base"); !comparison.Compatible {
t.Fatalf("default drift must not defeat a reviewed migration: %#v", comparison.Blocking)
}
}
// reviewedEntryPattern matches one table entry as gofmt writes it on a single
// line. It is used to read the sibling copy of the table out of the policy
// helper, which cannot be imported: that directory is copied into a worktree at
// a historical revision and built there, so it must not depend on this package.
var reviewedEntryPattern = regexp.MustCompile(
`\{CommandPath:\s*"([^"]*)",\s*Flag:\s*"([^"]*)",\s*From:\s*"([^"]*)",\s*To:\s*"([^"]*)"\}`)
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
// The modern authority and legacy smoke helper keep mirrored tables so local
// smoke checks cannot disagree with the authoritative comparison.
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
source, err := os.ReadFile(interfaceBaselineReviewedPath)
if err != nil {
t.Fatalf("读取 %s 失败: %v", interfaceBaselineReviewedPath, err)
}
sibling := make(map[flagTypeChange]struct{})
for _, match := range reviewedEntryPattern.FindAllStringSubmatch(string(source), -1) {
sibling[flagTypeChange{CommandPath: match[1], Flag: match[2], From: match[3], To: match[4]}] = struct{}{}
}
// Without this the whole guard degrades into comparing two empty sets the
// moment the pattern stops matching, which is exactly how a duplicated
// allowlist goes stale unnoticed.
if len(sibling) == 0 {
t.Fatalf("未能从 %s 提取到任何条目:正则或表的书写形态已漂移,这条守卫已失效", interfaceBaselineReviewedPath)
}
for change := range reviewedFlagTypeChanges {
if _, ok := sibling[change]; !ok {
t.Errorf("%+v 只存在于 interfacesnapshot,缺在 %s", change, interfaceBaselineReviewedPath)
}
}
for change := range sibling {
if _, ok := reviewedFlagTypeChanges[change]; !ok {
t.Errorf("%+v 只存在于 %s,缺在 interfacesnapshot", change, interfaceBaselineReviewedPath)
}
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRejectsInvalidLifecycleTransitions(t *testing.T) {
empty := testEmptyFlagMigrationManifest()
pending := testFlagMigrationManifest(FlagMigrationPending)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
before := testFlagMigrationSnapshot(false, true)
after := testFlagMigrationSnapshot(true, true)
partial := testFlagMigrationSnapshot(false, true)
partial.Commands[1].LocalFlags[0].Hidden = true
partial.Commands[1].LocalFlags[0].Required = false
partial.Commands[1].LocalFlags[0].AliasOf = "message-id"
typeDrift := testFlagMigrationSnapshot(true, true)
typeDrift.Commands[1].LocalFlags[0].Type = "stringSlice"
noOptDrift := testFlagMigrationSnapshot(true, true)
noOptDrift.Commands[1].LocalFlags[0].NoOpt = ""
shorthandDrift := testFlagMigrationSnapshot(true, true)
shorthandDrift.Commands[1].LocalFlags[0].Shorthand = "x"
tests := []struct {
name string
current Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
}{
{
name: "candidate-added pending record cannot approve its own surface change",
current: after,
authority: empty,
candidate: pending,
},
{
name: "candidate-added consumed record cannot approve its own surface change",
current: after,
authority: empty,
candidate: consumed,
},
{
name: "partial migration is rejected",
current: partial,
authority: pending,
candidate: consumed,
},
{
name: "pending record cannot be falsely consumed before the surface changes",
current: before,
authority: pending,
candidate: consumed,
},
{
name: "completed surface change must consume its pending record",
current: after,
authority: pending,
candidate: pending,
},
{
name: "legacy type drift is outside the approval",
current: typeDrift,
authority: pending,
candidate: consumed,
},
{
name: "legacy no-opt drift is outside the approval",
current: noOptDrift,
authority: pending,
candidate: consumed,
},
{
name: "legacy shorthand drift is outside the approval",
current: shorthandDrift,
authority: pending,
candidate: consumed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
test.current,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
test.authority,
test.candidate,
)
if err == nil {
t.Fatalf("invalid transition passed: %#v", report)
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsApprovalCreationBeforeSurfaceChange(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
report, err := CompareAllWithFlagMigrations(
before,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
testEmptyFlagMigrationManifest(),
testFlagMigrationManifest(FlagMigrationPending),
)
if err != nil {
t.Fatalf("pending approval creation was rejected before the surface changed: %v", err)
}
if !report.Compatible {
t.Fatalf("pending approval creation changed compatibility: %#v", report.Comparisons)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsKeepsUnrelatedBreakageBlocking(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
pending := testFlagMigrationManifest(FlagMigrationPending)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
tests := []struct {
name string
mutate func(*Snapshot)
kind string
flag string
}{
{
name: "unrelated flag removal",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags = snapshot.Commands[1].LocalFlags[:2]
},
kind: "flag_removed",
flag: "format",
},
{
name: "unrelated flag type change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].Type = "stringSlice"
},
kind: "flag_type_changed",
flag: "format",
},
{
name: "unrelated flag no-opt change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].NoOpt = ""
},
kind: "flag_no_opt_changed",
flag: "format",
},
{
name: "unrelated flag shorthand change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].Shorthand = ""
},
kind: "flag_shorthand_changed",
flag: "format",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := testFlagMigrationSnapshot(true, true)
test.mutate(&current)
report, err := CompareAllWithFlagMigrations(
current,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
pending,
consumed,
)
if err != nil {
t.Fatalf("valid migration with unrelated breakage returned a lifecycle error: %v", err)
}
if report.Compatible {
t.Fatalf("unrelated breakage was hidden by migration approval: %#v", report)
}
for _, comparison := range report.Comparisons {
if !hasFlagChange(comparison.Blocking, test.kind, "dws chat send", test.flag) {
t.Fatalf("reference %q blocking=%#v, want %s for --%s", comparison.Reference, comparison.Blocking, test.kind, test.flag)
}
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceiptUntilStableCatchesUp(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{
"merge-base": after,
"stable": before,
},
consumed,
consumed,
)
if err != nil {
t.Fatalf("consumed receipt was rejected while stable still needed it: %v", err)
}
if !report.Compatible {
t.Fatalf("stable comparison did not honor the consumed receipt: %#v", report.Comparisons)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
"merge-base": after,
"stable": after,
}
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
}
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("cleanup-only candidate was incompatible: %#v", report.Comparisons)
}
}
func testSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
@@ -348,6 +891,92 @@ func testFlag(name, flagType string, required bool) Flag {
return Flag{Name: name, Type: flagType, Required: required}
}
func testFlagMigrationSnapshot(after, includeUnrelated bool) Snapshot {
legacy := Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
NoOpt: "auto",
Required: true,
}
flags := []Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []Flag{
legacy,
{
Name: "message-id",
Type: "string",
Required: true,
},
}
}
if includeUnrelated {
flags = append(flags, Flag{
Name: "format",
Shorthand: "f",
Type: "string",
NoOpt: "json",
})
}
return testSnapshot(
testCommand("dws"),
testCommand("dws chat send", flags...),
)
}
func testFlagMigrationManifest(state string) FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{
{
Command: "dws chat send",
Legacy: FlagMigrationSide{
Name: "legacy-id",
Before: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
},
After: FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
AliasOf: "message-id",
},
},
Canonical: FlagMigrationSide{
Name: "message-id",
Before: FlagMigrationState{},
After: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Scope: "local",
},
},
State: state,
Reason: "rename the public flag while preserving the executable legacy alias",
},
},
}
}
func testEmptyFlagMigrationManifest() FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{},
}
}
func hasChangeKind(changes []Change, kind string) bool {
for _, change := range changes {
if change.Kind == kind {
+696
View File
@@ -0,0 +1,696 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"strings"
)
const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
)
type FlagMigrationManifest struct {
Version int `json:"version"`
Migrations []FlagMigration `json:"migrations"`
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
Name string `json:"name"`
Before FlagMigrationState `json:"before"`
After FlagMigrationState `json:"after"`
}
type FlagMigrationState struct {
Present bool `json:"present"`
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Shorthand string `json:"shorthand,omitempty"`
NoOpt string `json:"no_opt,omitempty"`
Scope string `json:"scope,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
}
func ReadFlagMigrationManifest(r io.Reader) (FlagMigrationManifest, error) {
data, err := io.ReadAll(r)
if err != nil {
return FlagMigrationManifest{}, fmt.Errorf("read flag migration manifest: %w", err)
}
var manifest FlagMigrationManifest
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return FlagMigrationManifest{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return FlagMigrationManifest{}, fmt.Errorf("flag migration manifest contains trailing multiple JSON values")
}
return FlagMigrationManifest{}, fmt.Errorf("read trailing flag migration manifest data: %w", err)
}
if err := validateFlagMigrationJSONSchema(data); err != nil {
return FlagMigrationManifest{}, err
}
if err := manifest.Validate(); err != nil {
return FlagMigrationManifest{}, err
}
return manifest, nil
}
func validateFlagMigrationJSONSchema(data []byte) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := validateMigrationJSONValue(decoder, "$", reflect.TypeOf(FlagMigrationManifest{})); err != nil {
return err
}
if token, err := decoder.Token(); err != io.EOF {
if err == nil {
return fmt.Errorf("flag migration manifest contains trailing JSON value %v", token)
}
return fmt.Errorf("read trailing flag migration manifest data: %w", err)
}
return nil
}
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
}
switch schema.Kind() {
case reflect.Struct:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return migrationJSONTypeError(path, schema, token)
}
fields := migrationJSONFields(schema)
seen := make(map[string]bool, len(fields))
for decoder.More() {
keyToken, keyErr := decoder.Token()
if keyErr != nil {
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
}
// encoding/json guarantees object member names are string tokens.
key := keyToken.(string)
fieldSchema, exists := fields[key]
if !exists {
for canonical := range fields {
if strings.EqualFold(key, canonical) {
return fmt.Errorf(
"flag migration manifest contains non-canonical field %q at %s (want %q)",
key,
path,
canonical,
)
}
}
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
}
if seen[key] {
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
}
seen[key] = true
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
}
return nil
case reflect.Slice:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return migrationJSONTypeError(path, schema, token)
}
for index := 0; decoder.More(); index++ {
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
}
return nil
case reflect.String:
if _, ok := token.(string); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Int:
if _, ok := token.(json.Number); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Bool:
if _, ok := token.(bool); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
default:
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
}
}
func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
fields := make(map[string]reflect.Type, schema.NumField())
for index := 0; index < schema.NumField(); index++ {
field := schema.Field(index)
name := field.Tag.Get("json")
if comma := strings.IndexByte(name, ','); comma >= 0 {
name = name[:comma]
}
if name == "-" {
continue
}
if name == "" {
name = field.Name
}
fields[name] = field.Type
}
return fields
}
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
return fmt.Errorf(
"flag migration manifest value at %s must be %s, got %s",
path,
migrationJSONKindDescription(want),
migrationJSONTokenDescription(token),
)
}
func migrationJSONKindDescription(schema reflect.Type) string {
switch schema.Kind() {
case reflect.Struct:
return "an object"
case reflect.Slice:
return "an array"
case reflect.String:
return "a string"
case reflect.Int:
return "a number"
case reflect.Bool:
return "a boolean"
default:
return "the declared JSON type"
}
}
func migrationJSONTokenDescription(token json.Token) string {
if token == nil {
return "null"
}
if delimiter, ok := token.(json.Delim); ok {
switch delimiter {
case '{':
return "object"
case '[':
return "array"
default:
return fmt.Sprintf("delimiter %q", delimiter)
}
}
switch token.(type) {
case string:
return "string"
case json.Number, float64:
return "number"
case bool:
return "boolean"
default:
return fmt.Sprintf("%T", token)
}
}
func (m FlagMigrationManifest) Validate() error {
if m.Version != FlagMigrationManifestVersion {
return fmt.Errorf(
"unsupported flag migration manifest version %d (want %d)",
m.Version,
FlagMigrationManifestVersion,
)
}
if m.Migrations == nil {
return fmt.Errorf("flag migration manifest migrations must be an array")
}
seen := make(map[string]bool, len(m.Migrations))
legacyTargets := make(map[string]string, len(m.Migrations))
for index, migration := range m.Migrations {
if err := migration.validate(); err != nil {
return fmt.Errorf("flag migration %d: %w", index, err)
}
key := migration.key()
if seen[key] {
return fmt.Errorf("flag migration %d duplicates %s", index, key)
}
seen[key] = true
legacyKey := migration.Command + "\x00" + migration.Legacy.Name
if canonical, exists := legacyTargets[legacyKey]; exists && canonical != migration.Canonical.Name {
return fmt.Errorf(
"flag migration %d maps %s --%s to both --%s and --%s",
index,
migration.Command,
migration.Legacy.Name,
canonical,
migration.Canonical.Name,
)
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
return nil
}
func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
if m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration reason must be trimmed")
}
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
if err := m.Legacy.After.validate("legacy after"); err != nil {
return err
}
if err := m.Canonical.Before.validate("canonical before"); err != nil {
return err
}
if err := m.Canonical.After.validate("canonical after"); err != nil {
return err
}
if !m.Legacy.Before.Present || !m.Legacy.After.Present {
return fmt.Errorf("legacy flag must remain present before and after migration")
}
if m.Legacy.Before.Hidden || !m.Legacy.After.Hidden {
return fmt.Errorf("legacy flag must migrate exactly from visible to hidden")
}
if !m.Canonical.After.Present {
return fmt.Errorf("canonical flag must be present after migration")
}
if m.Canonical.After.Hidden {
return fmt.Errorf("canonical flag must remain visible")
}
if !m.Canonical.After.Required {
return fmt.Errorf("canonical flag must be required after migration")
}
if m.Canonical.Before.Present && m.Canonical.Before.Required {
return fmt.Errorf("canonical flag must be absent or optional before migration")
}
if m.Legacy.After.AliasOf != m.Canonical.Name {
return fmt.Errorf(
"legacy flag after state must declare alias_of %q",
m.Canonical.Name,
)
}
if m.Legacy.Before.AliasOf != "" || m.Canonical.Before.AliasOf != "" || m.Canonical.After.AliasOf != "" {
return fmt.Errorf("alias_of is only valid on the legacy after state")
}
if m.Canonical.Before.Present {
if m.Canonical.Before.Type != m.Canonical.After.Type {
return fmt.Errorf("canonical flag type must remain unchanged")
}
if m.Canonical.Before.Shorthand != m.Canonical.After.Shorthand {
return fmt.Errorf("canonical flag shorthand must remain unchanged")
}
if m.Canonical.Before.NoOpt != m.Canonical.After.NoOpt {
return fmt.Errorf("canonical flag no_opt must remain unchanged")
}
if m.Canonical.Before.Scope != m.Canonical.After.Scope {
return fmt.Errorf("canonical flag scope must remain unchanged")
}
}
if m.Legacy.Before.Type != m.Legacy.After.Type || m.Legacy.After.Type != m.Canonical.After.Type {
return fmt.Errorf("legacy and canonical flag types must match exactly")
}
if m.Legacy.Before.Shorthand != m.Legacy.After.Shorthand {
return fmt.Errorf("legacy flag shorthand must remain unchanged")
}
if m.Legacy.Before.NoOpt != m.Legacy.After.NoOpt {
return fmt.Errorf("legacy flag no_opt must remain unchanged")
}
if m.Legacy.Before.Scope != m.Legacy.After.Scope {
return fmt.Errorf("legacy flag scope must remain unchanged")
}
return nil
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
return fmt.Errorf("%s absent state must not declare flag attributes", label)
}
return nil
}
if strings.TrimSpace(s.Type) == "" {
return fmt.Errorf("%s present state requires type", label)
}
if s.Scope != "local" && s.Scope != "inherited" {
return fmt.Errorf("%s present state has invalid scope %q", label, s.Scope)
}
return nil
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
func isExactCommandPath(path string) bool {
return path != "" &&
path == strings.TrimSpace(path) &&
strings.Join(strings.Fields(path), " ") == path &&
(path == "dws" || strings.HasPrefix(path, "dws ")) &&
!strings.ContainsAny(path, "*?[]{}")
}
func isExactFlagName(name string) bool {
return name != "" &&
name == strings.TrimSpace(name) &&
!strings.HasPrefix(name, "-") &&
!strings.ContainsAny(name, "*?[]{} /\t\r\n")
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) (Report, error) {
authorizations, err := AuthorizeFlagMigrations(current, references, authority, candidate)
if err != nil {
return Report{}, err
}
report := CompareAll(current, references)
if len(authorizations) == 0 {
return report, nil
}
for index := range report.Comparisons {
comparison := &report.Comparisons[index]
reference := references[comparison.Reference]
filtered := comparison.Blocking[:0]
for _, change := range comparison.Blocking {
if flagMigrationAuthorizesChange(current, reference, change, authorizations) {
continue
}
filtered = append(filtered, change)
}
comparison.Blocking = filtered
comparison.Compatible = len(filtered) == 0
}
report.Compatible = true
for _, comparison := range report.Comparisons {
if !comparison.Compatible {
report.Compatible = false
break
}
}
return report, nil
}
// AuthorizeFlagMigrations validates both snapshots and manifests, enforces the
// base-owned migration lifecycle, and returns only approvals that may authorize
// the current exact interface transition. A non-empty lifecycle requires both a
// main/merge-base authority reference and a stable reference. Candidate-added
// records never appear in the returned authorization set.
func AuthorizeFlagMigrations(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) ([]FlagMigration, error) {
if err := current.Validate(); err != nil {
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
}
for label, snapshot := range references {
if err := snapshot.Validate(); err != nil {
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
}
}
if err := authority.Validate(); err != nil {
return nil, fmt.Errorf("validate approved flag migrations: %w", err)
}
if err := candidate.Validate(); err != nil {
return nil, fmt.Errorf("validate candidate flag migrations: %w", err)
}
return evaluateFlagMigrationLifecycle(current, references, authority, candidate)
}
type flagMigrationPhase string
const (
flagMigrationBefore flagMigrationPhase = "before"
flagMigrationAfter flagMigrationPhase = "after"
flagMigrationPartial flagMigrationPhase = "partial"
)
func evaluateFlagMigrationLifecycle(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) ([]FlagMigration, error) {
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
return nil, nil
}
if _, ok := references["stable"]; !ok {
return nil, fmt.Errorf("flag migration lifecycle requires a stable reference")
}
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
if !ok {
return nil, fmt.Errorf("flag migration lifecycle requires a main or merge-base reference")
}
authorityByKey := flagMigrationIndex(authority)
candidateByKey := flagMigrationIndex(candidate)
authorizations := make([]FlagMigration, 0, len(authority.Migrations))
for _, approved := range authority.Migrations {
basePhase := matchFlagMigrationPhase(mergeBase, approved)
wantBasePhase := flagMigrationBefore
if approved.State == FlagMigrationConsumed {
wantBasePhase = flagMigrationAfter
}
if basePhase != wantBasePhase {
return nil, fmt.Errorf(
"approved flag migration %s is %s in %s, want exact %s state for %s",
approved.displayKey(),
basePhase,
label,
wantBasePhase,
approved.State,
)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameFlagMigrationApproval(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned flag migration %s", approved.displayKey())
}
currentPhase := matchFlagMigrationPhase(current, approved)
switch approved.State {
case FlagMigrationPending:
if !exists {
return nil, fmt.Errorf("candidate removed pending flag migration %s", approved.displayKey())
}
switch currentPhase {
case flagMigrationBefore:
if proposed.State != FlagMigrationPending {
return nil, fmt.Errorf("candidate falsely consumed unchanged flag migration %s", approved.displayKey())
}
case flagMigrationAfter:
if proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate completed flag migration %s without marking it consumed", approved.displayKey())
}
authorizations = append(authorizations, approved)
default:
return nil, fmt.Errorf("candidate partially applied flag migration %s", approved.displayKey())
}
case FlagMigrationConsumed:
if currentPhase != flagMigrationAfter {
return nil, fmt.Errorf("candidate drifted from consumed flag migration %s", approved.displayKey())
}
allReferencesAfter := true
for _, reference := range references {
if matchFlagMigrationPhase(reference, approved) != flagMigrationAfter {
allReferencesAfter = false
break
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
}
continue
}
if !exists {
return nil, fmt.Errorf("candidate removed consumed flag migration %s before every reference reached the after state", approved.displayKey())
}
if proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
authorizations = append(authorizations, approved)
}
}
for _, proposed := range candidate.Migrations {
if _, exists := authorityByKey[proposed.key()]; exists {
continue
}
if proposed.State != FlagMigrationPending {
return nil, fmt.Errorf("candidate-added flag migration %s must start pending", proposed.displayKey())
}
if matchFlagMigrationPhase(mergeBase, proposed) != flagMigrationBefore {
return nil, fmt.Errorf("candidate-added flag migration %s does not match the merge-base before state", proposed.displayKey())
}
if matchFlagMigrationPhase(current, proposed) != flagMigrationBefore {
return nil, fmt.Errorf("candidate-added flag migration %s cannot authorize its own interface change", proposed.displayKey())
}
}
return authorizations, nil
}
func flagMigrationAuthoritySnapshot(references map[string]Snapshot) (Snapshot, string, bool) {
for _, label := range []string{"merge-base", "main"} {
if snapshot, ok := references[label]; ok {
return snapshot, label, true
}
}
return Snapshot{}, "", false
}
func flagMigrationIndex(manifest FlagMigrationManifest) map[string]FlagMigration {
index := make(map[string]FlagMigration, len(manifest.Migrations))
for _, migration := range manifest.Migrations {
index[migration.key()] = migration
}
return index
}
func sameFlagMigrationApproval(left, right FlagMigration) bool {
left.State = ""
right.State = ""
return reflect.DeepEqual(left, right)
}
func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMigrationPhase {
command, exists := commandIndex(snapshot)[migration.Command]
if !exists {
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
return flagMigrationBefore
}
if legacy == migration.Legacy.After && canonical == migration.Canonical.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
func flagMigrationStateForCommand(command Command, name string) FlagMigrationState {
for _, flag := range command.LocalFlags {
if flag.Name == name {
return flagMigrationState(flag, "local")
}
}
for _, flag := range command.InheritedFlags {
if flag.Name == name {
return flagMigrationState(flag, "inherited")
}
}
return FlagMigrationState{}
}
func flagMigrationState(flag Flag, scope string) FlagMigrationState {
return FlagMigrationState{
Present: true,
Type: flag.Type,
Required: flag.Required,
Hidden: flag.Hidden,
Shorthand: flag.Shorthand,
NoOpt: flag.NoOpt,
Scope: scope,
AliasOf: flag.AliasOf,
}
}
func flagMigrationAuthorizesChange(
current Snapshot,
reference Snapshot,
change Change,
authorizations []FlagMigration,
) bool {
canonicalPath := acceptedPathIndex(reference)[change.Path]
if canonicalPath == "" {
canonicalPath = change.Path
}
for _, migration := range authorizations {
if canonicalPath != migration.Command ||
matchFlagMigrationPhase(reference, migration) != flagMigrationBefore ||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
if change.Flag != migration.Canonical.Name {
continue
}
if !migration.Canonical.Before.Present &&
migration.Canonical.After.Required &&
change.Kind == "required_flag_added" {
return true
}
if migration.Canonical.Before.Present &&
!migration.Canonical.Before.Required &&
migration.Canonical.After.Required &&
change.Kind == "flag_became_required" {
return true
}
}
return false
}
func (m FlagMigration) displayKey() string {
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -0,0 +1,966 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaErrors(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "schema validator rejects a trailing value",
input: coverageManifestJSON() + ` {}`,
wantErr: "trailing JSON value",
},
{
name: "schema validator reports malformed trailing data",
input: coverageManifestJSON() + ` ?`,
wantErr: "read trailing flag migration manifest data",
},
{
name: "schema validator reports an empty document",
input: "",
wantErr: "read flag migration manifest value at $",
},
{
name: "schema validator rejects an unknown field",
input: `{"version":1,"migrations":[],"unexpected":true}`,
wantErr: `unknown field "unexpected"`,
},
{
name: "schema validator reports a malformed field token",
input: `{"`,
wantErr: "flag migration manifest",
},
{
name: "schema validator reports an unclosed object",
input: `{"version":1`,
wantErr: "close flag migration manifest object",
},
{
name: "schema validator reports an unclosed array",
input: `{"version":1,"migrations":[`,
wantErr: "close flag migration manifest array",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateFlagMigrationJSONSchema([]byte(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validateFlagMigrationJSONSchema() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationStrictJSONTypeDiagnostics(t *testing.T) {
tests := []struct {
name string
input string
schema reflect.Type
wantErr string
}{
{
name: "manifest must be an object",
input: `[]`,
schema: reflect.TypeOf(FlagMigrationManifest{}),
wantErr: "must be an object, got array",
},
{
name: "migrations must be an array",
input: `{}`,
schema: reflect.TypeOf([]FlagMigration{}),
wantErr: "must be an array, got object",
},
{
name: "integer rejects string",
input: `"one"`,
schema: reflect.TypeOf(int(0)),
wantErr: "must be a number, got string",
},
{
name: "string rejects number",
input: `1`,
schema: reflect.TypeOf(""),
wantErr: "must be a string, got number",
},
{
name: "string rejects boolean",
input: `false`,
schema: reflect.TypeOf(""),
wantErr: "must be a string, got boolean",
},
{
name: "unsupported schema fails closed",
input: `1`,
schema: reflect.TypeOf(float64(0)),
wantErr: "unsupported Go schema type float64",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
decoder := json.NewDecoder(strings.NewReader(test.input))
decoder.UseNumber()
err := validateMigrationJSONValue(decoder, "$", test.schema)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validateMigrationJSONValue() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaHelpers(t *testing.T) {
type fieldFixture struct {
DefaultName string
Ignored string `json:"-"`
Tagged string `json:"tagged,omitempty"`
}
fields := migrationJSONFields(reflect.TypeOf(fieldFixture{}))
if _, exists := fields["Ignored"]; exists {
t.Fatal("migrationJSONFields() retained a json:- field")
}
if fields["DefaultName"] != reflect.TypeOf("") || fields["tagged"] != reflect.TypeOf("") {
t.Fatalf("migrationJSONFields() = %#v, want default and tagged string fields", fields)
}
if got := migrationJSONKindDescription(reflect.TypeOf(float64(0))); got != "the declared JSON type" {
t.Fatalf("migrationJSONKindDescription(float64) = %q", got)
}
for _, test := range []struct {
name string
token json.Token
want string
}{
{name: "closing delimiter", token: json.Delim('}'), want: `delimiter "}"`},
{name: "unsupported token", token: struct{}{}, want: "struct {}"},
} {
t.Run(test.name, func(t *testing.T) {
if got := migrationJSONTokenDescription(test.token); got != test.want {
t.Fatalf("migrationJSONTokenDescription() = %q, want %q", got, test.want)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "initial malformed JSON",
input: `{`,
wantErr: "unexpected EOF",
},
{
name: "multiple JSON values",
input: coverageManifestJSON() + ` {}`,
wantErr: "trailing multiple JSON values",
},
{
name: "malformed trailing JSON",
input: coverageManifestJSON() + ` {`,
wantErr: "read trailing flag migration manifest data",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
optionalCanonical := func() FlagMigrationManifest {
manifest := coverageManifest(FlagMigrationPending)
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
return manifest
}
tests := []struct {
name string
make func() FlagMigrationManifest
mutate func(*FlagMigrationManifest)
wantErr string
}{
{
name: "canonical flag name is exact",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = "--message-id" },
wantErr: "canonical name must be an exact canonical flag",
},
{
name: "legacy and canonical names differ",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = m.Migrations[0].Legacy.Name },
wantErr: "legacy and canonical flags must differ",
},
{
name: "legacy before state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Scope = "global" },
wantErr: "legacy before present state has invalid scope",
},
{
name: "legacy after state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Type = "" },
wantErr: "legacy after present state requires type",
},
{
name: "canonical before state validates",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "" },
wantErr: "canonical before present state requires type",
},
{
name: "canonical after state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Scope = "" },
wantErr: "canonical after present state has invalid scope",
},
{
name: "legacy before remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before = FlagMigrationState{} },
wantErr: "legacy flag must remain present before and after migration",
},
{
name: "legacy after remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After = FlagMigrationState{} },
wantErr: "legacy flag must remain present before and after migration",
},
{
name: "legacy starts visible",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Hidden = true },
wantErr: "legacy flag must migrate exactly from visible to hidden",
},
{
name: "legacy ends hidden",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Hidden = false },
wantErr: "legacy flag must migrate exactly from visible to hidden",
},
{
name: "canonical after remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
wantErr: "canonical flag must be present after migration",
},
{
name: "legacy after declares alias target",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.AliasOf = "other-id" },
wantErr: `legacy flag after state must declare alias_of "message-id"`,
},
{
name: "legacy before cannot declare alias",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical before cannot declare alias",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical after cannot declare alias",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical type remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "stringSlice" },
wantErr: "canonical flag type must remain unchanged",
},
{
name: "canonical shorthand remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Shorthand = "m" },
wantErr: "canonical flag shorthand must remain unchanged",
},
{
name: "canonical no-opt remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.NoOpt = "auto" },
wantErr: "canonical flag no_opt must remain unchanged",
},
{
name: "canonical scope remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Scope = "inherited" },
wantErr: "canonical flag scope must remain unchanged",
},
{
name: "legacy before type matches legacy after",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Type = "stringSlice" },
wantErr: "legacy and canonical flag types must match exactly",
},
{
name: "legacy after type matches canonical after",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Type = "stringSlice" },
wantErr: "legacy and canonical flag types must match exactly",
},
{
name: "legacy shorthand remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Shorthand = "x" },
wantErr: "legacy flag shorthand must remain unchanged",
},
{
name: "legacy no-opt remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.NoOpt = "" },
wantErr: "legacy flag no_opt must remain unchanged",
},
{
name: "legacy scope remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Scope = "inherited" },
wantErr: "legacy flag scope must remain unchanged",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manifest := test.make()
test.mutate(&manifest)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsAbsentAttributesAndTargetConflicts(t *testing.T) {
stateMutations := []struct {
name string
mutate func(*FlagMigrationState)
}{
{name: "type", mutate: func(s *FlagMigrationState) { s.Type = "string" }},
{name: "required", mutate: func(s *FlagMigrationState) { s.Required = true }},
{name: "hidden", mutate: func(s *FlagMigrationState) { s.Hidden = true }},
{name: "shorthand", mutate: func(s *FlagMigrationState) { s.Shorthand = "m" }},
{name: "no-opt", mutate: func(s *FlagMigrationState) { s.NoOpt = "auto" }},
{name: "scope", mutate: func(s *FlagMigrationState) { s.Scope = "local" }},
{name: "alias", mutate: func(s *FlagMigrationState) { s.AliasOf = "message-id" }},
}
for _, test := range stateMutations {
t.Run("absent state rejects "+test.name, func(t *testing.T) {
manifest := coverageManifest(FlagMigrationPending)
test.mutate(&manifest.Migrations[0].Canonical.Before)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "absent state must not declare flag attributes") {
t.Fatalf("Validate() error = %v, want absent-state rejection", err)
}
})
}
manifest := coverageManifest(FlagMigrationPending)
second := coverageManifest(FlagMigrationPending).Migrations[0]
second.Canonical.Name = "canonical-two"
second.Legacy.After.AliasOf = second.Canonical.Name
manifest.Migrations = append(manifest.Migrations, second)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "to both --message-id and --canonical-two") {
t.Fatalf("Validate() error = %v, want one legacy to two canonical targets rejected", err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsValidatesEveryInput(t *testing.T) {
before := coverageMigrationSnapshot(coverageManifest(FlagMigrationPending).Migrations[0], false, false)
empty := coverageEmptyManifest()
tests := []struct {
name string
current Snapshot
references map[string]Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
wantErr string
}{
{
name: "current snapshot",
current: Snapshot{},
references: map[string]Snapshot{"merge-base": before},
authority: empty,
candidate: empty,
wantErr: "validate current interface snapshot",
},
{
name: "reference snapshot",
current: before,
references: map[string]Snapshot{"merge-base": {}},
authority: empty,
candidate: empty,
wantErr: "validate merge-base interface snapshot",
},
{
name: "authority manifest",
current: before,
references: map[string]Snapshot{"merge-base": before},
authority: FlagMigrationManifest{},
candidate: empty,
wantErr: "validate approved flag migrations",
},
{
name: "candidate manifest",
current: before,
references: map[string]Snapshot{"merge-base": before},
authority: empty,
candidate: FlagMigrationManifest{},
wantErr: "validate candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
}
})
}
report, err := CompareAllWithFlagMigrations(before, map[string]Snapshot{"merge-base": before}, empty, empty)
if err != nil || !report.Compatible {
t.Fatalf("empty migration lifecycle = (%#v, %v), want compatible", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
partial := coverageMigrationSnapshot(migration, false, false)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
modified := coverageManifest(FlagMigrationPending)
modified.Migrations[0].Reason = "A different reviewed reason."
tests := []struct {
name string
current Snapshot
references map[string]Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
wantErr string
}{
{
name: "requires merge-base or main",
current: before,
references: map[string]Snapshot{"stable": before},
authority: pending,
candidate: pending,
wantErr: "requires a main or merge-base reference",
},
{
name: "pending authority requires before base",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: pending,
candidate: pending,
wantErr: "want exact before state for pending",
},
{
name: "consumed authority requires after base",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: consumed,
candidate: consumed,
wantErr: "want exact after state for consumed",
},
{
name: "missing base command is partial",
current: before,
references: map[string]Snapshot{"merge-base": coverageRootOnlySnapshot(), "stable": before},
authority: pending,
candidate: pending,
wantErr: "is partial in merge-base",
},
{
name: "candidate cannot modify base receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: modified,
wantErr: "candidate modified base-owned flag migration",
},
{
name: "candidate cannot remove pending receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: empty,
wantErr: "candidate removed pending flag migration",
},
{
name: "candidate cannot falsely consume pending receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: consumed,
wantErr: "candidate falsely consumed unchanged flag migration",
},
{
name: "candidate must consume completed receipt",
current: after,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: pending,
wantErr: "without marking it consumed",
},
{
name: "candidate rejects partial application",
current: partial,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: consumed,
wantErr: "candidate partially applied flag migration",
},
{
name: "consumed receipt rejects current revert",
current: before,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: consumed,
wantErr: "candidate drifted from consumed flag migration",
},
{
name: "consumed receipt cannot be removed early",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: empty,
wantErr: "before every reference reached the after state",
},
{
name: "consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: pending,
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
candidate: consumed,
wantErr: "is stale after all references reached the after state",
},
{
name: "candidate-added receipt starts pending",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: empty,
candidate: consumed,
wantErr: "must start pending",
},
{
name: "candidate-added receipt matches base before",
current: before,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: empty,
candidate: pending,
wantErr: "does not match the merge-base before state",
},
{
name: "candidate-added receipt cannot self-authorize",
current: after,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: empty,
candidate: pending,
wantErr: "cannot authorize its own interface change",
},
{
name: "missing current command is partial",
current: coverageRootOnlySnapshot(),
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: pending,
wantErr: "candidate partially applied flag migration",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsMainAuthorityAndCleanup(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"main": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("main-owned migration = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "main": after, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("merge-base precedence = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
empty,
)
if err != nil || !report.Compatible {
t.Fatalf("consumed receipt cleanup = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
before,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
)
if err != nil || !report.Compatible {
t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresStableForReceiptCleanup(t *testing.T) {
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
after := coverageMigrationSnapshot(consumed.Migrations[0], true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after},
consumed,
empty,
)
if err == nil || !strings.Contains(err.Error(), "requires a stable reference") {
t.Fatalf("receipt cleanup without stable = (%#v, %v), want stable-reference error", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCanonical(t *testing.T) {
t.Run("inherited exact state", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Legacy.Before.Scope = "inherited"
pending.Migrations[0].Legacy.After.Scope = "inherited"
pending.Migrations[0].Canonical.After.Scope = "inherited"
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("inherited migration = (%#v, %v), want compatible", report, err)
}
})
t.Run("existing optional canonical becomes required", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
}
})
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsNormalizesAliasesAndKeepsOtherBreakage(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, true)
after := coverageMigrationSnapshot(migration, true, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", "dws chat deliver", migration.Legacy.Name) {
t.Fatalf("alias fixture did not create alias-path finding: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("alias-path migration = (%#v, %v), want compatible", report, err)
}
beforeWithOther := before
afterWithOther := after
beforeWithOther.Commands = append(beforeWithOther.Commands, coverageCommand("dws files", nil, []Flag{{Name: "format", Type: "string"}}, nil))
afterWithOther.Commands = append(afterWithOther.Commands, coverageCommand("dws files", nil, nil, nil))
report, err = CompareAllWithFlagMigrations(
afterWithOther,
map[string]Snapshot{"merge-base": beforeWithOther, "stable": beforeWithOther},
pending,
consumed,
)
if err != nil {
t.Fatalf("unrelated breakage returned lifecycle error: %v", err)
}
if report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_removed", "dws files", "format") {
t.Fatalf("unrelated breakage was hidden: %#v", report)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsDoesNotAuthorizePartialReferenceOrRuleDrift(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
partialReference := coverageMigrationSnapshot(migration, false, false)
partialReference.Commands[len(partialReference.Commands)-1].LocalFlags[0].Hidden = true
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": partialReference},
pending,
consumed,
)
if err != nil {
t.Fatalf("partial non-authority reference returned lifecycle error: %v", err)
}
if report.Compatible {
t.Fatalf("partial reference was incorrectly authorized: %#v", report)
}
beforeWithoutRoot := coverageMigrationSnapshot(migration, false, false)
afterWithoutRoot := coverageMigrationSnapshot(migration, true, false)
beforeWithoutRoot.Commands = beforeWithoutRoot.Commands[len(beforeWithoutRoot.Commands)-1:]
afterWithoutRoot.Commands = afterWithoutRoot.Commands[len(afterWithoutRoot.Commands)-1:]
afterWithoutRoot.Rules.ExcludedFlags = []string{"help", "version"}
report, err = CompareAllWithFlagMigrations(
afterWithoutRoot,
map[string]Snapshot{"merge-base": beforeWithoutRoot, "stable": beforeWithoutRoot},
pending,
consumed,
)
if err != nil {
t.Fatalf("rule drift returned lifecycle error: %v", err)
}
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "snapshot_rules_changed") {
t.Fatalf("snapshot rule drift was incorrectly authorized: %#v", report)
}
}
func coverageManifestJSON() string {
return `{
"version": 1,
"migrations": [
{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reviewed exact flag migration."
}
]
}`
}
func coverageManifest(state string) FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{
{
Command: "dws chat send",
Legacy: FlagMigrationSide{
Name: "legacy-id",
Before: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
},
After: FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
AliasOf: "message-id",
},
},
Canonical: FlagMigrationSide{
Name: "message-id",
Before: FlagMigrationState{},
After: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Scope: "local",
},
},
State: state,
Reason: "Reviewed exact flag migration.",
},
},
}
}
func coverageEmptyManifest() FlagMigrationManifest {
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
}
func coverageMigrationSnapshot(migration FlagMigration, after, commandAlias bool) Snapshot {
legacyState := migration.Legacy.Before
canonicalState := migration.Canonical.Before
if after {
legacyState = migration.Legacy.After
canonicalState = migration.Canonical.After
}
local := []Flag{}
inherited := []Flag{}
add := func(name string, state FlagMigrationState) {
if !state.Present {
return
}
flag := Flag{
Name: name,
Shorthand: state.Shorthand,
Type: state.Type,
NoOpt: state.NoOpt,
Required: state.Required,
Hidden: state.Hidden,
AliasOf: state.AliasOf,
}
if state.Scope == "inherited" {
inherited = append(inherited, flag)
return
}
local = append(local, flag)
}
add(migration.Legacy.Name, legacyState)
add(migration.Canonical.Name, canonicalState)
aliases := []string{}
if commandAlias {
aliases = []string{"deliver"}
}
return coverageSnapshot(
coverageCommand("dws", nil, nil, nil),
coverageCommand("dws chat", nil, nil, nil),
coverageCommand(migration.Command, aliases, local, inherited),
)
}
func coverageRootOnlySnapshot() Snapshot {
return coverageSnapshot(coverageCommand("dws", nil, nil, nil))
}
func coverageSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
Rules: Rules{
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
ExcludedFlags: []string{"help"},
},
Commands: commands,
}
}
func coverageCommand(path string, aliases []string, local, inherited []Flag) Command {
if aliases == nil {
aliases = []string{}
}
if local == nil {
local = []Flag{}
}
if inherited == nil {
inherited = []Flag{}
}
return Command{
Path: path,
Runnable: true,
Aliases: aliases,
LocalFlags: local,
InheritedFlags: inherited,
}
}
@@ -0,0 +1,252 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"errors"
"reflect"
"strings"
"testing"
)
type flagMigrationErrorReader struct{}
func (flagMigrationErrorReader) Read([]byte) (int, error) {
return 0, errors.New("injected read failure")
}
const validFlagMigrationManifestJSON = `{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {"present": true, "type": "string", "required": true, "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reviewed message identifier flag migration."
}
]
}`
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
"version": 1,
"migrations": [],
"allow_all_chat_flags": true
}`))
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want unknown field rejection", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsDuplicateFields(t *testing.T) {
for _, input := range []string{
`{"version": 999, "version": 1, "migrations": []}`,
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "consumed", "state": "pending"`, 1),
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "duplicate field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want duplicate field rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNonCanonicalFields(t *testing.T) {
for _, input := range []string{
`{"Version": 1, "migrations": []}`,
`{"version": 1, "Version": 1, "migrations": []}`,
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"State": "pending"`, 1),
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "non-canonical field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want non-canonical field rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNullScalars(t *testing.T) {
for _, input := range []string{
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": null`, 1),
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": "local", "shorthand": null`, 1),
`{"version": null, "migrations": []}`,
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "must be") || !strings.Contains(err.Error(), "null") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want scalar null rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsWrongJSONTypes(t *testing.T) {
for _, input := range []string{
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": "true"`, 1),
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": false`, 1),
`{"version": "1", "migrations": []}`,
`{"version": 1, "migrations": {}}`,
} {
if _, err := ReadFlagMigrationManifest(strings.NewReader(input)); err == nil {
t.Fatal("ReadFlagMigrationManifest() accepted a field with the wrong JSON type")
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestReportsReaderFailure(t *testing.T) {
_, err := ReadFlagMigrationManifest(flagMigrationErrorReader{})
if err == nil || !strings.Contains(err.Error(), "injected read failure") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want reader failure", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *testing.T) {
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
}
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unsupported version",
input: strings.Replace(validFlagMigrationManifestJSON, `"version": 1`, `"version": 2`, 1),
wantErr: "unsupported flag migration manifest version",
},
{
name: "missing migrations array",
input: `{"version": 1}`,
wantErr: "migrations must be an array",
},
{
name: "null migrations array",
input: `{"version": 1, "migrations": null}`,
wantErr: "migrations must be an array",
},
{
name: "wildcard command",
input: strings.Replace(validFlagMigrationManifestJSON, "dws chat message recall", "dws chat *", 1),
wantErr: "exact command path",
},
{
name: "wildcard flag",
input: strings.Replace(validFlagMigrationManifestJSON, `"name": "msg-id"`, `"name": "msg-*"`, 1),
wantErr: "exact legacy flag",
},
{
name: "empty reason",
input: strings.Replace(validFlagMigrationManifestJSON, "Reviewed message identifier flag migration.", "", 1),
wantErr: "non-empty reason",
},
{
name: "invalid state",
input: strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "approved"`, 1),
wantErr: "invalid state",
},
{
name: "legacy removed",
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}`, `"after": {"present": false}`, 1),
wantErr: "legacy flag must remain present",
},
{
name: "canonical hidden",
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, 1),
wantErr: "canonical flag must remain visible",
},
{
name: "canonical remains optional",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "canonical flag must be required after migration",
},
{
name: "canonical was already required",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"before": {"present": false}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "canonical flag must be absent or optional before migration",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
duplicate := manifest
duplicate.Migrations = append(duplicate.Migrations, duplicate.Migrations[0])
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
t.Fatalf("duplicate migration error = %v", err)
}
whitespaceReason := manifest
whitespaceReason.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
whitespaceReason.Migrations[0].Reason = " reviewed "
if err := whitespaceReason.Validate(); err == nil || !strings.Contains(err.Error(), "trimmed") {
t.Fatalf("whitespace reason error = %v", err)
}
canonicalDrift := manifest
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
t.Fatalf("canonical type drift error = %v", err)
}
_, err = ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON + ` {}`))
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
got, err := AuthorizeFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil {
t.Fatalf("AuthorizeFlagMigrations() error = %v", err)
}
if !reflect.DeepEqual(got, pending.Migrations) {
t.Fatalf("AuthorizeFlagMigrations() = %#v, want exact base-owned approval %#v", got, pending.Migrations)
}
}
+124
View File
@@ -0,0 +1,124 @@
// 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 interfacesnapshot
// flagTypeChange identifies one flag type migration exactly: which command,
// which flag, and in which direction. Nothing is matched by wildcard — an entry
// that differs in any of the four fields does not apply.
//
// CommandPath is the canonical Command.Path form, which always includes the root
// command name ("dws minutes permission apply"). It is deliberately *not* the
// alias-expanded accepted path: a command reachable through an alias enters
// compareEffectiveFlags once per accepted spelling, and keying on the accepted
// path would let every alias spelling bypass the exemption and re-report the
// change.
//
// This table is duplicated in scripts/policy/interface-baseline/reviewed.go and
// the two must stay identical; TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline
// fails if they drift. The duplication is forced rather than chosen:
// check-authoritative-interface-baselines.sh copies the whole
// scripts/policy/interface-baseline directory into a worktree checked out at a
// historical revision and builds it there, so that copy cannot import a package
// this branch adds.
type flagTypeChange struct {
CommandPath string
Flag string
From string
To string
}
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
// migrations this gate accepts. A flag type is part of the published CLI
// contract, and a type swap cannot be proven safe from the type names alone —
// whether old invocations still parse depends on the concrete value domain the
// command enforces. So a migration is accepted only when this exact command,
// flag, and old→new pair appear here. Adding an entry is a contract decision
// and belongs in review, not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "int" is a
// separate key from "int" → "string" and only the reviewed direction is
// accepted. The reverse is not a candidate for this table — widening an int
// flag back to string lets values the parser used to reject reach RunE, which
// is a genuine loosening of validation rather than a type migration.
//
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
// only when nothing else about the flag moved. See compareEffectiveFlags.
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag parsed
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
// same [2,4] domain check in RunE.
//
// This is admissible because the historical set of *successful* invocations
// is a subset of the new one, not merely similar to it:
//
// - Old: the value had to parse as base-10 and land in [2,4]. That is
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
// ("+3", "03", "003", ...).
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
// resolves to the same number under base 0, so no historical success
// becomes a failure.
// - Base 0 additionally accepts "0x3" and friends, which the old parser
// rejected. That widens the accepted set; it cannot break a caller who
// was already succeeding.
// - Values outside [2,4] and non-numeric values still fail. Only the
// failure *message and timing* move, from RunE to flag parsing.
//
// The flag's default value changes from "" to "0" as an unavoidable
// consequence of the type. Neither gate compares defaults, and a default is
// not reachable by a caller here because RunE requires the flag to be
// explicitly set, so this is not a contract change.
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
}
// reviewedFlagTypeChange reports whether this exact command, flag and direction
// is a reviewed migration. Callers must additionally establish that the rest of
// the flag's contract is unchanged before skipping the failure.
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
CommandPath: commandPath,
Flag: flag,
From: from,
To: to,
}]
return reviewed
}
// flagContractOtherwiseChanged reports whether anything this gate checks about a
// flag, other than its type, differs between the two revisions. A reviewed type
// migration is only accepted when this returns false, so the exemption cannot
// carry an unrelated regression in behind it.
//
// Only the fields compareEffectiveFlags actually enforces are compared. Default
// is deliberately excluded: a string → int migration always changes it ("" to
// "0"), so including it would make every entry in the table dead. Deprecated is
// excluded for the same reason it is not a blocking change on its own.
func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
if !oldFlag.Required && newFlag.Required {
return true
}
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
return true
}
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
return true
}
if !oldFlag.Hidden && newFlag.Hidden {
return true
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
return true
}
return false
}
+118 -9
View File
@@ -22,11 +22,16 @@ import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const SchemaVersion = 1
const SchemaVersion = 2
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
// between a retained compatibility flag and an exact canonical Cobra flag.
const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf
var (
// Framework-owned command subtrees are deliberately excluded. They are
@@ -80,6 +85,12 @@ type Flag struct {
Required bool `json:"required"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
// Capture cannot return an error, so it preserves malformed or non-framework
// relation evidence here for Validate/Write to reject. Valid snapshots never
// serialize this implementation detail.
malformedAliasEvidence bool
}
// Capture walks root without rendering help or executing any command. Both
@@ -193,6 +204,9 @@ func (s Snapshot) Validate() error {
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
return err
}
if err := validateFlagAliases(command); err != nil {
return err
}
}
return nil
}
@@ -206,21 +220,46 @@ func captureFlags(set *pflag.FlagSet) []Flag {
if flag == nil || excludedFlags[flag.Name] {
return
}
aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag)
flags = append(flags, Flag{
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
AliasOf: aliasOf,
malformedAliasEvidence: malformedAliasEvidence,
})
})
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
return flags
}
func captureFlagAliasOf(flag *pflag.Flag) (string, bool) {
aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation)
origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin)
if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed {
return "", false
}
malformed := aliasMalformed || originMalformed || aliasOf == "" ||
origin != runtimeannotate.FlagAliasOriginCorecmdV1
return aliasOf, malformed
}
func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) {
values, exists := flag.Annotations[key]
if !exists {
return "", false
}
if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) {
return "", true
}
return values[0], false
}
func isRequired(flag *pflag.Flag) bool {
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
if value == "true" {
@@ -268,3 +307,73 @@ func validateFlags(path, scope string, flags []Flag) error {
}
return nil
}
func validateFlagAliases(command Command) error {
flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags))
ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags))
for _, flag := range command.LocalFlags {
flags[flag.Name] = flag
ordered = append(ordered, flag)
}
for _, flag := range command.InheritedFlags {
if _, exists := flags[flag.Name]; exists {
return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name)
}
flags[flag.Name] = flag
ordered = append(ordered, flag)
}
for _, flag := range ordered {
if flag.malformedAliasEvidence {
return fmt.Errorf(
"command %q flag --%s must declare one exact %q value and framework origin %q",
command.Path,
flag.Name,
FlagAliasOfAnnotation,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
if flag.AliasOf == "" {
continue
}
if !isExactFlagName(flag.AliasOf) {
return fmt.Errorf(
"command %q flag --%s has non-exact alias_of target %q",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if flag.AliasOf == flag.Name {
return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name)
}
target, exists := flags[flag.AliasOf]
if !exists {
return fmt.Errorf(
"command %q flag --%s aliases missing flag --%s",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if target.AliasOf != "" {
return fmt.Errorf(
"command %q flag --%s aliases --%s, which is itself an alias",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if flag.Type != target.Type {
return fmt.Errorf(
"command %q alias flags --%s and --%s have different types %q and %q",
command.Path,
flag.Name,
flag.AliasOf,
flag.Type,
target.Type,
)
}
}
return nil
}
+164 -1
View File
@@ -15,9 +15,12 @@ package interfacesnapshot
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
)
@@ -95,7 +98,7 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
"schema_version": 1,
"schema_version": 2,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
@@ -105,6 +108,166 @@ func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
t.Fatalf(
"flag alias annotation key drifted: snapshot=%q runtime=%q",
FlagAliasOfAnnotation,
runtimeannotate.AnnotationFlagAliasOf,
)
}
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
corecmd.RegisterFlags(leaf, []corecmd.FlagSpec{{
Name: "message-id",
Usage: "message ID",
Aliases: []string{"open-conversation-id"},
}})
root.AddCommand(leaf)
snapshot := Capture(root)
flag := flagIndex(commandIndex(snapshot)["dws send"].LocalFlags)["open-conversation-id"]
if flag.AliasOf != "message-id" {
t.Fatalf("captured alias_of = %q, want message-id", flag.AliasOf)
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write: %v", err)
}
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
if err != nil {
t.Fatalf("Read: %v", err)
}
decodedFlag := flagIndex(commandIndex(decoded)["dws send"].LocalFlags)["open-conversation-id"]
if decodedFlag.AliasOf != "message-id" {
t.Fatalf("round-tripped alias_of = %q, want message-id", decodedFlag.AliasOf)
}
}
func TestCrossPlatformCoverageCaptureRejectsUntrustedFlagAliasEvidence(t *testing.T) {
for _, origin := range []string{"", "handwritten.alias.v1"} {
t.Run(origin, func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("message-id", "", "canonical message ID")
leaf.Flags().String("legacy-id", "", "legacy message ID")
legacy := leaf.Flags().Lookup("legacy-id")
legacy.Annotations = map[string][]string{
FlagAliasOfAnnotation: {"message-id"},
}
if origin != "" {
legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin] = []string{origin}
}
root.AddCommand(leaf)
if err := Capture(root).Validate(); err == nil {
t.Fatal("Validate accepted alias evidence outside corecmd FlagSpec.Aliases")
}
})
}
}
func TestCrossPlatformCoverageCaptureRejectsMultipleNonEmptyFlagAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("message-id", "", "canonical message ID")
leaf.Flags().String("other-id", "", "other ID")
leaf.Flags().String("legacy-id", "", "legacy ID")
legacy := leaf.Flags().Lookup("legacy-id")
legacy.Annotations = map[string][]string{
FlagAliasOfAnnotation: {"message-id", "other-id"},
}
root.AddCommand(leaf)
snapshot := Capture(root)
if err := snapshot.Validate(); err == nil {
t.Fatal("Validate accepted multiple non-empty alias annotation values")
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err == nil {
t.Fatal("Write accepted multiple non-empty alias annotation values")
}
}
func TestCrossPlatformCoverageValidateRejectsMalformedFlagAliasState(t *testing.T) {
tests := []struct {
name string
aliasOf string
}{
{name: "self", aliasOf: "legacy-id"},
{name: "leading dashes", aliasOf: "--message-id"},
{name: "non exact", aliasOf: "message id"},
{name: "missing target", aliasOf: "missing-id"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
snapshot := testSnapshot(
testCommand("dws"),
testCommandWithFlagScopes(
"dws send",
[]Flag{
{Name: "legacy-id", Type: "string", AliasOf: test.aliasOf},
{Name: "message-id", Type: "string"},
},
nil,
),
)
if err := snapshot.Validate(); err == nil {
t.Fatalf("Validate accepted alias_of %q", test.aliasOf)
}
encoded, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if _, err := Read(bytes.NewReader(encoded)); err == nil {
t.Fatalf("Read accepted alias_of %q", test.aliasOf)
}
})
}
}
func TestCrossPlatformCoverageValidateRejectsFlagAliasChainTypeAndScopeConflicts(t *testing.T) {
tests := []struct {
name string
local []Flag
inherited []Flag
}{
{
name: "alias chain",
local: []Flag{
{Name: "legacy-id", Type: "string", AliasOf: "middle-id"},
{Name: "middle-id", Type: "string", AliasOf: "message-id"},
{Name: "message-id", Type: "string"},
},
},
{
name: "different types",
local: []Flag{
{Name: "legacy-id", Type: "string", AliasOf: "message-id"},
{Name: "message-id", Type: "int"},
},
},
{
name: "same flag in local and inherited scopes",
local: []Flag{{Name: "legacy-id", Type: "string"}},
inherited: []Flag{{Name: "legacy-id", Type: "string"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
snapshot := testSnapshot(
testCommand("dws"),
testCommandWithFlagScopes("dws send", test.local, test.inherited),
)
if err := snapshot.Validate(); err == nil {
t.Fatalf("Validate accepted invalid alias contract: %#v", snapshot.Commands[1])
}
})
}
}
func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
base := testSnapshot(testCommand("dws"))
current := testSnapshot(
@@ -71,8 +71,8 @@ func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
"+find-doc": "+search", "+doc-append": "+update", "+history-save": "+version-save",
"+history-list": "+version-list", "+history-revert": "+version-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
+53 -18
View File
@@ -1439,8 +1439,8 @@ var MessagesSendCard = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-card",
Product: "im",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1454,30 +1454,34 @@ var MessagesSendCard = shortcut.Shortcut{
CLIPath: "chat +messages-send-card",
PrimaryCLIPath: "chat +messages-send-card",
},
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId and atTag.",
},
Selection: contract.SelectionSpec{
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
Examples: []string{
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>",
},
},
Parameters: []contract.ParamDecl{
{Name: "receiver-open-dingtalk-id", Property: "receiverOpenDingTalkId"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
{Name: "at-all", Property: "atAll"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥);艾特参数仅支持群聊 --group"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "群聊创建卡片时 @ 的 openDingTalkId 列表;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
{Name: "at-all", Type: shortcut.FlagBool, Desc: "群聊创建卡片时 @ 所有人;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片正文;群聊 @ 时 Runtime 自动前置 create 返回的 atTag;省略时仅创建并返回 bizId"},
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
},
Constraints: []shortcut.Constraint{
@@ -1487,10 +1491,15 @@ var MessagesSendCard = shortcut.Shortcut{
Flags: []string{"flow-status"},
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"group", "at-open-dingtalk-ids", "at-all"},
Description: "艾特参数仅支持群聊 --group",
},
},
Tips: []string{
`dws chat +messages-send-card --group <openConversationId>`,
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
@@ -1499,6 +1508,9 @@ var MessagesSendCard = shortcut.Shortcut{
if rt.Changed("flow-status") && rt.Str("content") == "" {
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
}
if rt.Str("group") == "" && (len(uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))) > 0 || rt.Bool("at-all")) {
return fmt.Errorf("--at-open-dingtalk-ids 和 --at-all 仅支持群聊 --group")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -1506,9 +1518,18 @@ var MessagesSendCard = shortcut.Shortcut{
receiver := rt.Str("receiver")
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
params := map[string]any{}
mentionsRequested := false
switch {
case group != "":
params["openConversationId"] = group
if atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
mentionsRequested = true
}
if rt.Bool("at-all") {
params["atAll"] = true
mentionsRequested = true
}
case receiver != "":
openID, err := resolveUserOpenDingTalkID(rt, receiver)
if err != nil {
@@ -1524,6 +1545,10 @@ var MessagesSendCard = shortcut.Shortcut{
}
status := rt.Int("flow-status")
if rt.DryRun() {
plannedContent := content
if mentionsRequested {
plannedContent = "<atTag from create_and_send_card>" + content
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"dry_run": true,
@@ -1540,7 +1565,7 @@ var MessagesSendCard = shortcut.Shortcut{
"tool": "update_streaming_card",
"arguments": map[string]any{
"bizId": "<from create_and_send_card>",
"msgContent": content,
"msgContent": plannedContent,
"flowStatus": status,
},
},
@@ -1555,9 +1580,13 @@ var MessagesSendCard = shortcut.Shortcut{
if bizID == "" {
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
}
atTag := findCardAtTag(created)
if mentionsRequested && atTag == "" {
return fmt.Errorf("卡片已创建(bizId=%s),但下层未返回 atTag,无法保证请求的 @ 生效;未执行自动更新", bizID)
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
"bizId": bizID,
"msgContent": content,
"msgContent": atTag + content,
"flowStatus": status,
})
if err != nil {
@@ -1575,12 +1604,18 @@ var MessagesSendCard = shortcut.Shortcut{
}
func findCardBizID(value any) string {
return strings.TrimSpace(findCardResponseString(value, []string{"bizId", "bizID", "biz_id"}))
}
func findCardAtTag(value any) string {
return findCardResponseString(value, []string{"atTag"})
}
func findCardResponseString(value any, directKeys []string) string {
switch typed := value.(type) {
case map[string]any:
directKeys := []string{"bizId", "bizID", "biz_id"}
for _, key := range directKeys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
candidate = strings.TrimSpace(candidate)
return candidate
}
}
@@ -1595,7 +1630,7 @@ func findCardBizID(value any) string {
}
for _, key := range envelopeKeys {
visited[key] = struct{}{}
if candidate := findCardBizID(typed[key]); candidate != "" {
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
return candidate
}
}
@@ -1608,13 +1643,13 @@ func findCardBizID(value any) string {
}
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
if candidate := findCardBizID(typed[key]); candidate != "" {
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
return candidate
}
}
case []any:
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
if candidate := findCardResponseString(child, directKeys); candidate != "" {
return candidate
}
}
@@ -1623,7 +1658,7 @@ func findCardBizID(value any) string {
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var nested any
if json.Unmarshal([]byte(trimmed), &nested) == nil {
return findCardBizID(nested)
return findCardResponseString(nested, directKeys)
}
}
}
+124 -2
View File
@@ -609,7 +609,7 @@ func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T)
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1","atTag":"<a atId=D-one>甲</a> <a atId=D-two>乙</a> "}}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
@@ -619,6 +619,8 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
"--at-all",
"--content", "完成",
"--flow-status", "3",
"--yes",
@@ -630,7 +632,20 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
}
if fake.calls[0].args["atAll"] != true {
t.Fatalf("card create atAll = %#v", fake.calls[0].args["atAll"])
}
if _, exists := fake.calls[1].args["atOpenDingTalkIds"]; exists {
t.Fatalf("card update leaked atOpenDingTalkIds: %#v", fake.calls[1].args)
}
if _, exists := fake.calls[1].args["atAll"]; exists {
t.Fatalf("card update leaked atAll: %#v", fake.calls[1].args)
}
if fake.calls[1].args["bizId"] != "biz-1" ||
fake.calls[1].args["msgContent"] != "<a atId=D-one>甲</a> <a atId=D-two>乙</a> 完成" ||
fake.calls[1].args["flowStatus"] != 3 {
t.Fatalf("card update args = %#v", fake.calls[1].args)
}
@@ -643,6 +658,49 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
}
}
func TestCrossPlatformCoverageMessagesSendCardKeepsContentWithoutAtTag(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"bizId":"biz-no-mention"}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "正文",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["msgContent"] != "正文" {
t.Fatalf("card calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"bizId":"biz-missing-at-tag"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--content", "正文",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "biz-missing-at-tag") || !strings.Contains(err.Error(), "atTag") {
t.Fatalf("error = %v, want recoverable missing-atTag error containing bizId", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card calls = %#v, want create only", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
@@ -748,6 +806,50 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
})
t.Run("dry run mentions only in create", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-all",
"--content", "处理中",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("card group dry-run calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
actions, _ := payload["actions"].([]any)
create, _ := actions[0].(map[string]any)
createArguments, _ := create["arguments"].(map[string]any)
update, _ := actions[1].(map[string]any)
updateArguments, _ := update["arguments"].(map[string]any)
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
t.Fatalf("card create dry-run mentions = %#v", createArguments)
}
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
t.Fatalf("card update dry-run leaked atOpenDingTalkIds: %#v", updateArguments)
}
if _, exists := updateArguments["atAll"]; exists {
t.Fatalf("card update dry-run leaked atAll: %#v", updateArguments)
}
if updateArguments["msgContent"] != "<atTag from create_and_send_card>处理中" {
t.Fatalf("card update dry-run content = %#v", updateArguments["msgContent"])
}
})
t.Run("receiver resolution error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
@@ -815,6 +917,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
@@ -858,6 +962,24 @@ func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
}
}
func TestCrossPlatformCoverageFindCardAtTagResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
want string
}{
{map[string]any{"atTag": "<a atId=D-direct>甲</a> "}, "<a atId=D-direct>甲</a> "},
{map[string]any{"result": map[string]any{"atTag": "<a atId=D-nested>乙</a> "}}, "<a atId=D-nested>乙</a> "},
{`{"result":{"card":{"atTag":"<a atId=D-json>丙</a> "}}}`, "<a atId=D-json>丙</a> "},
{map[string]any{"atTag": " "}, ""},
{map[string]any{"atTag": 42}, ""},
{map[string]any{"result": map[string]any{"created": true}}, ""},
} {
if got := findCardAtTag(tc.value); got != tc.want {
t.Errorf("findCardAtTag(%#v) = %q, want %q", tc.value, got, tc.want)
}
}
}
func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
+189 -15
View File
@@ -5,6 +5,7 @@ package doc
import (
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
@@ -16,7 +17,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
const (
compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin"
docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空"
)
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
@@ -66,7 +71,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
cwd, err := docGetwd()
if err != nil {
@@ -82,7 +87,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
data, err := docReadFile(realPath)
if err != nil {
@@ -91,30 +96,198 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
return string(data), nil
}
func validateWorkspaceInputPath(flag, raw string) error {
path := strings.TrimSpace(raw)
if path == "" || filepath.IsAbs(path) {
return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag))
}
cwd, err := docGetwd()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
realBase, err := docEvalSymlinks(cwd)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag))
}
return nil
}
func validateJSONML(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
}
if _, ok := value.([]any); !ok {
root, ok := value.([]any)
if !ok {
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
}
if len(root) == 0 {
return "", apperrors.NewValidation("JSONML 顶层数组不能为空")
}
if _, nestedRoot := root[0].([]any); nestedRoot {
return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素")
}
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
return map[string]any{
"ok": true,
"status": "success",
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
}
}
type docFailureState string
const (
docFailureFailed docFailureState = "failed"
docFailureRetryable docFailureState = "retryable"
docFailureUnknown docFailureState = "unknown"
)
type docFailureTransition struct {
state docFailureState
reason string
retryable bool
executionStarted bool
message string
actions []string
}
func classifyDocWriteFailure(cause error) docFailureTransition {
transition := docFailureTransition{
state: docFailureUnknown,
reason: "doc_write_commit_unknown",
retryable: false,
executionStarted: true,
message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试",
actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"},
}
var typed *apperrors.Error
if !errors.As(cause, &typed) {
return transition
}
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
message := strings.ToLower(typed.Message)
if typed.Category == apperrors.CategoryValidation {
if reason == "" {
reason = "invalid_input"
}
return docFailureTransition{
state: docFailureFailed,
reason: reason,
retryable: false,
executionStarted: false,
message: typed.Message,
actions: []string{"根据错误中的参数约束修正输入后重新执行"},
}
}
permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") ||
strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限")
if permissionDenied {
return docFailureTransition{
state: docFailureFailed,
reason: "permission_denied",
retryable: false,
executionStarted: false,
message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令",
actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"},
}
}
if typed.Category == apperrors.CategoryAuth {
return docFailureTransition{
state: docFailureFailed,
reason: "authentication_required",
retryable: false,
executionStarted: false,
message: "当前身份认证不可用;已终止文档写入",
actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"},
}
}
if typed.ExecutionStarted != nil && !*typed.ExecutionStarted {
transition.state = docFailureFailed
transition.executionStarted = false
transition.retryable = typed.RetryableSet && typed.Retryable
if transition.retryable {
transition.state = docFailureRetryable
}
if reason != "" {
transition.reason = reason
} else {
transition.reason = "doc_write_not_started"
}
transition.message = "文档写入已确认尚未开始"
transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"}
}
return transition
}
func docUnknownWriteError(operation, stage, nodeID string, cause error) error {
var existing *apperrors.Error
if errors.As(cause, &existing) {
if existing.Category == apperrors.CategoryValidation {
return cause
}
if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") {
return cause
}
}
transition := classifyDocWriteFailure(cause)
details := map[string]any{
"contractVersion": "doc.operation.v1",
"status": string(transition.state),
"state": string(transition.state),
"nodeId": nodeID,
"stage": stage,
}
options := []apperrors.Option{
apperrors.WithOperation(operation),
apperrors.WithReason(transition.reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(transition.executionStarted),
apperrors.WithRetryable(transition.retryable),
apperrors.WithActions(transition.actions...),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
}
if transition.reason == "permission_denied" || transition.reason == "authentication_required" {
return apperrors.NewAuth(transition.message, options...)
}
return apperrors.NewAPI(transition.message, options...)
}
func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error {
return docPartialWriteError(
operation,
"doc_write_verification_failed",
stage,
"文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入",
cause,
map[string]any{"nodeId": nodeID, "verified": false},
steps,
map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"},
)
}
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
return apperrors.NewAPI(
message,
@@ -125,10 +298,11 @@ func docPartialWriteError(operation, reason, stage, message string, cause error,
apperrors.WithRetryable(false),
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
"contractVersion": "doc.operation.v1",
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
}),
apperrors.WithCause(cause),
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("staged.md", []byte("body"), 0o600); err != nil {
t.Fatal(err)
}
if err := validateWorkspaceInputPath("file", "staged.md"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
stub func(*testing.T)
}{
{"getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
}},
{"base symlink", func(t *testing.T) {
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("base") })
}},
{"file symlink", func(t *testing.T) {
calls := 0
testseam.Swap(t, &docEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("file")
}
return path, nil
})
}},
{"relative escape", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "../escape", nil })
}},
{"relative error", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
tc.stub(t)
if err := validateWorkspaceInputPath("file", "staged.md"); err == nil {
t.Fatal("expected validation failure")
}
})
}
if _, err := validateJSONML(`["",{}]`); err == nil {
t.Fatal("empty JSONML tag succeeded")
}
validation := classifyDocWriteFailure(apperrors.NewValidation("bad"))
if validation.reason != "invalid_input" || validation.executionStarted {
t.Fatalf("validation transition = %#v", validation)
}
permission := classifyDocWriteFailure(apperrors.NewAPI("forbidden"))
if permission.reason != "permission_denied" {
t.Fatalf("permission transition = %#v", permission)
}
auth := classifyDocWriteFailure(apperrors.NewAuth("login"))
if auth.reason != "authentication_required" {
t.Fatalf("auth transition = %#v", auth)
}
notStarted := classifyDocWriteFailure(apperrors.NewAPI("later", apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)))
if notStarted.state != docFailureRetryable || notStarted.reason != "doc_write_not_started" {
t.Fatalf("retryable transition = %#v", notStarted)
}
for _, cause := range []error{apperrors.NewAPI("permission denied"), apperrors.NewAuth("login")} {
var typed *apperrors.Error
if err := docUnknownWriteError("doc.test", "write", "n", cause); !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
t.Fatalf("auth-classified error = %#v", err)
}
}
for _, existing := range []error{
apperrors.NewValidation("existing"),
apperrors.NewAPI("existing", apperrors.WithDetails(map[string]any{"status": "partial_success"})),
} {
if got := docUnknownWriteError("doc.test", "write", "n", existing); got != existing {
t.Fatalf("existing classified error was wrapped: %p != %p", got, existing)
}
}
if !containsText(nil, "") || !containsText("alpha", "ph") || containsText("alpha", "z") ||
!containsText(map[string]any{"nested": "alpha"}, "alpha") || containsText(map[string]any{"nested": "beta"}, "alpha") ||
!containsText([]any{"alpha"}, "alpha") || containsText([]any{"beta"}, "alpha") || containsText(3, "alpha") {
t.Fatal("containsText branch contract failed")
}
jsonElement := []any{"p", map[string]any{"uuid": "b"}, "text"}
jsonPayload := map[string]any{"jsonml": `["p",{"uuid":"b"},"text"]`}
for _, value := range []any{jsonPayload, `["p",{"uuid":"b"},"text"]`, jsonElement} {
if canonicalBlockContent(value, "jsonml") == "" {
t.Fatalf("empty canonical JSONML for %#v", value)
}
}
for _, value := range []any{
map[string]any{"text": "alpha"},
map[string]any{"id": "ignored", "nested": map[string]any{"text": "beta"}},
[]any{"p", map[string]any{"text": "gamma"}},
[]any{map[string]any{"text": "delta"}},
"epsilon",
} {
if canonicalBlockContent(value, "markdown") == "" {
t.Fatalf("empty canonical markdown for %#v", value)
}
}
jsonTree := map[string]any{
"jsonml": `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"new"},"after"]]`,
"content": "not-json",
"nested": []any{"p", map[string]any{"uuid": "direct"}, "direct"},
}
if len(orderedCanonicalBlocks(jsonTree, "jsonml")) < 3 || findJSONMLBlock(jsonTree, "new") == nil {
t.Fatalf("JSONML traversal failed: %#v", orderedJSONMLBlocks(jsonTree))
}
if findJSONMLBlock(jsonTree, "missing") != nil || jsonMLBlockIdentity([]any{"p"}) != "" || jsonMLBlockIdentity([]any{"p", "attrs"}) != "" {
t.Fatal("JSONML identity negative contract failed")
}
if canonicalBlockIdentity(jsonElement, "jsonml") != "b" || canonicalBlockIdentity("bad", "jsonml") != "" {
t.Fatal("JSONML canonical identity failed")
}
elementTree := map[string]any{"wrapper": []any{map[string]any{"id": "ref", "text": "before"}, map[string]any{"id": "new", "text": "after"}}}
if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" {
t.Fatal("element canonical traversal failed")
}
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") ||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") ||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") {
t.Fatal("inserted block verification branch contract failed")
}
if blockContentEquals(map[string]any{}, "missing", "after", "element") {
t.Fatal("missing block matched")
}
if blockContentEquals(nil, "missing", "after", "element") {
t.Fatal("nil block tree matched")
}
if blockContentEquals(map[string]any{}, "missing", "after", "jsonml") {
t.Fatal("missing JSONML block matched")
}
if got := documentContentCandidates("root", "markdown"); len(got) != 1 || got[0] != "root" {
t.Fatalf("root content candidates = %#v", got)
}
if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" {
t.Fatalf("nested content candidates = %#v", got)
}
if got := splitDocMarkdown("a\nb", 0); len(got) != 1 {
t.Fatalf("disabled split = %#v", got)
}
if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" {
t.Fatalf("newline split = %#v", got)
}
}
func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) {
longContent := strings.Repeat("x", 10001)
if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil {
t.Fatal("partial chunk create succeeded")
}
for _, failAt := range []int{1, 2} {
err := runDocCoverage(t, Update, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}},
"--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes")
if err == nil {
t.Fatalf("block mutation failAt=%d succeeded", failAt)
}
}
mismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {{"blocks": []any{map[string]any{"id": "new", "text": "wrong"}}}},
}}
if err := runDocCoverage(t, Update, mismatch, "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes"); err == nil {
t.Fatal("mismatched block verification succeeded")
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"query error", map[string][]map[string]any{}, 1, []string{"--job-id", "j"}},
{"unknown", map[string][]map[string]any{"query_export_job": {{}}}, 0, []string{"--job-id", "j"}},
{"failed", map[string][]map[string]any{"query_export_job": {{"status": "CANCELLED"}}}, 0, []string{"--job-id", "j"}},
{"missing url", map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}, 0, []string{"--job-id", "j", "--output", "out"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{failAt: tc.failAt, responses: tc.responses}, tc.args...)
})
}
t.Run("export getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
t.Run("export download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
})
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
searchCaller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {{"documents": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, Search, searchCaller, "--query", "q", "--creator-uids", "u", "--editor-uids", "e", "--mentioned-uids", "m", "--workspace-ids", "w"); err != nil {
t.Fatal(err)
}
if got := searchCaller.history[0].params["pageSize"]; got != 10 {
t.Fatalf("default search page size = %#v", got)
}
listCaller := &docCoverageCaller{responses: map[string][]map[string]any{"list_nodes": {{"nodes": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, List, listCaller, "--folder", "f", "--workspace", "w"); err != nil {
t.Fatal(err)
}
if got := listCaller.history[0].params["pageSize"]; got != 50 {
t.Fatalf("default list page size = %#v", got)
}
_ = runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--emoji", "--mention", "[bad", "--yes")
if err := runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u", "--yes"); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, CommentUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "[bad")
_ = runDocCoverage(t, ExportSubmit, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx")
if err := runDocCoverage(t, ExportSubmit, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"template search error", map[string][]map[string]any{}, 1, []string{"--query", "q"}},
{"template not found", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}, "hasMore": false}}}, 0, []string{"--query", "q"}},
{"template resolved", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}}}, 0, []string{"--query", "q", "--source", "PUBLIC"}},
{"template continue", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "nextCursor": "p2"}}}, 0, []string{"--query", "q", "--cursor", "p1"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, TemplateSearch, &docCoverageCaller{responses: tc.responses, failAt: tc.failAt}, tc.args...)
})
}
for _, raw := range [][]string{{"[bad"}, {"", " "}} {
if _, err := normalizeMentionUserIDs(raw); err == nil {
t.Fatalf("invalid mentions %#v succeeded", raw)
}
}
if _, ok := sliceUTF16Range("A😀B", 1, 2); ok {
t.Fatal("selection ending inside surrogate pair succeeded")
}
if _, ok := sliceUTF16Range("abc", -1, 1); ok {
t.Fatal("negative selection range succeeded")
}
commentCases := []struct {
name string
caller *docCoverageCaller
args []string
wantError bool
}{
{"block read", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"range", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": ""}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"selected mismatch", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": "alpha"}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "z", "--yes"}, true},
{"write error", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--yes"}, true},
{"mention", &docCoverageCaller{responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--mention", "[bad", "--yes"}, true},
}
for _, tc := range commentCases {
t.Run(tc.name, func(t *testing.T) {
err := runDocCoverage(t, CommentCreate, tc.caller, tc.args...)
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if !versionEvidenceMatches([]any{map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("array version evidence did not match")
}
if !versionEvidenceMatches(map[string]any{"wrapper": map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
withDescription := collectTemplateCandidates(map[string]any{"templateId": "t", "description": "d"})
if len(withDescription) != 1 || withDescription[0]["description"] != "d" {
t.Fatalf("template description = %#v", withDescription)
}
duplicateTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false},
}}}
_ = runDocCoverage(t, CreateFromTemplate, duplicateTemplates, "--query", "q")
stalledTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
}}}
if err := runDocCoverage(t, CreateFromTemplate, stalledTemplates, "--query", "q"); err == nil {
t.Fatal("stalled template cursor succeeded")
}
maxPages := make([]map[string]any, 20)
for index := range maxPages {
maxPages[index] = map[string]any{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p" + strings.Repeat("x", index+1)}
}
if err := runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": maxPages}}, "--query", "q"); err == nil {
t.Fatal("template max-pages succeeded")
}
t.Chdir(t.TempDir())
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run"); err != nil {
t.Fatal(err)
}
t.Run("empty preview", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
})
t.Run("empty download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaDownload, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r", "--output", "out")
})
}
+142 -108
View File
@@ -17,8 +17,11 @@
package doc
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -77,6 +80,9 @@ var Search = shortcut.Shortcut{
{Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"},
},
Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -111,18 +117,17 @@ var Search = shortcut.Shortcut{
if rt.Changed("workspace-ids") {
params["workspaceIds"] = rt.StrSlice("workspace-ids")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 10
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "search_documents", params)
result, err := collectDocPages(rt, "search_documents", "documents", params, searchDocsProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
docs := searchDocsProject(data)
return rt.Output(map[string]any{"count": len(docs), "documents": docs})
return rt.Output(result)
},
}
@@ -241,6 +246,9 @@ var List = shortcut.Shortcut{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"},
},
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -251,18 +259,17 @@ var List = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 50
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "list_nodes", params)
result, err := collectDocPages(rt, "list_nodes", "nodes", params, listNodesProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
nodes := listNodesProject(data)
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
return rt.Output(result)
},
}
@@ -414,7 +421,7 @@ var CommentList = shortcut.Shortcut{
Command: "+comment-list",
Product: productComment,
Description: "查询文档评论列表",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -436,26 +443,26 @@ var CommentList = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档评论列表",
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。"},
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws doc +comment-list --node DOC_ID",
"dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved",
"dws doc +comment-list --node DOC_ID --limit 20",
"dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN",
},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)", Aliases: []string{"page-size"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"},
{Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}},
{Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}},
},
Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`},
Tips: []string{`dws doc +comment-list --node DOC_ID --limit 20`, `dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
if rt.Changed("limit") || rt.Changed("page-size") {
params["pageSize"] = rt.IntFirst("limit", "page-size")
}
if v := rt.Str("cursor"); v != "" {
params["nextToken"] = v
@@ -470,54 +477,44 @@ var CommentList = shortcut.Shortcut{
},
}
const commentCreateTargetConstraint = "selection 与 block-id/start/end 高级通道互斥;block-id/start/end 必须一起提供;selection 必须唯一匹配"
var CommentCreate = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create",
Product: productComment,
Description: "在文档上创建一条评论",
Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。",
Description: "创建全文评论,或按 selection 创建划词评论",
Intent: "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "shortcut_comment_create",
CanonicalPath: "doc.shortcut_comment_create",
CLIPath: "doc +comment-create",
PrimaryCLIPath: "doc +comment-create",
},
Description: "在文档上创建一条评论",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "在文档上创建一条评论",
UseWhen: []string{"当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +comment-create --node DOC_ID --content \"这里需要修改\""},
},
},
Contract: docContract("+comment-create", "创建全文评论,或按 selection 创建划词评论",
"当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
[]string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "content", Property: "content"},
contract.ParamDecl{Name: "selection", Property: "selection"},
contract.ParamDecl{Name: "block-id", Property: "blockId"},
contract.ParamDecl{Name: "start", Property: "start"},
contract.ParamDecl{Name: "end", Property: "end"},
contract.ParamDecl{Name: "selected-text", Property: "selectedText"},
contract.ParamDecl{Name: "mention", Property: "mention"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_comment", params)
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀;" + commentCreateTargetConstraint},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;" + commentCreateTargetConstraint},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内 UTF-16 起始偏移;" + commentCreateTargetConstraint},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内 UTF-16 结束偏移;" + commentCreateTargetConstraint},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "可选引用原文;CLI 会从 block 回读并交叉校验"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: commentCreateTargetConstraint}},
Tips: []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
var CommentReply = shortcut.Shortcut{
@@ -557,7 +554,7 @@ var CommentReply = shortcut.Shortcut{
{Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true},
{Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -570,7 +567,11 @@ var CommentReply = shortcut.Shortcut{
params["emoji"] = true
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("reply_comment", params)
},
@@ -580,9 +581,10 @@ var CommentCreateInline = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create-inline",
Product: productComment,
Description: "在文档选中文本区域上创建划词评论",
Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。",
Description: "兼容入口:按 block/start/end 创建划词评论",
Intent: "仅兼容既有调用;新任务统一使用 +comment-create 的 selection 或 block/start/end 通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
@@ -590,25 +592,12 @@ var CommentCreateInline = shortcut.Shortcut{
{Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
"blockId": rt.Str("block-id"),
"start": rt.Int("start"),
"end": rt.Int("end"),
}
if v := rt.Str("selected-text"); v != "" {
params["selectedText"] = v
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_inline_comment", params)
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"block-id", "start", "end"}, Description: "block-id/start/end 必须一起提供,CLI 回读并校验 selectedText"}},
Tips: []string{`dws doc +comment-create --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
// ── 协作权限 ─────────────────────────────────────────────────
@@ -620,7 +609,7 @@ var ExportSubmit = shortcut.Shortcut{
Command: "+export-submit",
Product: productDoc,
Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。",
Intent: "仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -642,25 +631,25 @@ var ExportSubmit = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
UseWhen: []string{"当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
UseWhen: []string{"仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。"},
AvoidWhen: []string{"正常导出和保存本地文件使用 doc +export;不要手工编排 submit/get,也不要用它绕过 +export 的安全下载"},
Examples: []string{"dws doc +export-submit --node DOC_ID --export-format markdown"},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx", Enum: []string{"docx", "markdown", "pdf"}},
},
Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`},
Execute: func(rt *shortcut.RuntimeContext) error {
format := rt.Str("export-format")
if format == "" {
format = "docx"
}
return rt.CallMCP("submit_export_job", map[string]any{
result, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{
"nodeId": rt.Str("node"),
"exportFormat": format,
"exportFormat": rt.Str("export-format"),
})
if err != nil {
return docUnknownWriteError("doc.export_submit", "submit_export_job", rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.export_submit", map[string]any{"nodeId": rt.Str("node"), "result": result}, map[string]any{"name": "submit_export_job", "status": "success"}))
},
}
@@ -669,7 +658,7 @@ var ExportGet = shortcut.Shortcut{
Command: "+export-get",
Product: productDoc,
Description: "根据 jobId 查询文档导出任务结果",
Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。",
Intent: "当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -691,18 +680,24 @@ var ExportGet = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "根据 jobId 查询文档导出任务结果",
UseWhen: []string{"当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +export-get --job-id JOB_ID"},
UseWhen: []string{"当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。"},
AvoidWhen: []string{"尚未提交导出或没有真实 jobId 时不要使用;正常首次导出使用 doc +export"},
Examples: []string{"dws doc +export-get --job-id JOB_ID", "dws doc +export-get --job-id JOB_ID --output ./exports/"},
},
},
Flags: []shortcut.Flag{
{Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "可选:任务完成后安全下载到工作目录内相对路径"},
},
Tips: []string{`dws doc +export-get --job-id JOB_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")})
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("output") == "" {
return nil
}
return localio.ValidateOutput(rt.Str("output"))
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "提供 --output 时必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +export-get --job-id JOB_ID`, `dws doc +export-get --job-id JOB_ID --output ./exports/`},
Execute: executeExportGet,
}
// ── 历史版本 (server: doc) ───────────────────────────────────
@@ -938,17 +933,57 @@ var TemplateSearch = shortcut.Shortcut{
},
Tips: []string{`dws doc +template-search --query "周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"searchName": rt.Str("query")}
pageSize := rt.Int("limit")
if pageSize <= 0 {
pageSize = 50
}
params := map[string]any{"searchName": rt.Str("query"), "maxResults": pageSize}
if v := rt.Str("source"); v != "" {
params["templateSource"] = v
}
if rt.Changed("limit") {
params["maxResults"] = rt.Int("limit")
}
if v := rt.Str("cursor"); v != "" {
params["nextCursor"] = v
}
return rt.CallMCP("search_doc_templates", params)
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return err
}
candidates := collectTemplateCandidates(found)
hasMore, hasMoreKnown, nextCursor := docPageState(found)
nextCursor = strings.TrimSpace(nextCursor)
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && nextCursor == "" && len(candidates) < pageSize {
complete = true
}
globalComplete := complete && rt.Str("cursor") == ""
status := "selection_required"
if globalComplete && len(candidates) == 0 {
status = "not_found"
} else if globalComplete && len(candidates) == 1 {
status = "resolved"
}
selectedTemplateID := ""
if status == "resolved" {
selectedTemplateID = candidates[0]["templateId"].(string)
}
nextAction := map[string]string{"resolved": "create_once", "not_found": "stop", "selection_required": "ask_user"}[status]
if !complete {
nextAction = "continue_search"
}
return rt.Output(docEnvelope("doc.template_search", map[string]any{
"query": rt.Str("query"),
"source": rt.Str("source"),
"count": len(candidates),
"candidates": candidates,
"complete": complete,
"hasMore": !complete,
"nextCursor": nextCursor,
"selection": map[string]any{
"status": status,
"templateId": selectedTemplateID,
"nextAction": nextAction,
},
}))
},
}
@@ -986,7 +1021,6 @@ func init() {
CommentCreateInline.Contract = corecmd.ContractDecl{}
TemplateApply.Contract = corecmd.ContractDecl{}
canonicalizeHistoryShortcuts()
canonicalizeCommentShortcuts()
shortcut.Register(
Search,
List,
@@ -998,9 +1032,9 @@ func init() {
CommentCreateInline,
ExportSubmit,
ExportGet,
legacyVersionSave,
legacyVersionList,
legacyVersionRevert,
compatHistorySave,
compatHistoryList,
compatHistoryRevert,
VersionSave,
VersionList,
VersionRevert,
+695 -40
View File
@@ -7,15 +7,18 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
@@ -26,8 +29,10 @@ import (
)
type docCoverageCaller struct {
mu sync.Mutex
failAt int
calls int
dryRun bool
responses map[string][]map[string]any
ctx context.Context
history []docCoverageCall
@@ -43,12 +48,17 @@ type docCoverageErrorReader struct{}
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
if f.failAt == f.calls {
return nil, errors.New("injected doc coverage failure")
}
value := docCoveragePayload(tool)
if tool == "revert_doc_version" {
value = map[string]any{"version": params["version"]}
}
if queue := f.responses[tool]; len(queue) > 0 {
value = queue[0]
f.responses[tool] = queue[1:]
@@ -61,7 +71,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m
}
func (f *docCoverageCaller) Format() string { return "json" }
func (f *docCoverageCaller) DryRun() bool { return false }
func (f *docCoverageCaller) DryRun() bool { return f.dryRun }
func (f *docCoverageCaller) Fields() string { return "" }
func (f *docCoverageCaller) JQ() string { return "" }
@@ -70,9 +80,9 @@ func docCoveragePayload(tool string) map[string]any {
case "create_document":
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
case "get_document_content":
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
return map[string]any{"data": map[string]any{"revision": 1, "markdown": "alpha beta x body gamma legacy []", "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta x body gamma legacy []"]]`}}
case "list_document_blocks":
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta x body gamma []"}}}}}
case "submit_export_job":
return map[string]any{"jobId": "job-1"}
case "query_export_job":
@@ -156,6 +166,224 @@ func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testin
}
}
func TestCrossPlatformCoverageFetchResolvesUniqueTitleBeforeRead(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{map[string]any{"nodeId": "resolved-node", "name": "项目周报", "docType": "adoc"}},
"hasMore": false,
}},
}}
if err := runDocCoverage(t, Fetch, caller, "--query", "项目周报"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].tool != "search_documents" || caller.history[1].tool != "get_document_content" {
t.Fatalf("fetch resolution calls = %#v", caller.history)
}
if caller.history[1].params["nodeId"] != "resolved-node" {
t.Fatalf("resolved fetch params = %#v", caller.history[1].params)
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{
map[string]any{"nodeId": "a", "name": "项目周报", "docType": "adoc"},
map[string]any{"nodeId": "b", "name": "项目周报", "docType": "adoc"},
},
"hasMore": false,
}},
}}
if err := runDocCoverage(t, Fetch, ambiguous, "--query", "项目周报"); err == nil {
t.Fatal("ambiguous title unexpectedly succeeded")
}
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_documents" {
t.Fatalf("ambiguous fetch reached content read: %#v", ambiguous.history)
}
}
func TestCrossPlatformCoverageTemplateGoldenRouteStopsBeforeAmbiguousCreate(t *testing.T) {
payload := map[string]any{
"templates": []any{
map[string]any{"templateId": "template-b", "templateName": "团队周报", "templateSource": "PUBLIC"},
map[string]any{"templateId": "template-a", "templateName": "运营周报", "templateSource": "PUBLIC"},
},
}
candidates := collectTemplateCandidates(payload)
if len(candidates) != 2 || candidates[0]["templateId"] != "template-b" || candidates[1]["templateId"] != "template-a" {
t.Fatalf("template candidates = %#v", candidates)
}
search := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
if err := runDocCoverage(t, TemplateSearch, search, "--query", "周报", "--source", "PUBLIC"); err != nil {
t.Fatal(err)
}
if len(search.history) != 1 || search.history[0].tool != "search_doc_templates" {
t.Fatalf("template search calls = %#v", search.history)
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报", "--source", "PUBLIC")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" || typed.ExecutionStarted == nil || *typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("ambiguous template error = %#v", err)
}
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_doc_templates" {
t.Fatalf("ambiguous template reached write: %#v", ambiguous.history)
}
if got, ok := typed.Details["candidates"].([]map[string]any); !ok || len(got) != 2 {
t.Fatalf("ambiguous template details = %#v", typed.Details)
}
notFound := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}}}}}
err = runDocCoverage(t, CreateFromTemplate, notFound, "--query", "不存在")
if !errors.As(err, &typed) || typed.Reason != "template_not_found" {
t.Fatalf("not-found template error = %#v", err)
}
if selection := CreateFromTemplate.Contract.Selection; len(selection.AvoidWhen) < 2 || !strings.Contains(strings.Join(selection.AvoidWhen, " "), "+template-search") {
t.Fatalf("create-from-template selection = %#v", selection)
}
mutualRoutes := []struct {
name string
selection contract.SelectionSpec
siblings []string
}{
{name: "+template-list", selection: TemplateList.Contract.Selection, siblings: []string{"+template-search", "+create-from-template"}},
{name: "+template-search", selection: TemplateSearch.Contract.Selection, siblings: []string{"+template-list", "+create-from-template"}},
{name: "+create-from-template", selection: CreateFromTemplate.Contract.Selection, siblings: []string{"+template-list", "+template-search"}},
}
for _, route := range mutualRoutes {
avoid := strings.Join(route.selection.AvoidWhen, " ")
for _, sibling := range route.siblings {
if !strings.Contains(avoid, sibling) {
t.Errorf("%s Selection does not exclude sibling %s: %#v", route.name, sibling, route.selection)
}
}
}
}
func TestCrossPlatformCoverageTemplateCreateProvesUniquenessAcrossPages(t *testing.T) {
firstPage := map[string]any{
"templates": []any{map[string]any{"templateId": "template-a", "templateName": "周报"}},
"hasMore": true,
"nextCursor": "page-2",
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
"search_doc_templates": {
firstPage,
{"templates": []any{map[string]any{"templateId": "template-b", "templateName": "周报二"}}, "hasMore": false},
},
}}
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" {
t.Fatalf("cross-page ambiguity = %#v", err)
}
if len(ambiguous.history) != 2 || ambiguous.history[1].params["nextCursor"] != "page-2" {
t.Fatalf("cross-page search calls = %#v", ambiguous.history)
}
unique := &docCoverageCaller{responses: map[string][]map[string]any{
"search_doc_templates": {
firstPage,
{"templates": []any{}, "hasMore": false},
},
}}
if err := runDocCoverage(t, CreateFromTemplate, unique, "--query", "周报"); err != nil {
t.Fatal(err)
}
if len(unique.history) != 3 || unique.history[2].tool != "apply_doc_template" || unique.history[2].params["templateId"] != "template-a" {
t.Fatalf("unique cross-page create calls = %#v", unique.history)
}
missingCursor := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {firstPage}}}
missingCursor.responses["search_doc_templates"][0] = map[string]any{
"templates": []any{map[string]any{"templateId": "template-a"}},
"hasMore": true,
}
err = runDocCoverage(t, CreateFromTemplate, missingCursor, "--query", "周报")
if !errors.As(err, &typed) || typed.Reason != "template_pagination_missing_next_cursor" || len(missingCursor.history) != 1 {
t.Fatalf("missing template cursor = %#v history=%#v", err, missingCursor.history)
}
}
func TestCrossPlatformCoverageExportRecoveryReusesJobAndSafeDownloader(t *testing.T) {
for _, status := range []string{"INIT", "init", " PROCESSING "} {
if !docExportStatusPollable(status) {
t.Errorf("export status %q should remain pollable", status)
}
}
for _, status := range []string{"", "FAILED", "CANCELLED", "SUCCESS"} {
if docExportStatusPollable(status) {
t.Errorf("export status %q should not remain pollable", status)
}
}
init := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "INIT"}}}}
err := runDocCoverage(t, Export, init, "--node", "n", "--export-format", "docx", "--output", "export.docx", "--max-polls", "1")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_timeout" {
t.Fatalf("INIT export status = %#v, want poll timeout", err)
}
pollFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, Export, pollFailure, "--node", "n", "--export-format", "docx", "--output", "export.docx")
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_failed" || typed.Details["jobId"] != "job-1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
t.Fatalf("export poll error = %#v", err)
}
if !strings.Contains(strings.Join(typed.Actions, " "), "+export-get --job-id job-1") {
t.Fatalf("export recovery actions = %#v", typed.Actions)
}
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, opts localio.DownloadOptions) (localio.DownloadResult, error) {
if opts.Output != "recovered.docx" {
t.Fatalf("recovery output = %#v", opts)
}
return localio.DownloadResult{RelativePath: "recovered.docx", SizeBytes: 42}, nil
})
recovery := &docCoverageCaller{responses: map[string][]map[string]any{
"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/recovered.docx"}},
}}
if err := runDocCoverage(t, ExportGet, recovery, "--job-id", "job-1", "--output", "recovered.docx"); err != nil {
t.Fatal(err)
}
if len(recovery.history) != 1 || recovery.history[0].tool != "query_export_job" {
t.Fatalf("export recovery calls = %#v", recovery.history)
}
if err := runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--job-id", "job-1", "--output", "/tmp/unsafe.docx"); err == nil {
t.Fatal("absolute recovery output unexpectedly accepted")
}
}
func TestCrossPlatformCoverageCommentListPaginationFlagsAndAlias(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentList, caller, "--node", "n", "--page-size", "20", "--cursor", "next-1"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 1 || caller.history[0].tool != "list_comments" || caller.history[0].params["pageSize"] != 20 || caller.history[0].params["nextToken"] != "next-1" {
t.Fatalf("comment pagination call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageMediaFailuresKeepStableIDsAndForbidPathEscape(t *testing.T) {
if err := runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "/tmp/media.bin", "--dry-run", "--yes"); err == nil {
t.Fatal("absolute media input unexpectedly accepted")
}
if err := runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "/tmp/report.docx", "--workspace", "workspace-1"); err == nil {
t.Fatal("absolute import input unexpectedly accepted")
}
resolveFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, MediaDownload, resolveFailure, "--node", "node-1", "--resource-id", "resource-1", "--output", "media.bin")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_media_resolve_failed" || typed.Details["nodeId"] != "node-1" || typed.Details["resourceId"] != "resource-1" {
t.Fatalf("media recovery error = %#v", err)
}
if !strings.Contains(strings.Join(typed.Actions, " "), "不要 curl/wget") {
t.Fatalf("media recovery actions = %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
t.Helper()
@@ -184,7 +412,7 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
}
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml")
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
compensation, _ := typed.Details["compensation"].(map[string]any)
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
@@ -206,6 +434,13 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
checkpointMismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"save_doc_version": {{"version": 8.0}},
"get_document_content": {{"markdown": "different content"}},
}}
err = runDocCoverage(t, CheckpointUpdate, checkpointMismatch, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
@@ -215,22 +450,31 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
tests := []struct {
name string
args []string
responses map[string][]map[string]any
wantTools []string
}{
{
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
wantTools: []string{"list_document_blocks", "update_document_block"},
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}},
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "gamma beta"}}}}},
}},
wantTools: []string{"list_document_blocks", "update_document_block", "list_document_blocks"},
},
{
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
wantTools: []string{"list_document_blocks", "insert_document_block"},
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha"}}}}},
{"blocks": []any{map[string]any{"element": map[string]any{"id": "id-1", "paragraph": map[string]any{"text": "alpha"}}}}},
}},
wantTools: []string{"list_document_blocks", "insert_document_block", "list_document_blocks"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
caller := &docCoverageCaller{responses: tc.responses}
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
t.Fatal(err)
}
@@ -246,6 +490,246 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
}
}
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_commit_unknown" || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown write error = %#v", err)
}
if len(unknown.history) != 1 || unknown.history[0].tool != "update_document" {
t.Fatalf("unknown write was replayed or verified: %#v", unknown.history)
}
verificationFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, Update, verificationFailure, "--node", "n", "--command", "append", "--content", "x", "--yes")
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.Details["status"] != "partial_success" {
t.Fatalf("verification failure = %#v", err)
}
if len(verificationFailure.history) != 2 || verificationFailure.history[1].tool != "get_document_content" {
t.Fatalf("verification calls = %#v", verificationFailure.history)
}
}
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "truncated"}},
}}
err := runDocCoverage(t, Create, caller, "--name", "n", "--content", "complete body")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.FailureStage != "verify" {
t.Fatalf("create mismatch error = %#v, want verification failure", err)
}
if len(caller.history) != 2 || caller.history[0].tool != "create_document" || caller.history[1].tool != "get_document_content" {
t.Fatalf("create mismatch calls = %#v, want one write followed by one read", caller.history)
}
}
func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *testing.T) {
for _, test := range []struct {
name string
command string
blockArg string
response string
}{
{name: "insert", command: "block_insert_after", blockArg: "--after-block-id", response: `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"id-1"},"after"]]`},
{name: "replace", command: "block_replace", blockArg: "--block-id", response: `["root",{},["p",{"uuid":"target"},"after"]]`},
} {
t.Run(test.name, func(t *testing.T) {
blockID := "ref"
if test.command == "block_replace" {
blockID = "target"
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {{"jsonml": test.response}},
}}
err := runDocCoverage(t, Update, caller,
"--node", "n", "--command", test.command, test.blockArg, blockID,
"--content", `["p",{},"after"]`, "--doc-format", "jsonml", "--yes")
if err != nil {
t.Fatal(err)
}
if got := caller.history[len(caller.history)-1].params["format"]; got != "jsonml" {
t.Fatalf("verification format = %#v, want jsonml", got)
}
})
}
}
func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testing.T) {
t.Run("target on second page", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_doc_versions": {
{"versions": []any{map[string]any{"version": 1.0}}, "hasMore": true, "nextCursor": "page-2"},
{"versions": []any{map[string]any{"version": 3.0}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 4 || caller.history[1].params["nextCursor"] != "page-2" {
t.Fatalf("version pagination calls = %#v", caller.history)
}
})
t.Run("empty write response and ordinary document info", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"revert_doc_version": {{}},
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
}
})
for _, test := range []struct {
name string
responses []map[string]any
want string
}{
{name: "not found", responses: []map[string]any{{"versions": []any{}, "hasMore": false}}, want: "目标版本 3 不存在"},
{name: "missing cursor", responses: []map[string]any{{"versions": []any{}, "hasMore": true}}, want: "无法证明分页已经完整"},
{name: "stalled cursor", responses: []map[string]any{
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
}, want: "无法证明分页已经完整"},
} {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": test.responses}}
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("version preflight error = %v, want %q", err, test.want)
}
for _, call := range caller.history {
if call.tool == "revert_doc_version" {
t.Fatalf("preflight failure executed revert: %#v", caller.history)
}
}
})
}
pages := make([]map[string]any, 20)
for index := range pages {
pages[index] = map[string]any{"versions": []any{}, "hasMore": true, "nextCursor": fmt.Sprintf("page-%d", index+1)}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": pages}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err == nil || !strings.Contains(err.Error(), "无法证明分页已经完整") {
t.Fatalf("max-pages preflight error = %v", err)
}
for _, value := range []any{json.Number("3"), 3, "3"} {
if !versionNumberMatches(value, 3) {
t.Errorf("version value %#v did not match", value)
}
}
for _, value := range []any{json.Number("bad"), 4, "bad", true} {
if versionNumberMatches(value, 3) {
t.Errorf("version value %#v unexpectedly matched", value)
}
}
if !versionEvidenceMatches([]any{map[string]any{"target-version": "3"}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
if versionEvidenceMatches(map[string]any{"version": 4}, 3, map[string]bool{"version": true}) {
t.Fatal("mismatched version evidence unexpectedly matched")
}
}
func TestCrossPlatformCoverageDocWriteErrorStateMachine(t *testing.T) {
tests := []struct {
name string
cause error
wantState string
wantReason string
wantRetry bool
}{
{
name: "permission is terminal",
cause: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"), apperrors.WithRetryable(false)),
wantState: "failed",
wantReason: "permission_denied",
},
{
name: "confirmed not started may retry",
cause: apperrors.NewAPI("rate limited", apperrors.WithReason("http_429"),
apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)),
wantState: "retryable",
wantReason: "http_429",
wantRetry: true,
},
{
name: "unknown write never replays",
cause: errors.New("connection reset"),
wantState: "unknown",
wantReason: "doc_write_commit_unknown",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := docUnknownWriteError("doc.update", "update_document", "n", tc.cause)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if typed.Details["state"] != tc.wantState || typed.Reason != tc.wantReason || typed.Retryable != tc.wantRetry {
t.Fatalf("transition = reason=%q retry=%v details=%#v", typed.Reason, typed.Retryable, typed.Details)
}
})
}
}
func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
long := strings.Repeat("段落😀", 4000)
chunks := splitDocMarkdown(long, 10000)
if len(chunks) < 2 || strings.Join(chunks, "") != long {
t.Fatalf("split chunks=%d roundtrip=%v", len(chunks), strings.Join(chunks, "") == long)
}
update := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": long}},
}}
if err := runDocCoverage(t, Update, update, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err != nil {
t.Fatal(err)
}
if len(update.history) != len(chunks)+1 || update.history[0].params["mode"] != "overwrite" || update.history[1].params["mode"] != "append" || update.history[len(update.history)-1].tool != "get_document_content" {
t.Fatalf("long update calls = %#v", update.history)
}
partial := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, partial, "--node", "n", "--command", "append", "--content", long, "--yes")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_update_chunk_commit_unknown" || len(partial.history) != 2 {
t.Fatalf("partial long update err=%#v calls=%#v", err, partial.history)
}
create := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": long}},
}}
if err := runDocCoverage(t, Create, create, "--name", "long", "--content", long); err != nil {
t.Fatal(err)
}
if len(create.history) != len(chunks)+1 || create.history[0].tool != "create_document" || create.history[1].tool != "update_document" || create.history[len(create.history)-1].tool != "get_document_content" {
t.Fatalf("long create calls = %#v", create.history)
}
}
func TestCrossPlatformCoverageUpdateVerificationNormalizesMarkdownRoundTrip(t *testing.T) {
expected := "# 值班表\r\n\r\n| 姓名 | 班次 |\r\n| --- | --- |\r\n| 小王 | 早班 |\r\n"
serverMarkdown := "# 值班表\n\n|姓名|班次|\n|---|---|\n|小王|早班|\n"
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"data": map[string]any{"markdown": serverMarkdown}}},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "overwrite", "--content", expected, "--yes"); err != nil {
t.Fatalf("normalized Markdown verification failed: %v", err)
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "已有内容\n" + serverMarkdown}, expected, "append", "markdown") {
t.Fatal("normalized append verification did not find the appended content")
}
if verifyUpdatedDocumentContent(map[string]any{"markdown": "# 值班表\n|姓名|班次|"}, expected, "overwrite", "markdown") {
t.Fatal("incomplete overwrite content passed normalized verification")
}
}
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
tests := []struct {
name string
@@ -378,20 +862,20 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
{"inspect base", Inspect, []string{"--node", "n"}},
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}},
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
{"export dry", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
}
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
@@ -400,6 +884,43 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
switch tc.name {
case "create markdown":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "body"}}
case "create jsonml file":
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{},"body"]`}}
case "create stdin":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "stdin body"}}
}
if tc.name == "update overwrite jsonml" || tc.name == "update revision" {
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{}]`}}
}
switch tc.name {
case "update append":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
case "update insert text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "x"}}}}
case "update insert jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"reference"],["p",{"uuid":"id-1"},"x"]]`}}
case "update replace text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "x"}}}}
case "update replace jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"x"]]`}}
case "update delete":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "other", "text": "x"}}}}
case "update replace":
caller.responses["list_document_blocks"] = []map[string]any{
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
{"items": []any{map[string]any{"id": "block-1", "text": "gamma beta"}}},
}
case "update copy":
caller.responses["list_document_blocks"] = []map[string]any{
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "alpha beta"}}},
}
case "checkpoint success":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
}
var err error
if tc.name == "create stdin" {
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
@@ -415,18 +936,73 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
for failAt := 1; failAt <= 7; failAt++ {
args := map[string][]string{
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
"+create": {"--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"},
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
"+update": {"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
"+export": {"--node", "n", "--output", "out.docx"},
"+export": {"--node", "n", "--export-format", "docx", "--output", "out.docx"},
}[command.Command]
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
}
func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) {
flags := make(map[string]shortcut.Flag, len(Update.Flags))
for _, flag := range Update.Flags {
flags[flag.Name] = flag
}
if !flags["node"].Required || flags["command"].Required {
t.Fatalf("unconditional required flags: node=%v command=%v", flags["node"].Required, flags["command"].Required)
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
if got := flags[name].RequiredWhen; got != "" {
t.Errorf("--%s RequiredWhen = %q, want compatibility-safe custom constraint", name, got)
}
}
if len(Update.Constraints) != 1 || Update.Constraints[0].Kind != shortcut.ConstraintCustom ||
!strings.Contains(Update.Constraints[0].Description, "依 command 校验") {
t.Fatalf("update custom constraint = %#v", Update.Constraints)
}
cmd := corecmd.New(shortcut.FromShortcut(Update))
for _, alias := range []string{"doc", "text"} {
flag := cmd.Flags().Lookup(alias)
if flag == nil {
t.Errorf("compatibility alias --%s is not mounted", alias)
}
}
invalid := []struct {
name string
args []string
want string
}{
{name: "missing command", args: []string{"--node", "n"}, want: "--command"},
{name: "insert missing reference", args: []string{"--node", "n", "--command", "block_insert_after", "--content", "x"}, want: "--after-block-id"},
{name: "copy missing reference", args: []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "b"}, want: "--after-block-id"},
{name: "jsonml append", args: []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml"}, want: "JSONML 当前不支持 append"},
{name: "revision without server CAS path", args: []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1"}, want: "仅支持 --command overwrite --doc-format jsonml"},
}
for _, test := range invalid {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, caller, test.args...)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
if strings.Contains(err.Error(), "confirmation_required") || caller.calls != 0 {
t.Fatalf("invalid input reached confirmation or execution: err=%v calls=%d", err, caller.calls)
}
})
}
if err := runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}},
"--node", "n", "--command", "str_replace", "--old", "x", "--new", "", "--dry-run"); err != nil {
t.Fatalf("explicit empty --new should satisfy str_replace contract: %v", err)
}
}
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
@@ -442,6 +1018,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Create, []string{"--name", "n", "--content", "@/absolute"}},
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}},
{Fetch, []string{"--node", "n", "--revision", "1"}},
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
{Update, []string{"--node", "n"}},
@@ -449,25 +1027,28 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
}
for _, tc := range badCases {
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
}
}
absoluteInputErr := runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@/absolute")
if absoluteInputErr == nil || !strings.Contains(absoluteInputErr.Error(), "暂存到工作目录") || !strings.Contains(absoluteInputErr.Error(), "stdin") {
t.Fatalf("absolute @file guidance = %v", absoluteInputErr)
}
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"); err == nil {
t.Fatal("jsonml create without node id succeeded")
}
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("revision conflict succeeded")
revision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"jsonml": `["root",{}]`}}}}
if err := runDocCoverage(t, Update, revision, "--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "7", "--yes"); err != nil {
t.Fatalf("server revision update failed: %v", err)
}
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("missing revision succeeded")
if len(revision.history) < 1 || revision.history[0].tool != "update_document" || revision.history[0].params["revision"] != 7 {
t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history)
}
for _, value := range []any{
@@ -482,6 +1063,9 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
if _, err := validateJSONML(`{}`); err == nil {
t.Fatal("object jsonml succeeded")
}
if _, err := validateJSONML(`[["p",{},"x"]]`); err == nil {
t.Fatal("nested element-array jsonml succeeded")
}
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
t.Fatal("nestedMap did not unwrap")
}
@@ -496,8 +1080,11 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
stripBlockIDs(blocks)
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("visible update flag aliases: %v", err)
aliasCaller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "legacy"}},
}}
if err := runDocCoveragePath(t, Update, aliasCaller, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("compatibility aliases --doc/--text must remain executable: %v", err)
}
missingNode := Update
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
@@ -562,19 +1149,19 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{"jobId": "j"},
} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
_ = runDocCoverage(t, Export, caller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
}
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "0")
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--export-format", "docx", "--output", "x")
}
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
@@ -596,7 +1183,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "block-1", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
{MediaList, []string{"--node", "n"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
@@ -620,7 +1207,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
args := map[string][]string{
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
"+version-revert": {"--node", "n", "--version", "3", "--yes"},
"+create-from-template": {"--query", "q"},
"+review": {"--node", "n"},
"+media-list": {"--node", "n"},
@@ -676,6 +1263,27 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--dry-run"); err != nil {
t.Fatalf("import to default root failed: %v", err)
}
if err := runDocCoverage(t, Export, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--node", "n", "--output", "out.docx", "--dry-run"); err != nil {
t.Fatalf("export default format failed: %v", err)
}
if selected, ok := sliceUTF16Range("A😀B", 1, 3); !ok || selected != "😀" {
t.Fatalf("UTF-16 selection = %q/%v", selected, ok)
}
if _, ok := sliceUTF16Range("A😀B", 2, 3); ok {
t.Fatal("selection splitting a surrogate pair succeeded")
}
for _, raw := range []string{`["550582"]`, `[550582]`} {
mentions, err := normalizeMentionUserIDs([]string{raw})
if err != nil || len(mentions) != 1 || mentions[0] != "550582" {
t.Fatalf("mention normalization %q = %#v/%v", raw, mentions, err)
}
}
if _, err := normalizeMentionUserIDs([]string{`[{"uid":"550582"}]`}); err == nil {
t.Fatal("object mention unexpectedly succeeded")
}
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
@@ -704,6 +1312,53 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
})
}
func TestCrossPlatformCoverageVersionRoutesAreCanonicalAndHistoryRoutesAreCompatible(t *testing.T) {
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "doc" {
registered[item.Command] = item
}
}
pairs := map[string]string{
"+history-save": "+version-save",
"+history-list": "+version-list",
"+history-revert": "+version-revert",
}
for compatibility, canonical := range pairs {
compatItem, ok := registered[compatibility]
if !ok {
t.Fatalf("missing compatibility route %s", compatibility)
}
canonicalItem, ok := registered[canonical]
if !ok {
t.Fatalf("missing canonical route %s", canonical)
}
if compatItem.Disposition != shortcut.DispositionAliasInternal || compatItem.PrimaryCommand != canonical {
t.Errorf("%s routing = %s/%s, want alias_internal/%s", compatibility, compatItem.Disposition, compatItem.PrimaryCommand, canonical)
}
if !strings.Contains(strings.Join(compatItem.Contract.Selection.AvoidWhen, " "), canonical) {
t.Errorf("%s compatibility Selection does not route Agent to %s", compatibility, canonical)
}
if canonicalItem.Contract.Selection.UseWhen[0] != canonicalItem.Intent {
t.Errorf("%s UseWhen and Intent drifted", canonical)
}
if strings.Contains(strings.Join(canonicalItem.Contract.Selection.AvoidWhen, " "), "+history-") {
t.Errorf("%s canonical Selection routes back to history compatibility commands", canonical)
}
}
if VersionSave.Command != "+version-save" || VersionSave.Safety.Confirmation != "user_required" {
t.Errorf("version-save command/confirmation = %s/%s", VersionSave.Command, VersionSave.Safety.Confirmation)
}
if compatHistorySave.Safety.Confirmation != "not_required" {
t.Errorf("history-save compatibility confirmation = %s", compatHistorySave.Safety.Confirmation)
}
if VersionRevert.Command != "+version-revert" || VersionRevert.Execute == nil {
t.Errorf("version-revert canonical smart route is incomplete")
}
}
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
@@ -713,7 +1368,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
@@ -727,7 +1382,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
@@ -4,112 +4,165 @@
package doc
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
legacyVersionSave shortcut.Shortcut
legacyVersionList shortcut.Shortcut
legacyVersionRevert shortcut.Shortcut
compatHistorySave shortcut.Shortcut
compatHistoryList shortcut.Shortcut
compatHistoryRevert shortcut.Shortcut
)
func canonicalizeHistoryShortcuts() {
legacyVersionSave = VersionSave
legacyVersionList = VersionList
legacyVersionRevert = VersionRevert
VersionSave.Command = "+history-save"
VersionSave.Command = "+version-save"
VersionSave.Aliases = nil
VersionSave.Description = "手动保存当前文档版本快照"
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
VersionSave.Intent = "当用户要求保存、创建或建立当前文档版本快照时使用;只保存快照,不更新正文。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
VersionSave.Contract = versionSaveContract()
VersionSave.Tips = []string{`dws doc +version-save --node <DOC_ID>`}
VersionList.Command = "+history-list"
VersionList.Command = "+version-list"
VersionList.Aliases = nil
VersionList.Description = "分页列出文档历史版本"
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
VersionList.Contract = versionListContract()
VersionList.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回版本数量上限", Aliases: []string{"page-size"}, AliasesVisible: true},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
}
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
VersionList.Tips = []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`}
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if size := rt.IntFirst("page-size", "limit"); size > 0 {
if size := rt.IntFirst("limit", "page-size"); size > 0 {
params["maxResults"] = size
}
if token := rt.StrFirst("page-token", "cursor"); token != "" {
if token := rt.StrFirst("cursor", "page-token"); token != "" {
params["nextCursor"] = token
}
return rt.CallMCP("list_doc_versions", params)
}
VersionRevert.Command = "+history-revert"
VersionRevert.Command = "+version-revert"
VersionRevert.Aliases = nil
VersionRevert.Description = "预检并回滚文档到指定历史版本"
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
VersionRevert.Contract = versionRevertContract()
VersionRevert.Tips = []string{`dws doc +version-revert --node <DOC_ID> --version 3`}
VersionRevert.Execute = executeHistoryRevert
compatHistorySave = compatibilityHistoryShortcut(VersionSave, "+history-save", "+version-save")
compatHistorySave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
compatHistoryList = compatibilityHistoryShortcut(VersionList, "+history-list", "+version-list")
compatHistoryRevert = compatibilityHistoryShortcut(VersionRevert, "+history-revert", "+version-revert")
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
TemplateSearch.Description = "按名称检索文档模板"
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用。"
TemplateList.Contract = templateListContract()
TemplateSearch.Description = "按名称或关键词检索文档模板"
TemplateSearch.Intent = "当用户已提供明确模板名称或关键词时使用;返回结构化候选和 resolved/not_found/selection_required 状态,零命中或多候选时停止创建。"
TemplateSearch.Contract = templateSearchContract()
}
func canonicalizeCommentShortcuts() {
// Preserve the historical confirmation contract for comment writes. The
// richer canonical implementations must not silently weaken that gate.
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
CommentCreate.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
func versionSaveContract() corecmd.ContractDecl {
decl := docContract("+version-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +version-save --node <DOC_ID>`})
decl.Selection.AvoidWhen = []string{
"只查看历史版本时使用 doc +version-list;需要恢复历史内容时使用 doc +version-revert",
"需要保存恢复点后继续修改正文时使用 doc +checkpoint-update",
}
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
CommentCreate.Validate = validateCommentCreate
CommentCreate.Execute = executeCommentCreate
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
return decl
}
func versionListContract() corecmd.ContractDecl {
decl := docContract("+version-list", VersionList.Description, VersionList.Intent, []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`})
decl.Selection.AvoidWhen = []string{
"需要创建当前版本快照时使用 doc +version-save",
"已经明确目标版本并要求恢复正文时使用 doc +version-revert",
}
return decl
}
func versionRevertContract() corecmd.ContractDecl {
decl := docContract("+version-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +version-revert --node <DOC_ID> --version 3`})
decl.Selection.AvoidWhen = []string{
"只需要创建当前版本快照时使用 doc +version-save",
"尚未确定目标版本号时先使用 doc +version-list",
}
return decl
}
func compatibilityHistoryShortcut(source shortcut.Shortcut, command, canonical string) shortcut.Shortcut {
result := source
result.Command = command
result.Aliases = nil
result.Description = fmt.Sprintf("兼容入口:%s", source.Description)
result.Intent = fmt.Sprintf("仅当调用方明确使用既有 doc %s 路径时兼容执行。", command)
result.Contract = docContract(command, result.Description, result.Intent, []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)})
result.Contract.Selection.AvoidWhen = []string{fmt.Sprintf("新的 Agent 任务统一使用 doc %s;不要因自然语言版本意图选择本兼容入口", canonical)}
result.Tips = []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)}
if command == "+history-revert" {
result.Contract.Selection.Examples = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
result.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
}
return result
}
func templateSearchContract() corecmd.ContractDecl {
decl := docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
decl.Selection.AvoidWhen = []string{
"没有明确模板名称或关键词、只是浏览可用模板时使用 doc +template-list",
"已经拿到唯一 templateId 时不要重复搜索,直接使用 doc +create-from-template --template-id",
"不要为了预览多个候选而逐个创建文档;多候选必须让用户选择",
}
return decl
}
func templateListContract() corecmd.ContractDecl {
decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
decl.Selection.AvoidWhen = []string{
"已经有明确模板名称或关键词时使用 doc +template-search --query",
"已经拿到 templateId 且要创建文档时使用 doc +create-from-template --template-id",
}
return decl
}
func createFromTemplateContract() corecmd.ContractDecl {
decl := docContract("+create-from-template", "使用已选定的 templateId 创建文档",
"当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`})
decl.Selection.AvoidWhen = []string{
"只有模板名称或关键词时先使用 doc +template-search;零命中或多候选时停止并让用户选择",
"没有模板名称、关键词或 templateId,只想浏览可用模板时使用 doc +template-list",
"不要通过创建多个候选文档来预览模板,也不要反复改写关键词自动重试",
}
return decl
}
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
nodeID := rt.Str("node")
target := rt.Int("version")
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
found, err := findHistoryVersion(rt, nodeID, target)
if err != nil {
return err
}
if !containsVersion(versions, target) {
if !found {
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
}
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
reverted, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target})
if err != nil {
return err
}
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
@@ -125,28 +178,111 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
"version": target, "revertResult": reverted, "current": current, "verified": true,
"verification": "revert_acknowledged_and_document_readable",
},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
const maxPages = 20
cursor := ""
seenCursors := map[string]bool{}
for page := 1; page <= maxPages; page++ {
params := map[string]any{"nodeId": nodeID}
if cursor != "" {
params["nextCursor"] = cursor
}
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", params)
if err != nil {
return false, err
}
if containsVersion(versions, target) {
return true, nil
}
hasMore, hasMoreKnown, nextCursor := docPageState(versions)
nextCursor = strings.TrimSpace(nextCursor)
if hasMoreKnown && !hasMore {
return false, nil
}
if nextCursor == "" {
if hasMoreKnown && hasMore {
return false, historyVersionPaginationError("missing_next_cursor", page, cursor)
}
return false, nil
}
if seenCursors[nextCursor] {
return false, historyVersionPaginationError("stalled_cursor", page, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
return false, historyVersionPaginationError("max_pages", maxPages, cursor)
}
func historyVersionPaginationError(reason string, page int, cursor string) error {
return apperrors.NewAPI(
"文档版本预检无法证明分页已经完整,已停止回滚",
apperrors.WithOperation("doc.history_revert"),
apperrors.WithReason("doc_history_version_"+reason),
apperrors.WithFailureStage("preflight"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithDetails(map[string]any{"page": page, "cursor": cursor, "targetVerified": false}),
)
}
func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if acceptedKeys[normalized] {
if versionNumberMatches(child, target) {
return true
}
}
if versionEvidenceMatches(child, target, acceptedKeys) {
return true
}
}
case []any:
for _, child := range typed {
if versionEvidenceMatches(child, target, acceptedKeys) {
return true
}
}
}
return false
}
func versionNumberMatches(value any, target int) bool {
switch number := value.(type) {
case float64:
return number == float64(target)
case json.Number:
parsed, err := number.Int64()
return err == nil && parsed == int64(target)
case int:
return number == target
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
return err == nil && parsed == target
}
return false
}
func containsVersion(value any, target int) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
switch number := child.(type) {
case float64:
if int(number) == target && number == float64(target) {
return true
}
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
if err == nil && parsed == target {
return true
}
if versionNumberMatches(child, target) {
return true
}
}
if containsVersion(child, target) {
@@ -167,39 +303,53 @@ var CreateFromTemplate = shortcut.Shortcut{
Service: "doc",
Command: "+create-from-template",
Product: productDoc,
Description: "按 templateId 直达或搜索消歧后创建文档",
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
Description: "使用已选定的 templateId 创建文档",
Intent: "当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
Contract: createFromTemplateContract(),
Flags: []shortcut.Flag{
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
{Name: "query", Type: shortcut.FlagString, Desc: "兼容入口:先搜索且仅唯一命中时创建;新的 Agent 流程应先调用 +template-search"},
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
templateID := rt.Str("template-id")
if templateID == "" {
params := map[string]any{"searchName": rt.Str("query")}
if rt.Str("source") != "" {
params["templateSource"] = rt.Str("source")
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
candidates, err := searchTemplateCandidatesForCreate(rt, rt.Str("query"), rt.Str("source"))
if err != nil {
return err
}
ids := collectTemplateIDs(found)
if len(ids) != 1 {
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
if len(candidates) != 1 {
status := "selection_required"
message := fmt.Sprintf("模板搜索返回 %d 个候选,必须先选择唯一 templateId", len(candidates))
actions := []string{"运行 dws doc +template-search 查看结构化候选", "让用户选择后使用 --template-id 创建一次"}
if len(candidates) == 0 {
status = "not_found"
message = "模板搜索没有命中;已停止创建"
actions = []string{"检查模板来源或请用户提供更准确的模板名", "不要自动变换关键词循环搜索"}
}
return apperrors.NewValidation(message,
apperrors.WithOperation("doc.create_from_template"),
apperrors.WithReason("template_"+status),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions(actions...),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.template-selection.v1",
"status": status,
"query": rt.Str("query"),
"source": rt.Str("source"),
"candidates": candidates,
}),
)
}
templateID = ids[0]
templateID = candidates[0]["templateId"].(string)
}
params := map[string]any{"templateId": templateID}
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
@@ -212,12 +362,151 @@ var CreateFromTemplate = shortcut.Shortcut{
}
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
if err != nil {
return err
return docUnknownWriteError("doc.create_from_template", "apply_template", "", err)
}
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"templateId": templateID, "result": result}, map[string]any{"name": "apply_template", "status": "success"}))
},
}
func searchTemplateCandidatesForCreate(rt *shortcut.RuntimeContext, query, source string) ([]map[string]any, error) {
const (
pageSize = 2
maxPages = 20
)
cursor := ""
seenCursors := map[string]bool{}
seenCandidates := map[string]bool{}
candidates := make([]map[string]any, 0, pageSize)
for page := 1; page <= maxPages; page++ {
params := map[string]any{"searchName": query, "maxResults": pageSize}
if source != "" {
params["templateSource"] = source
}
if cursor != "" {
params["nextCursor"] = cursor
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return nil, err
}
pageCandidates := collectTemplateCandidates(found)
for _, candidate := range pageCandidates {
id, _ := candidate["templateId"].(string)
if id == "" || seenCandidates[id] {
continue
}
seenCandidates[id] = true
candidates = append(candidates, candidate)
}
hasMore, hasMoreKnown, next := docPageState(found)
next = strings.TrimSpace(next)
if len(candidates) > 1 {
return sortedTemplateCandidates(candidates), nil
}
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && next == "" && len(pageCandidates) < pageSize {
complete = true
}
if complete {
return sortedTemplateCandidates(candidates), nil
}
if next == "" {
return nil, templatePaginationError("missing_next_cursor", page, cursor, candidates)
}
if next == cursor || seenCursors[next] {
return nil, templatePaginationError("stalled_cursor", page, next, candidates)
}
seenCursors[next] = true
cursor = next
}
return nil, templatePaginationError("max_pages", maxPages, cursor, candidates)
}
func templatePaginationError(reason string, page int, cursor string, candidates []map[string]any) error {
return apperrors.NewAPI(
"模板搜索分页未完整结束;为避免基于非唯一候选创建文档,已停止执行",
apperrors.WithOperation("doc.create_from_template"),
apperrors.WithReason("template_pagination_"+reason),
apperrors.WithFailureStage("template_search"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions("使用 dws doc +template-search 分页查看候选", "选择唯一 templateId 后重新执行创建"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.template-selection.v1",
"status": "pagination_incomplete",
"reason": reason,
"page": page,
"nextCursor": cursor,
"candidates": sortedTemplateCandidates(candidates),
}),
)
}
func sortedTemplateCandidates(candidates []map[string]any) []map[string]any {
out := append([]map[string]any(nil), candidates...)
sort.SliceStable(out, func(i, j int) bool {
leftName, _ := out[i]["name"].(string)
rightName, _ := out[j]["name"].(string)
if leftName != rightName {
return leftName < rightName
}
leftID, _ := out[i]["templateId"].(string)
rightID, _ := out[j]["templateId"].(string)
return leftID < rightID
})
return out
}
func collectTemplateCandidates(value any) []map[string]any {
seen := map[string]bool{}
var out []map[string]any
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
id := directTemplateString(typed, "templateId", "template_id")
if id != "" && !seen[id] {
seen[id] = true
candidate := map[string]any{"templateId": id}
if name := directTemplateString(typed, "templateName", "name", "title"); name != "" {
candidate["name"] = name
}
if source := directTemplateString(typed, "templateSource", "source"); source != "" {
candidate["source"] = source
}
if description := directTemplateString(typed, "description", "summary"); description != "" {
candidate["description"] = description
}
out = append(out, candidate)
}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
walk(typed[key])
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(value)
return sortedTemplateCandidates(out)
}
func directTemplateString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func collectTemplateIDs(value any) []string {
seen := map[string]bool{}
var out []string
+61 -20
View File
@@ -17,11 +17,11 @@ import (
var MediaList = shortcut.Shortcut{
Service: "doc", Command: "+media-list", Product: productDoc,
Description: "列出文档正文中的图片和附件资源",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
[]string{`dws doc +media-list --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
@@ -31,40 +31,42 @@ var MediaList = shortcut.Shortcut{
return err
}
items := collectMediaItems(data)
return rt.Output(map[string]any{"count": len(items), "media": items})
return rt.Output(docEnvelope("doc.media_list", map[string]any{"nodeId": rt.Str("node"), "count": len(items), "media": items}))
},
}
var MediaInsert = shortcut.Shortcut{
Service: "doc", Command: "+media-insert", Product: productDoc,
Description: "上传本地图片或文件并插入文档正文",
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
Intent: "当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
"当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在的相对文件路径", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
Validate: func(rt *shortcut.RuntimeContext) error { return validateWorkspaceInputPath("file", rt.Str("file")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "--file 必须是工作目录内存在且不能经符号链接逃逸的相对文件"}},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
}
var MediaDownload = shortcut.Shortcut{
Service: "doc", Command: "+media-download", Product: productDoc,
Description: "安全下载文档正文附件到工作目录",
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
Intent: "当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
"当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
@@ -97,7 +99,7 @@ var MediaPreview = shortcut.Shortcut{
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
}
dir, err := docMkdirTemp("", "dws-doc-preview-*")
if err != nil {
@@ -106,9 +108,13 @@ var MediaPreview = shortcut.Shortcut{
result, err := downloadResolvedResource(rt, data, dir, ".")
if err != nil {
_ = docRemoveAll(dir)
return err
return docMediaRecoveryError("doc_media_preview_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
if result.SizeBytes <= 0 {
_ = docRemoveAll(dir)
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes, "verified": true}))
},
}
@@ -124,11 +130,20 @@ var ResourceUpdate = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在封面图片的相对路径"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("file") == "" {
return nil
}
return validateWorkspaceInputPath("file", rt.Str("file"))
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "提供 --file 时必须是工作目录内已存在且不通过符号链接逃逸的相对路径"},
},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
}
var ResourceDownload = shortcut.Shortcut{
@@ -225,7 +240,7 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
}
cwd, err := docGetwd()
if err != nil {
@@ -233,9 +248,35 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
return docMediaRecoveryError("doc_media_download_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
if result.SizeBytes <= 0 {
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes, "verified": true}))
}
func docMediaRecoveryError(reason, stage, nodeID, resourceID string, cause error) error {
return apperrors.NewAPI(
"文档媒体操作未完成;已保留 nodeId/resourceId,请稍后重试同一 shortcut,禁止改用临时 URL 或手写 HTTP",
apperrors.WithOperation("doc.media_download"),
apperrors.WithReason(reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithActions(
fmt.Sprintf("dws doc +media-download --node %s --resource-id %s --output <工作目录相对路径>", nodeID, resourceID),
"不要 curl/wget 临时 OSS URL,不要安装本地下载或文档转换依赖",
),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1",
"status": "incomplete",
"nodeId": nodeID,
"resourceId": resourceID,
"stage": stage,
}),
apperrors.WithCause(cause),
)
}
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
type docPageOptions struct {
PageAll bool
PageSize int
MaxPages int
MaxItems int
Cursor string
}
func collectDocPages(
rt *shortcut.RuntimeContext,
tool, outputKey string,
base map[string]any,
project func(map[string]any) []map[string]any,
options docPageOptions,
) (map[string]any, error) {
if options.PageSize <= 0 {
options.PageSize = 30
}
if options.MaxPages <= 0 {
options.MaxPages = 20
}
if options.MaxItems <= 0 {
options.MaxItems = 500
}
pageLimit := 1
if options.PageAll {
pageLimit = options.MaxPages
}
items := make([]map[string]any, 0)
seenItems := map[string]bool{}
seenCursors := map[string]bool{}
cursor := strings.TrimSpace(options.Cursor)
if cursor != "" {
seenCursors[cursor] = true
}
complete := false
truncated := false
stopReason := ""
nextCursor := ""
hasMore := false
pagesRead := 0
for page := 1; page <= pageLimit; page++ {
remaining := options.MaxItems - len(items)
requestPageSize := options.PageSize
if requestPageSize > remaining {
requestPageSize = remaining
}
params := cloneMap(base)
params["pageSize"] = requestPageSize
if cursor != "" {
params["pageToken"] = cursor
}
data, err := rt.CallMCPData(productDoc, tool, params)
if err != nil {
return nil, docPaginationError(tool, "page_read_failed", err, page, items, cursor)
}
pagesRead++
projected := project(data)
pageItems := make([]map[string]any, 0, len(projected))
pageSeen := map[string]bool{}
for _, item := range projected {
key := pageItemKey(item)
if key != "" && (seenItems[key] || pageSeen[key]) {
continue
}
if key != "" {
pageSeen[key] = true
}
pageItems = append(pageItems, item)
}
if len(pageItems) > remaining {
return nil, docPaginationError(tool, "page_size_exceeded", nil, page, items, cursor)
}
for _, item := range pageItems {
if key := pageItemKey(item); key != "" {
seenItems[key] = true
}
items = append(items, item)
}
pageHasMore, hasMoreKnown, next := docPageState(data)
nextCursor = strings.TrimSpace(next)
switch {
case hasMoreKnown:
hasMore = pageHasMore
complete = !pageHasMore
case nextCursor != "":
hasMore = true
case len(projected) < requestPageSize:
complete = true
default:
hasMore = true
stopReason = "pagination_unproven"
}
if len(items) >= options.MaxItems && hasMore {
truncated = true
stopReason = "max_items"
}
if truncated {
complete = false
hasMore = true
}
if truncated || complete || !options.PageAll {
break
}
if stopReason == "pagination_unproven" {
return nil, docPaginationError(tool, stopReason, nil, page, items, cursor)
}
if nextCursor == "" {
return nil, docPaginationError(tool, "missing_next_cursor", nil, page, items, cursor)
}
if seenCursors[nextCursor] {
return nil, docPaginationError(tool, "stalled_cursor", nil, page, items, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
if page == pageLimit {
truncated = true
stopReason = "max_pages"
}
}
result := map[string]any{
"contractVersion": "doc.list.v1",
"status": "success",
"count": len(items),
outputKey: items,
"pagesRead": pagesRead,
"complete": complete,
"truncated": truncated,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"failures": []map[string]any{},
}
return result, nil
}
func docPaginationError(tool, reason string, cause error, page int, items []map[string]any, cursor string) error {
message := fmt.Sprintf("%s 分页未完成,已在第 %d 页停止", tool, page)
return apperrors.NewAPI(
message,
apperrors.WithOperation("doc/"+tool),
apperrors.WithReason("doc_pagination_"+reason),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(cause != nil),
apperrors.WithActions("保留 nextCursor 后继续读取", "缩小查询范围后重试"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.list.v1",
"status": "partial_success",
"complete": false,
"reason": reason,
"page": page,
"nextCursor": cursor,
"count": len(items),
"items": items,
}),
apperrors.WithCause(cause),
)
}
func cloneMap(source map[string]any) map[string]any {
out := make(map[string]any, len(source)+2)
for key, value := range source {
out[key] = value
}
return out
}
func pageItemKey(item map[string]any) string {
for _, key := range []string{"nodeId", "id", "url"} {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
return key + ":" + strings.TrimSpace(value)
}
}
return ""
}
func docPageState(data map[string]any) (bool, bool, string) {
var hasMore bool
var known bool
var next string
var walk func(map[string]any)
walk = func(value map[string]any) {
if !known {
for _, key := range []string{"hasMore", "has_more"} {
if flag, ok := value[key].(bool); ok {
hasMore, known = flag, true
break
}
}
}
if next == "" {
if value, ok := docFirst(value, "nextPageToken", "nextCursor", "next_page_token"); ok {
next, _ = value.(string)
}
}
for _, key := range []string{"result", "data", "page", "pagination"} {
if nested, ok := value[key].(map[string]any); ok {
walk(nested)
}
}
}
walk(data)
return hasMore, known, next
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDocSearchPageAllContract(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {
{"documents": []any{map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{map[string]any{"nodeId": "b", "name": "B"}, map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("pagination calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationFailsClosedOnStalledCursor(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_nodes": {
{"nodes": []any{map[string]any{"nodeId": "a"}}, "hasMore": true, "nextPageToken": "p2"},
{"nodes": []any{map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
},
}}
err := runDocCoverage(t, List, caller, "--folder", "f", "--page-all", "--limit", "1")
if err == nil || len(caller.history) != 2 {
t.Fatalf("stalled pagination err=%v history=%#v", err, caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationMaxItemsStopsAtPageBoundary(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {
{"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{map[string]any{"nodeId": "c"}}, "hasMore": true, "nextPageToken": "p3"},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageSize"] != 1 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("max-items pagination calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}},
"hasMore": true, "nextPageToken": "p2",
}},
}}
err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "1")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_pagination_page_size_exceeded" {
t.Fatalf("page overflow error = %#v", err)
}
if len(caller.history) != 1 || caller.history[0].params["pageSize"] != 1 {
t.Fatalf("page overflow calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationFinalBranchMatrix(t *testing.T) {
run := func(t *testing.T, caller *docCoverageCaller, options docPageOptions) (map[string]any, error) {
t.Helper()
declaration := Search
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
result, err := collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options)
if err == nil {
err = rt.Output(result)
}
return err
}
err := runDocCoverage(t, declaration, caller)
return nil, err
}
defaults := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{"documents": []any{map[string]any{"url": "u"}}}},
}}
if _, err := run(t, defaults, docPageOptions{Cursor: " initial "}); err != nil {
t.Fatal(err)
}
if len(defaults.history) != 1 || defaults.history[0].params["pageSize"] != 30 || defaults.history[0].params["pageToken"] != "initial" {
t.Fatalf("default pagination call = %#v", defaults.history)
}
readFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
if _, err := run(t, readFailure, docPageOptions{PageAll: true}); err == nil {
t.Fatal("page read failure succeeded")
}
duplicate := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{
map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "a"}, map[string]any{"name": "no-key"},
},
"nextPageToken": "p2",
}},
}}
if _, err := run(t, duplicate, docPageOptions{PageAll: false, PageSize: 3, MaxPages: 2, MaxItems: 10}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
response map[string]any
maxPages int
wantError bool
}{
{"unproven", map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}}, 2, true},
{"missing cursor", map[string]any{"documents": []any{}, "hasMore": true}, 2, true},
{"max pages", map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": "p2"}, 1, false},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {tc.response}}}
_, err := run(t, caller, docPageOptions{PageAll: true, PageSize: 1, MaxPages: tc.maxPages, MaxItems: 10})
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if got := pageItemKey(map[string]any{"url": " u "}); got != "url:u" {
t.Fatalf("URL page key = %q", got)
}
if got := pageItemKey(map[string]any{"id": 1}); got != "" {
t.Fatalf("invalid page key = %q", got)
}
if more, known, next := docPageState(map[string]any{"data": map[string]any{"has_more": true, "nextCursor": "c"}}); !more || !known || next != "c" {
t.Fatalf("nested page state = %v/%v/%q", more, known, next)
}
}
+82 -9
View File
@@ -4,6 +4,7 @@
package doc
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf16"
@@ -60,13 +61,17 @@ var CommentUpdate = shortcut.Shortcut{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("update_comment", params)
},
@@ -113,14 +118,29 @@ func validateCommentCreate(rt *shortcut.RuntimeContext) error {
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
if rt.Str("block-id") != "" {
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
if rt.Str("selected-text") != "" {
params["selectedText"] = rt.Str("selected-text")
blockID, start, end := rt.Str("block-id"), rt.Int("start"), rt.Int("end")
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "blockId": blockID, "format": "element"})
if err != nil {
return err
}
return rt.CallMCP("create_inline_comment", params)
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
selectedText, ok := sliceUTF16Range(blockText[blockID], start, end)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("INVALID_SELECTION_RANGE: block %s 的 UTF-16 范围 [%d,%d) 无效", blockID, start, end))
}
if supplied := rt.Str("selected-text"); supplied != "" && supplied != selectedText {
return apperrors.NewValidation("SELECTED_TEXT_MISMATCH: --selected-text 与 block/start/end 对应的真实原文不一致")
}
params["blockId"], params["start"], params["end"], params["selectedText"] = blockID, start, end, selectedText
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
if selection := rt.Str("selection"); selection != "" {
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
@@ -136,9 +156,62 @@ func executeCommentCreate(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
}
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
return rt.CallMCP("create_inline_comment", params)
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
return rt.CallMCP("create_comment", params)
return executeCommentWrite(rt, "create_comment", "global", params)
}
func normalizeMentionUserIDs(values []string) ([]string, error) {
if len(values) == 1 && strings.HasPrefix(strings.TrimSpace(values[0]), "[") {
var decoded []any
decoder := json.NewDecoder(strings.NewReader(values[0]))
decoder.UseNumber()
if err := decoder.Decode(&decoded); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--mention JSON 数组解析失败: %v", err))
}
values = make([]string, 0, len(decoded))
for _, value := range decoded {
switch typed := value.(type) {
case string:
values = append(values, typed)
case json.Number:
values = append(values, typed.String())
default:
return nil, apperrors.NewValidation("--mention JSON 数组只接受字符串或数字 uid")
}
}
}
values = stringSliceNonEmpty(values)
if len(values) == 0 {
return nil, apperrors.NewValidation("--mention 至少包含一个非空用户 uid")
}
return values, nil
}
func executeCommentWrite(rt *shortcut.RuntimeContext, tool, commentType string, params map[string]any) error {
result, err := rt.CallMCPWriteData(productComment, tool, params)
if err != nil {
return docUnknownWriteError("doc.comment_create", tool, rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.comment_create", map[string]any{
"nodeId": rt.Str("node"), "commentType": commentType, "result": result,
}, map[string]any{"name": tool, "status": "success"}))
}
func sliceUTF16Range(text string, start, end int) (string, bool) {
units := utf16.Encode([]rune(text))
if start < 0 || end <= start || end > len(units) {
return "", false
}
isHigh := func(value uint16) bool { return value >= 0xD800 && value <= 0xDBFF }
isLow := func(value uint16) bool { return value >= 0xDC00 && value <= 0xDFFF }
if start > 0 && start < len(units) && isHigh(units[start-1]) && isLow(units[start]) {
return "", false
}
if end > 0 && end < len(units) && isHigh(units[end-1]) && isLow(units[end]) {
return "", false
}
return string(utf16.Decode(units[start:end])), true
}
type selectionMatch struct {
+285
View File
@@ -0,0 +1,285 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package docresolver resolves document IDs, URLs, and natural titles to one
// typed document target before a shortcut performs a business operation.
package docresolver
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
searchPageSize = 30
searchPageLimit = 40
)
// Reader is the read-only RuntimeContext surface needed by the resolver.
type Reader interface {
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
}
// Candidate is a credential-free document search candidate.
type Candidate struct {
CanonicalID string `json:"canonicalId"`
Name string `json:"name,omitempty"`
Product string `json:"product"`
ResourceType string `json:"resourceType,omitempty"`
URL string `json:"canonicalUrl,omitempty"`
ContainerID string `json:"containerId,omitempty"`
}
// Resolution is the stable result consumed by document shortcuts.
type Resolution struct {
ContractVersion string `json:"contractVersion"`
Status string `json:"status"`
Query string `json:"query,omitempty"`
MatchedBy string `json:"matchedBy"`
Complete bool `json:"complete"`
Selected Candidate `json:"selected"`
Candidates []Candidate `json:"candidates"`
}
// Resolve accepts exactly one stable node/URL or natural title. Natural title
// resolution exhausts pagination before selecting a unique candidate.
func Resolve(rt Reader, node, query string) (Resolution, error) {
node = strings.TrimSpace(node)
query = strings.TrimSpace(query)
if (node == "") == (query == "") {
return Resolution{}, apperrors.NewValidation(
"文档目标必须且只能提供一个:--node 或 --query",
apperrors.WithReason("document_target_invalid"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
)
}
if node != "" {
return Resolution{
ContractVersion: "doc.target.v1",
Status: "resolved",
MatchedBy: "stable_id_or_url",
Complete: true,
Selected: Candidate{
CanonicalID: node,
Product: "doc",
},
Candidates: []Candidate{},
}, nil
}
candidates, err := searchAll(rt, query)
if err != nil {
return Resolution{}, err
}
selected, matchedBy := selectCandidates(candidates, query)
if len(selected) == 0 {
return Resolution{}, resolutionError("not_found", query, candidates, "没有找到匹配的在线文字文档")
}
if len(selected) > 1 {
return Resolution{}, resolutionError("ambiguous", query, selected, "找到多个匹配文档,请使用 nodeId 或 URL 消歧")
}
if typ := normalizeType(selected[0].ResourceType); typ != "" && typ != "adoc" && typ != "document" && typ != "doc" {
return Resolution{}, apperrors.NewValidation(
fmt.Sprintf("目标 %q 的资源类型为 %s,不是在线文字文档", selected[0].Name, selected[0].ResourceType),
apperrors.WithReason("document_target_type_mismatch"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{"status": "type_mismatch", "target": selected[0]}),
)
}
return Resolution{
ContractVersion: "doc.target.v1",
Status: "resolved",
Query: query,
MatchedBy: matchedBy,
Complete: true,
Selected: selected[0],
Candidates: []Candidate{},
}, nil
}
func searchAll(rt Reader, query string) ([]Candidate, error) {
cursor := ""
seenCursors := map[string]bool{}
all := make([]Candidate, 0)
for page := 1; page <= searchPageLimit; page++ {
params := map[string]any{"keyword": query, "pageSize": searchPageSize}
if cursor != "" {
params["pageToken"] = cursor
}
data, err := rt.CallMCPData("doc", "search_documents", params)
if err != nil {
return nil, err
}
rows := extractRows(data)
all = append(all, extractCandidates(rows)...)
hasMore, hasMoreKnown, next := extractPage(data)
switch {
case hasMoreKnown && !hasMore:
return dedupe(all), nil
case hasMoreKnown && hasMore:
// next is validated below.
case next != "":
// Older responses may only publish the continuation.
case len(rows) < searchPageSize:
return dedupe(all), nil
default:
return nil, incompleteError(query, all, "搜索返回满页结果但没有结束标记或下一页游标")
}
next = strings.TrimSpace(next)
if next == "" {
return nil, incompleteError(query, all, "搜索声明仍有更多结果但缺少下一页游标")
}
if next == cursor || seenCursors[next] {
return nil, incompleteError(query, all, fmt.Sprintf("搜索分页游标停滞在 %q", next))
}
seenCursors[next] = true
cursor = next
}
return nil, incompleteError(query, all, fmt.Sprintf("搜索超过最大页数 %d", searchPageLimit))
}
func selectCandidates(candidates []Candidate, query string) ([]Candidate, string) {
exact := make([]Candidate, 0)
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
exact = append(exact, candidate)
}
}
if len(exact) > 0 {
return exact, "exact_title"
}
return candidates, "search_candidate"
}
func extractCandidates(rows []any) []Candidate {
out := make([]Candidate, 0, len(rows))
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
continue
}
candidate := Candidate{
CanonicalID: firstString(item, "nodeId", "node_id", "docId", "doc_id", "id"),
Name: firstString(item, "name", "title", "docName", "fileName"),
Product: "doc",
ResourceType: firstString(item, "docType", "doc_type", "extension", "fileType", "type"),
URL: firstString(item, "url", "docUrl", "nodeUrl", "webUrl"),
ContainerID: firstString(item, "workspaceId", "spaceId", "folderId", "parentId"),
}
if candidate.CanonicalID != "" {
out = append(out, candidate)
}
}
return out
}
func dedupe(in []Candidate) []Candidate {
seen := make(map[string]bool, len(in))
out := make([]Candidate, 0, len(in))
for _, candidate := range in {
if candidate.CanonicalID == "" || seen[candidate.CanonicalID] {
continue
}
seen[candidate.CanonicalID] = true
out = append(out, candidate)
}
return out
}
func extractRows(data map[string]any) []any {
for _, key := range []string{"documents", "nodes", "items", "list", "records", "result", "data"} {
value, ok := data[key]
if !ok {
continue
}
if rows, ok := value.([]any); ok {
return rows
}
if nested, ok := value.(map[string]any); ok {
if rows := extractRows(nested); rows != nil {
return rows
}
}
}
return []any{}
}
func extractPage(data map[string]any) (bool, bool, string) {
var hasMore bool
var hasMoreKnown bool
var next string
var walk func(map[string]any)
walk = func(value map[string]any) {
if !hasMoreKnown {
for _, key := range []string{"hasMore", "has_more"} {
if flag, ok := value[key].(bool); ok {
hasMore, hasMoreKnown = flag, true
break
}
}
}
if next == "" {
next = firstString(value, "nextPageToken", "nextCursor", "next_page_token")
}
for _, key := range []string{"result", "data", "page", "pagination"} {
if nested, ok := value[key].(map[string]any); ok {
walk(nested)
}
}
}
walk(data)
return hasMore, hasMoreKnown, next
}
func firstString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func normalizeType(value string) string {
return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, ".")))
}
func resolutionError(status, query string, candidates []Candidate, message string) error {
return apperrors.NewValidation(
message,
apperrors.WithReason("document_target_"+status),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.target.v1",
"status": status,
"query": query,
"complete": true,
"candidates": candidates,
}),
)
}
func incompleteError(query string, candidates []Candidate, cause string) error {
return apperrors.NewAPI(
"无法证明文档搜索候选完整,已停止目标解析",
apperrors.WithReason("document_target_incomplete"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithActions("稍后以相同条件重试", "使用已知 nodeId 或 URL 直接指定目标"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.target.v1",
"status": "incomplete",
"query": query,
"complete": false,
"cause": cause,
"candidates": dedupe(candidates),
}),
)
}
@@ -0,0 +1,154 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package docresolver
import (
"errors"
"fmt"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type scriptedReader struct {
pages []map[string]any
err error
calls int
}
func (r *scriptedReader) CallMCPData(_ string, _ string, _ map[string]any) (map[string]any, error) {
r.calls++
if r.err != nil {
return nil, r.err
}
if len(r.pages) == 0 {
return map[string]any{}, nil
}
page := r.pages[0]
r.pages = r.pages[1:]
return page, nil
}
func row(id, name, typ string) any {
return map[string]any{"nodeId": id, "name": name, "docType": typ, "url": "https://alidocs.dingtalk.com/i/nodes/" + id}
}
func TestCrossPlatformCoverageResolveStableTargetDoesNotSearch(t *testing.T) {
reader := &scriptedReader{}
resolution, err := Resolve(reader, "node-1", "")
if err != nil || reader.calls != 0 || resolution.Selected.CanonicalID != "node-1" || !resolution.Complete {
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
}
}
func TestCrossPlatformCoverageResolveNaturalTitleExhaustsPagesBeforeSelecting(t *testing.T) {
first := make([]any, 0, searchPageSize)
for i := 0; i < searchPageSize; i++ {
first = append(first, row(fmt.Sprintf("other-%d", i), "其他", "adoc"))
}
reader := &scriptedReader{pages: []map[string]any{
{"documents": first, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{row("wanted", "项目周报", "adoc")}, "hasMore": false},
}}
resolution, err := Resolve(reader, "", "项目周报")
if err != nil || reader.calls != 2 || resolution.Selected.CanonicalID != "wanted" || resolution.MatchedBy != "exact_title" {
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
}
}
func TestCrossPlatformCoverageResolveFailsClosedForAmbiguousIncompleteAndWrongType(t *testing.T) {
tests := []struct {
name string
pages []map[string]any
query string
reason string
}{
{"ambiguous", []map[string]any{{"documents": []any{row("1", "周报", "adoc"), row("2", "周报", "adoc")}, "hasMore": false}}, "周报", "ambiguous"},
{"not found", []map[string]any{{"documents": []any{}, "hasMore": false}}, "missing", "not_found"},
{"stalled", []map[string]any{{"documents": []any{row("1", "周报", "adoc")}, "hasMore": true, "nextPageToken": "p1"}, {"documents": []any{}, "hasMore": true, "nextPageToken": "p1"}}, "周报", "incomplete"},
{"wrong type", []map[string]any{{"documents": []any{row("1", "周报", "pdf")}, "hasMore": false}}, "周报", "type_mismatch"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := Resolve(&scriptedReader{pages: tc.pages}, "", tc.query)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || !strings.Contains(typed.Reason, tc.reason) {
t.Fatalf("err=%v", err)
}
})
}
reader := &scriptedReader{err: errors.New("backend")}
if _, err := Resolve(reader, "", "x"); !errors.Is(err, reader.err) {
t.Fatalf("transport error=%v", err)
}
}
func TestCrossPlatformCoverageResolverFinalBranchMatrix(t *testing.T) {
for _, target := range [][2]string{{"", ""}, {"node", "query"}} {
if _, err := Resolve(&scriptedReader{}, target[0], target[1]); err == nil {
t.Fatalf("invalid target %#v succeeded", target)
}
}
full := make([]any, searchPageSize)
for i := range full {
full[i] = row(fmt.Sprintf("full-%d", i), "other", "adoc")
}
for _, tc := range []struct {
name string
pages []map[string]any
}{
{"unproven full page", []map[string]any{{"documents": full}}},
{"missing cursor", []map[string]any{{"documents": []any{}, "hasMore": true}}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := Resolve(&scriptedReader{pages: tc.pages}, "", "wanted"); err == nil {
t.Fatal("incomplete pagination succeeded")
}
})
}
if _, err := Resolve(&scriptedReader{pages: []map[string]any{{"documents": []any{}}}}, "", "wanted"); err == nil {
t.Fatal("underfilled implicit final page should resolve to not-found")
}
pages := make([]map[string]any, searchPageLimit)
for i := range pages {
pages[i] = map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": fmt.Sprintf("p-%d", i+1)}
}
if _, err := Resolve(&scriptedReader{pages: pages}, "", "wanted"); err == nil {
t.Fatal("max-page pagination succeeded")
}
nested := map[string]any{
"result": map[string]any{
"data": map[string]any{
"records": []any{
"invalid",
map[string]any{"id": "", "title": "empty"},
map[string]any{"doc_id": "d", "fileName": "Doc", "fileType": ".ADOC", "webUrl": " u ", "parentId": "p"},
},
"pagination": map[string]any{"has_more": false, "next_page_token": " next "},
},
},
}
rows := extractRows(nested)
candidates := extractCandidates(rows)
if len(candidates) != 1 || candidates[0].CanonicalID != "d" || normalizeType(candidates[0].ResourceType) != "adoc" {
t.Fatalf("nested candidates = %#v", candidates)
}
if hasMore, known, next := extractPage(nested); !known || hasMore || next != "next" {
t.Fatalf("nested page = %v/%v/%q", hasMore, known, next)
}
if rows := extractRows(map[string]any{"items": "not-a-list"}); len(rows) != 0 {
t.Fatalf("non-list rows = %#v", rows)
}
deduped := dedupe([]Candidate{{}, {CanonicalID: "d"}, {CanonicalID: "d"}})
if len(deduped) != 1 {
t.Fatalf("deduped = %#v", deduped)
}
selected, matchedBy := selectCandidates([]Candidate{{CanonicalID: "d", Name: "Different"}}, "query")
if len(selected) != 1 || matchedBy != "search_candidate" {
t.Fatalf("fallback selection = %#v/%q", selected, matchedBy)
}
}
+14
View File
@@ -153,6 +153,20 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an
return rt.callMCPData(product, tool, params)
}
// CallMCPReadData dispatches a tool whose name is explicitly classified as
// read-only without consulting Cobra state. Callers must invoke it serially
// unless the injected ToolCaller separately documents and enforces concurrent
// safety. Write-shaped tool names fail closed.
func (rt *RuntimeContext) CallMCPReadData(product, tool string, params map[string]any) (map[string]any, error) {
if !looksReadTool(tool) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"并发只读入口拒绝写工具 %s/%s;写操作必须使用 CallMCPWriteData",
product, tool,
))
}
return rt.callMCPReadData(product, tool, params)
}
// CallMCPWriteData dispatches a write tool call and returns its parsed response.
// Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot
// accidentally perform writes while rendering a preview. For compatibility with
+8 -8
View File
@@ -11,11 +11,11 @@
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true},
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+version-save": {"disposition":"semantic_adapter","semantic_delta":"版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。","risk":"write","public":true,"reviewed":true},
"+version-list": {"disposition":"semantic_adapter","semantic_delta":"版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。","risk":"read","public":true,"reviewed":true},
"+version-revert": {"disposition":"primary_smart","semantic_delta":"版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称或关键词检索模板并返回可消歧候选和 templateId。","risk":"read","public":true,"reviewed":true},
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
@@ -40,9 +40,9 @@
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
"+history-save": {"disposition":"alias_internal","semantic_delta":"保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。","risk":"write","primary":"+version-save","public":true,"reviewed":true},
"+history-list": {"disposition":"alias_internal","semantic_delta":"保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。","risk":"read","primary":"+version-list","public":true,"reviewed":true},
"+history-revert": {"disposition":"alias_internal","semantic_delta":"保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。","risk":"high-risk-write","primary":"+version-revert","public":true,"reviewed":true},
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
@@ -50,7 +50,7 @@
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"仅兼容既有调用;selection 与 block/start/end 新场景都统一使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
}
}
+8
View File
@@ -685,6 +685,14 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
}
}
func TestCrossPlatformCoverageCallMCPReadDataRejectsWriteName(t *testing.T) {
rt := &RuntimeContext{}
_, err := rt.CallMCPReadData("doc", "update_document", map[string]any{"nodeId": "n"})
if err == nil || !strings.Contains(err.Error(), "doc/update_document") {
t.Fatalf("read-only guard error = %v", err)
}
}
func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
+1 -1
View File
@@ -19,7 +19,7 @@ Script groups:
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
- CI package plan: `./scripts/ci/test-packages.sh`
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh`
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag> --candidate-ref <candidate-sha>`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh`
- Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace
- Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md)
- Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
+15 -3
View File
@@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat"}
COMPACT_PRODUCT_SERVICES = {"chat", "doc"}
def md_escape(value: Any) -> str:
@@ -121,7 +121,10 @@ def runtime_contract_block() -> str:
def mono_overview(items: list[dict[str, Any]]) -> str:
counts = Counter(item["service"] for item in items)
# The source collector cannot see a small number of commands whose final
# canonical names are normalized at runtime. Overview counts come from the
# reviewed public catalog so they cannot under-report those declarations.
counts = Counter(service for service, _ in load_public_catalog())
rows = []
for service, count in sorted(counts.items()):
path = SERVICE_TO_SKILL.get(service)
@@ -163,10 +166,18 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str:
def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
# Compact skills intentionally do not depend on the source parser's ability
# to recover every runtime-normalized declaration. The reviewed public
# catalog is the count authority for this non-enumerating overview.
public_count = sum(1 for item_service, _ in load_public_catalog() if item_service == service)
if service == "doc":
discovery = """已知意图按下方路由。"""
else:
discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。"""
return f"""{PRODUCT_START}
## Shortcut 发现(按需)
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`{md_escape(service)}` 当前有 {public_count} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。{discovery}
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
{PRODUCT_END}"""
@@ -194,6 +205,7 @@ def update_runtime_contract(check: bool) -> list[Path]:
changed = []
targets = [
ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
SHARED_SKILL,
]
for path in targets:
@@ -1,15 +1,16 @@
#!/bin/sh
set -eu
# Compare the candidate command tree against a Git-owned historical source
# revision. CI invokes this for both the PR merge-base and latest stable tag; a
# fixture from the candidate branch is never used as authority.
# Compatibility decisions are centralized in check-command-compatibility.sh.
# This historical entry point remains as the Makefile/user-facing wrapper.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
usage() {
printf '%s\n' "usage: $0 --base-ref <ref>" >&2
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
@@ -19,6 +20,16 @@ while [ "$#" -gt 0 ]; do
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -32,63 +43,30 @@ while [ "$#" -gt 0 ]; do
done
[ -n "$BASE_REF" ] || { usage; exit 2; }
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
. "$ROOT/scripts/release/release-lib.sh"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'error: interface authority is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
TMP_ROOT="$(policy_runtime_mktemp_dir dws-interface-authority)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
BASELINE="$TMP_ROOT/merge-base.txt"
CANDIDATE_BIN="$TMP_ROOT/interface-baseline"
CANDIDATE_HOME="$TMP_ROOT/candidate-home"
if [ -z "$STABLE_REF" ]; then
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
STABLE_REF="$tag"
break
done
fi
if [ -z "$STABLE_REF" ]; then
printf 'error: no stable release tag is reachable from interface authority %s\n' "$BASE_REF" >&2
exit 2
fi
cleanup() {
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
install_snapshot_helper() {
worktree="$1"
helper="$worktree/scripts/policy/interface-baseline"
# Use one contract format on both sides while constructing the command tree
# from the authoritative historical source revision.
rm -rf "$helper"
mkdir -p "$helper"
cp -R "$ROOT/scripts/policy/interface-baseline/." "$helper/"
}
generate_reference() {
ref="$1"
worktree="$2"
output="$3"
home="$4"
bin="$TMP_ROOT/reference-$(basename "$worktree")"
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
install_snapshot_helper "$worktree"
mkdir -p "$home"
(
cd "$worktree"
go build -o "$bin" ./scripts/policy/interface-baseline
HOME="$home" DWS_LANG=zh "$bin" >"$output"
)
}
generate_reference "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" "$TMP_ROOT/base-home"
mkdir -p "$CANDIDATE_HOME"
go build -o "$CANDIDATE_BIN" ./scripts/policy/interface-baseline
printf 'checking candidate against authoritative interface ref %s\n' "$BASE_REF"
HOME="$CANDIDATE_HOME" DWS_LANG=zh "$CANDIDATE_BIN" --check "$BASELINE"
exec "$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$BASE_REF" \
--stable-ref "$STABLE_REF" \
--candidate-ref "$CANDIDATE_REF"
@@ -1,16 +1,23 @@
#!/bin/sh
set -eu
# Compare the candidate's complete Schema contract with the Git-owned PR
# merge-base (or previous main SHA on push). The candidate branch cannot
# weaken this authority by editing a checked-in fixture.
# Compare the committed candidate's complete Schema contract independently with
# the Git-owned PR merge-base (or previous main SHA on push) and the latest
# reachable stable GA. The merge-base owns the Schema checker and, after
# bootstrap, the approved flag-migration ledger. Candidate code is lifecycle
# input only and cannot replace either authority.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BIN="${DWS_BIN:-$ROOT/dws}"
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go"
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
MIGRATIONS_REL="internal/interfacesnapshot/migrations.go"
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
usage() {
printf '%s\n' "usage: $0 --base-ref <ref>" >&2
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
@@ -20,6 +27,16 @@ while [ "$#" -gt 0 ]; do
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -36,27 +53,62 @@ done
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
. "$ROOT/scripts/release/release-lib.sh"
policy_prepare_runtime "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || {
printf 'error: Schema authority is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
if [ ! -x "$BIN" ]; then
printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2
CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || {
printf 'error: Schema candidate is not available locally: %s\n' "$CANDIDATE_REF" >&2
exit 2
}
EXPECTED_STABLE_REF=""
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
EXPECTED_STABLE_REF="$tag"
break
done
if [ -z "$EXPECTED_STABLE_REF" ]; then
printf 'error: no stable release tag is reachable from Schema authority %s\n' "$BASE_REF" >&2
exit 2
fi
if [ -z "$STABLE_REF" ]; then
STABLE_REF="$EXPECTED_STABLE_REF"
fi
STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || {
printf 'error: Schema stable authority is not available locally: %s\n' "$STABLE_REF" >&2
exit 2
}
if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then
printf 'error: stable ref %s is not the expected highest GA tag %s reachable from %s\n' \
"$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2
exit 2
fi
TMP_ROOT="$(policy_runtime_mktemp_dir dws-schema-authority)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
BASE_BIN="$TMP_ROOT/base-dws"
BASE_RAW="$TMP_ROOT/base-schema.json"
BASELINE="$TMP_ROOT/base-contract.json"
CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
CHECKER="$TMP_ROOT/schema-compat"
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree"
BASE_WORKTREE_ADDED=false
STABLE_WORKTREE_ADDED=false
CANDIDATE_WORKTREE_ADDED=false
cleanup() {
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true
fi
if [ "$STABLE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
fi
if [ "$BASE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
fi
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
@@ -64,19 +116,237 @@ trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_REF" >/dev/null
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null
BASE_WORKTREE_ADDED=true
git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null
STABLE_WORKTREE_ADDED=true
git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null
CANDIDATE_WORKTREE_ADDED=true
commit_path_exists() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ]
}
commit_path_is_regular_file() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ] || return 1
set -- $tree_entry
[ "$#" -eq 4 ] || return 1
case "$1" in
100644|100755) ;;
*) return 1 ;;
esac
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
}
has_modern_interface_helper() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go
}
has_complete_schema_migration_governance() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" "$SCHEMA_CHECKER_REL" &&
has_modern_interface_helper "$governance_commit" &&
commit_path_is_regular_file "$governance_commit" "$MIGRATIONS_REL" &&
commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" &&
commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL"
}
has_any_schema_migration_governance_artifact() {
governance_commit="$1"
for relative_path in \
"$MIGRATIONS_REL" \
"$ALIAS_CONTRACT_REL" \
"$MIGRATION_MANIFEST_REL"; do
if commit_path_exists "$governance_commit" "$relative_path"; then
return 0
fi
done
return 1
}
check_candidate_alias_source_policy() {
source_policy_failed=false
for token in \
dws.compat.alias_of \
dws.compat.alias_origin \
corecmd.flag_spec_aliases.v1 \
AnnotationFlagAliasOf \
AnnotationFlagAliasOrigin \
FlagAliasOriginCorecmdV1; do
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)"
[ -z "$matches" ] && continue
while IFS= read -r relative_path; do
[ -n "$relative_path" ] || continue
case "$relative_path" in
*_test.go)
continue
;;
internal/corecmd/corecmd.go|\
internal/corecmd/runtimeannotate/interface_alias.go|\
internal/interfacesnapshot/snapshot.go)
;;
*)
if [ "$source_policy_failed" = false ]; then
printf 'error: candidate production source may not forge framework-owned flag alias evidence:\n' >&2
fi
printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2
source_policy_failed=true
;;
esac
done <<EOF
$matches
EOF
done
if [ "$source_policy_failed" = true ]; then
exit 2
fi
}
if ! commit_path_is_regular_file "$BASE_COMMIT" "$SCHEMA_CHECKER_REL"; then
printf 'error: merge-base lacks the base-owned Schema checker: %s\n' "$BASE_REF" >&2
exit 2
fi
if ! has_modern_interface_helper "$BASE_COMMIT"; then
printf 'error: merge-base lacks the modern interface snapshot helper required for Schema governance: %s\n' "$BASE_REF" >&2
exit 2
fi
if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then
printf 'error: candidate must preserve the complete Schema flag migration governance artifact set at %s\n' \
"$CANDIDATE_COMMIT" >&2
exit 2
fi
check_candidate_alias_source_policy
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
USE_MIGRATION_GOVERNANCE=false
if has_complete_schema_migration_governance "$BASE_COMMIT"; then
USE_MIGRATION_GOVERNANCE=true
APPROVED_MANIFEST="$BASE_WORKTREE/$MIGRATION_MANIFEST_REL"
CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
else
# One-time bootstrap for the governance PR itself. The old base checker owns
# the decision and receives no flags it does not understand. With no base
# approval authority, only the exact canonical empty candidate ledger is
# admissible.
if has_any_schema_migration_governance_artifact "$BASE_COMMIT"; then
printf 'error: merge-base contains an incomplete Schema flag migration governance artifact set: %s\n' \
"$BASE_REF" >&2
exit 2
fi
BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then
printf 'error: initial Schema flag migration governance requires the canonical empty manifest: %s\n' \
"$BOOTSTRAP_MANIFEST" >&2
exit 2
fi
fi
BASE_BIN="$TMP_ROOT/base-dws"
STABLE_BIN="$TMP_ROOT/stable-dws"
CANDIDATE_BIN="$TMP_ROOT/candidate-dws"
CHECKER="$TMP_ROOT/schema-compat"
BASE_RAW="$TMP_ROOT/base-schema.json"
BASELINE="$TMP_ROOT/base-contract.json"
STABLE_RAW="$TMP_ROOT/stable-schema.json"
STABLE_BASELINE="$TMP_ROOT/stable-contract.json"
CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
(
cd "$BASE_WORKTREE"
go build -o "$BASE_BIN" ./cmd
go build -o "$CHECKER" ./scripts/policy/schema-compat
)
(
cd "$STABLE_WORKTREE"
go build -o "$STABLE_BIN" ./cmd
)
(
cd "$CANDIDATE_WORKTREE"
go build -o "$CANDIDATE_BIN" ./cmd
)
go build -o "$CHECKER" ./scripts/policy/schema-compat
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/candidate-home"
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
HOME="$TMP_ROOT/stable-home" DWS_LANG=zh \
"$STABLE_BIN" schema --all --format json >"$STABLE_RAW"
HOME="$TMP_ROOT/candidate-home" DWS_LANG=zh \
"$BIN" schema --all --format json >"$CANDIDATE_RAW"
"$CANDIDATE_BIN" schema --all --format json >"$CANDIDATE_RAW"
"$CHECKER" --normalize "$BASE_RAW" >"$BASELINE"
printf 'checking complete Schema contract against PR merge-base %s\n' "$BASE_REF"
"$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW"
"$CHECKER" --normalize "$STABLE_RAW" >"$STABLE_BASELINE"
check_schema_contract() {
historical_kind="$1"
historical_ref="$2"
historical_baseline="$3"
shift 3
printf 'checking complete Schema contract against %s %s\n' \
"$historical_kind" "$historical_ref"
"$CHECKER" --check "$historical_baseline" --current "$CANDIDATE_RAW" "$@"
}
if [ "$USE_MIGRATION_GOVERNANCE" = false ]; then
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE"
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE"
exit 0
fi
install_authority_interface_helper() {
worktree="$1"
for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do
rm -rf "$worktree/$helper_path"
mkdir -p "$worktree/$helper_path"
cp -R "$BASE_WORKTREE/$helper_path/." "$worktree/$helper_path/"
done
mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")"
rm -f "$worktree/$ALIAS_CONTRACT_REL"
cp "$BASE_WORKTREE/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL"
}
# The candidate and historical stable trees provide only the command surface.
# The merge-base owns the snapshot generator, comparator, and alias contract.
install_authority_interface_helper "$CANDIDATE_WORKTREE"
install_authority_interface_helper "$STABLE_WORKTREE"
generate_interface_snapshot() {
worktree="$1"
output="$2"
(
cd "$worktree"
go run ./cmd/interface-snapshot generate --output "$output"
)
}
CURRENT_INTERFACE_SNAPSHOT="$TMP_ROOT/candidate-interface-snapshot.json"
BASE_INTERFACE_SNAPSHOT="$TMP_ROOT/base-interface-snapshot.json"
STABLE_INTERFACE_SNAPSHOT="$TMP_ROOT/stable-interface-snapshot.json"
generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT"
generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT"
generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT"
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
+228 -49
View File
@@ -1,23 +1,21 @@
#!/bin/sh
set -eu
# Compare the current Cobra command surface with two local Git revisions:
# the PR merge-base/main reference and the latest stable GA tag. The caller must
# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag
# governance treats that tag as proof of a successfully published GA release;
# release automation must not leave a GA tag behind when publication fails.
#
# The script never fetches refs or snapshots from the network. With checkout
# fetch-depth=0, both references are materialized in temporary worktrees. A
# reference uses its own snapshot helper when available; older revisions are
# bootstrapped with the candidate helper.
# Compare the committed candidate Cobra surface with the PR merge-base and the
# latest stable GA tag. Once the merge-base contains the migration governance
# helper, that revision owns the generator, comparator, and approved manifest.
# The candidate manifest is lifecycle input only and cannot authorize a change
# introduced by the same PR.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
usage() {
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref>" >&2
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
@@ -32,6 +30,11 @@ while [ "$#" -gt 0 ]; do
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -47,20 +50,51 @@ done
[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; }
cd "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
. "$ROOT/scripts/policy/policy-runtime.sh"
. "$ROOT/scripts/release/release-lib.sh"
policy_prepare_runtime "$ROOT"
BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || {
printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || {
STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || {
printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2
exit 2
}
CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || {
printf 'candidate ref is not available locally: %s\n' "$CANDIDATE_REF" >&2
exit 2
}
EXPECTED_STABLE_REF=""
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
EXPECTED_STABLE_REF="$tag"
break
done
if [ -z "$EXPECTED_STABLE_REF" ]; then
printf 'no stable GA tag is reachable from compatibility base %s\n' "$BASE_REF" >&2
exit 2
fi
if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then
printf 'stable ref %s is not the expected highest GA tag %s reachable from %s\n' \
"$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2
exit 2
fi
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-command-compat)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree"
CANDIDATE_WORKTREE_ADDED=false
cleanup() {
if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true
fi
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
rm -rf "$TMP_ROOT"
@@ -70,38 +104,168 @@ trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
ensure_snapshot_helper() {
worktree="$1"
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null
git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null
git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null
CANDIDATE_WORKTREE_ADDED=true
# Once a baseline contains the helper, use that revision's own capture
# rules. This makes a rule/filter change in the candidate visible as a
# snapshot_rules_changed failure instead of applying the new exclusion to
# both sides and accidentally hiding a removed command. Older revisions
# created before this helper existed are bootstrapped with the candidate
# implementation.
if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \
[ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \
[ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then
return
fi
if [ -e "$worktree/cmd/interface-snapshot" ] || \
[ -e "$worktree/internal/interfacesnapshot" ]; then
printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2
exit 2
fi
mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot"
cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/"
cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/"
commit_path_exists() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ]
}
generate_ref_snapshot() {
ref="$1"
worktree="$2"
output="$3"
commit_path_is_regular_file() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ] || return 1
set -- $tree_entry
[ "$#" -eq 4 ] || return 1
case "$1" in
100644|100755) ;;
*) return 1 ;;
esac
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
}
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
ensure_snapshot_helper "$worktree"
has_modern_interface_helper() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go
}
has_complete_migration_governance() {
governance_commit="$1"
has_modern_interface_helper "$governance_commit" &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/migrations.go &&
commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" &&
commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL"
}
has_any_migration_governance_artifact() {
governance_commit="$1"
for relative_path in \
internal/interfacesnapshot/migrations.go \
"$ALIAS_CONTRACT_REL" \
"$MIGRATION_MANIFEST_REL"; do
if commit_path_exists "$governance_commit" "$relative_path"; then
return 0
fi
done
return 1
}
require_complete_candidate_governance() {
if ! has_complete_migration_governance "$CANDIDATE_COMMIT"; then
printf 'candidate must preserve the complete flag migration governance artifact set at %s\n' \
"$CANDIDATE_COMMIT" >&2
exit 2
fi
}
check_candidate_alias_source_policy() {
source_policy_failed=false
for token in \
dws.compat.alias_of \
dws.compat.alias_origin \
corecmd.flag_spec_aliases.v1 \
AnnotationFlagAliasOf \
AnnotationFlagAliasOrigin \
FlagAliasOriginCorecmdV1; do
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)"
[ -z "$matches" ] && continue
while IFS= read -r relative_path; do
[ -n "$relative_path" ] || continue
case "$relative_path" in
*_test.go)
continue
;;
internal/corecmd/corecmd.go|\
internal/corecmd/runtimeannotate/interface_alias.go|\
internal/interfacesnapshot/snapshot.go)
;;
*)
if [ "$source_policy_failed" = false ]; then
printf 'candidate production source may not forge framework-owned flag alias evidence:\n' >&2
fi
printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2
source_policy_failed=true
;;
esac
done <<EOF
$matches
EOF
done
if [ "$source_policy_failed" = true ]; then
exit 2
fi
}
install_authority_helper() {
source_root="$1"
worktree="$2"
include_alias_contract="$3"
[ "$source_root" = "$worktree" ] && return
for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do
rm -rf "$worktree/$helper_path"
mkdir -p "$worktree/$helper_path"
cp -R "$source_root/$helper_path/." "$worktree/$helper_path/"
done
if [ "$include_alias_contract" = true ]; then
mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")"
rm -f "$worktree/$ALIAS_CONTRACT_REL"
cp "$source_root/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL"
fi
}
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
USE_MIGRATION_GOVERNANCE=false
if has_complete_migration_governance "$BASE_COMMIT"; then
USE_MIGRATION_GOVERNANCE=true
AUTHORITY_ROOT="$BASE_WORKTREE"
APPROVED_MANIFEST="$BASE_WORKTREE/$MIGRATION_MANIFEST_REL"
CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
require_complete_candidate_governance
check_candidate_alias_source_policy
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true
else
# One-time bootstrap for the governance PR itself. No base approval exists,
# so the existing base-owned modern helper performs an ordinary comparison
# and this path admits only the canonical empty ledger. The candidate's new
# comparator never participates in the bootstrap decision.
if has_any_migration_governance_artifact "$BASE_COMMIT"; then
printf 'merge-base contains an incomplete flag migration governance artifact set: %s\n' \
"$BASE_REF" >&2
exit 2
fi
if ! has_modern_interface_helper "$BASE_COMMIT"; then
printf 'merge-base lacks the modern interface snapshot helper required for governance bootstrap: %s\n' "$BASE_REF" >&2
exit 2
fi
AUTHORITY_ROOT="$BASE_WORKTREE"
require_complete_candidate_governance
check_candidate_alias_source_policy
BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then
printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2
exit 2
fi
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" false
fi
install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
generate_snapshot() {
worktree="$1"
output="$2"
(
cd "$worktree"
go run ./cmd/interface-snapshot generate --output "$output"
@@ -112,11 +276,26 @@ CANDIDATE="$TMP_ROOT/candidate.json"
BASELINE="$TMP_ROOT/merge-base.json"
STABLE="$TMP_ROOT/stable.json"
go run ./cmd/interface-snapshot generate --output "$CANDIDATE"
generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE"
generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE"
generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE"
generate_snapshot "$BASE_WORKTREE" "$BASELINE"
generate_snapshot "$STABLE_WORKTREE" "$STABLE"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE"
if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
(
cd "$AUTHORITY_ROOT"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST"
)
else
(
cd "$AUTHORITY_ROOT"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE"
)
fi
@@ -1,5 +1,5 @@
#!/bin/sh
# Mono↔multi skill content QA (G1–G4). See docs/skill-mono-multi-qa.md
# Mono↔multi skill content QA (G1–G5). See docs/skill-mono-multi-qa.md
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
+85 -1
View File
@@ -7,12 +7,14 @@ cd "$ROOT"
python3 scripts/gen_skill_shortcut_sections.py --check
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
doc_skill="skills/multi/dingtalk-doc/SKILL.md"
event_skill="skills/multi/dingtalk-misc/references/event.md"
mono_skill="skills/mono/SKILL.md"
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
chat_target_bytes=10000
chat_max_overage_percent=5
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
doc_max_bytes=9000
event_max_bytes=10000
runtime_contract_max_bytes=3000
@@ -23,6 +25,13 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
exit 1
fi
doc_bytes="$(wc -c < "$doc_skill" | tr -d ' ')"
if [ "$doc_bytes" -gt "$doc_max_bytes" ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (max ${doc_max_bytes})" >&2
exit 1
fi
event_bytes="$(wc -c < "$event_skill" | tr -d ' ')"
if [ "$event_bytes" -gt "$event_max_bytes" ]; then
printf '%s\n' \
@@ -51,6 +60,20 @@ if [ "$shortcut_rows" -ne 0 ]; then
exit 1
fi
doc_shortcut_rows="$(
awk '
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
in_block && /^\| `dws doc \+/ { count++ }
END { print count + 0 }
' "$doc_skill"
)"
if [ "$doc_shortcut_rows" -ne 0 ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill re-expanded $doc_shortcut_rows shortcut rows" >&2
exit 1
fi
for required_heading in \
"## 最小 DWS 执行契约" \
"## Golden Route" \
@@ -65,6 +88,67 @@ do
fi
done
for required_heading in \
"## 最小 DWS 执行契约" \
"## Golden Route" \
"## 关键结果语义" \
"## 按需加载" \
"## 错误最短路径"
do
if ! grep -Fq "$required_heading" "$doc_skill"; then
printf '%s\n' \
"skill value regression: $doc_skill is missing $required_heading" >&2
exit 1
fi
done
for required_route in \
"+search" \
"+fetch" \
"+create" \
"+update" \
"+checkpoint-update" \
"+export"
do
if ! grep -Fq -- "$required_route" "$doc_skill"; then
printf '%s\n' \
"skill route regression: $doc_skill is missing $required_route" >&2
exit 1
fi
done
for forbidden_route in \
"## 标准 SOP" \
"doc_create_and_write.py" \
"只在任务命中时读取一个精确 reference" \
"MUST 先用 Read" \
"先用 Read 工具完整读取"
do
if grep -Fq "$forbidden_route" "$doc_skill"; then
printf '%s\n' \
"skill route regression: $doc_skill restored legacy route: $forbidden_route" >&2
exit 1
fi
done
for required_reference_rule in \
"Golden Route 已给出命令且参数足够时,禁止读取 reference" \
"才最多读取一个 reference" \
"\`+create\`、\`+fetch\`、\`+update\` append/overwrite、\`+export\`、\`+import\` 禁止读取 reference"
do
if ! grep -Fq "$required_reference_rule" "$doc_skill"; then
printf '%s\n' \
"skill reference budget regression: $doc_skill is missing $required_reference_rule" >&2
exit 1
fi
done
if grep -Fq "../dingtalk-shared/SKILL.md" "$doc_skill"; then
printf '%s\n' \
"skill context regression: $doc_skill requires full dingtalk-shared cold-start loading" >&2
exit 1
fi
for required_route in \
"+dm" \
"+send-to-group" \
@@ -108,4 +192,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
fi
printf '%s\n' \
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_max=$doc_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows doc_shortcut_rows=$doc_shortcut_rows)"
@@ -0,0 +1,454 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package commandcompat
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const authorityHelperSource = `package main
import (
"flag"
"fmt"
"os"
"example.com/dws-command-compat-authority-test/internal/interfacesnapshot"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "missing command")
os.Exit(2)
}
switch os.Args[1] {
case "generate":
flags := flag.NewFlagSet("generate", flag.ExitOnError)
output := flags.String("output", "", "snapshot output")
_ = flags.Parse(os.Args[2:])
if *output == "" {
fmt.Fprintln(os.Stderr, "missing --output")
os.Exit(2)
}
marker := interfacesnapshot.AuthorityMarker()
if err := os.WriteFile(*output, []byte("AUTHORITY="+marker+"\n"), 0o600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("BASE_HELPER_GENERATE=%s\n", marker)
case "compare":
flags := flag.NewFlagSet("compare", flag.ExitOnError)
candidateManifest := flags.String("candidate-flag-migrations", "", "candidate manifest")
current := flags.String("current", "", "current snapshot")
base := flags.String("base", "", "base snapshot")
stable := flags.String("stable", "", "stable snapshot")
_ = flags.String("approved-flag-migrations", "", "approved manifest")
_ = flags.Parse(os.Args[2:])
for _, path := range []string{*current, *base, *stable} {
content, err := os.ReadFile(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if string(content) != "AUTHORITY=BASE\n" {
fmt.Fprintf(os.Stderr, "snapshot did not use base internal helper: %s", content)
os.Exit(2)
}
}
if *candidateManifest != "" {
content, err := os.ReadFile(*candidateManifest)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("CANDIDATE_MANIFEST=%s\n", *candidateManifest)
if string(content) != "{\"version\":1,\"migrations\":[]}\n" {
fmt.Println("UNCOMMITTED_MANIFEST_USED")
return
}
}
fmt.Fprintln(os.Stderr, "BASE_COMPARATOR_ENFORCED")
os.Exit(23)
default:
fmt.Fprintln(os.Stderr, "unknown command")
os.Exit(2)
}
}
`
const governedSnapshotSource = `package interfacesnapshot
import "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate"
var (
_ = runtimeannotate.AnnotationFlagAliasOf
_ = runtimeannotate.AnnotationFlagAliasOrigin
_ = runtimeannotate.FlagAliasOriginCorecmdV1
)
func AuthorityMarker() string { return "BASE" }
`
const baseSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "BASE" }
`
const stableSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "STABLE" }
`
const candidateSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "CANDIDATE" }
`
const aliasContractSource = `package runtimeannotate
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
`
const candidateNoopSource = `package main
import "fmt"
func main() {
fmt.Println("CANDIDATE_NOOP")
}
`
type authorityScenario struct {
name string
baseGovernance string
candidateMutation string
want string
wantAuthorityDecision bool
}
func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
tests := []authorityScenario{
{
name: "governed base rejects candidate helper and live manifest",
baseGovernance: "complete",
wantAuthorityDecision: true,
},
{
name: "bootstrap uses old base helper",
baseGovernance: "none",
wantAuthorityDecision: true,
},
{
name: "partial base fails closed",
baseGovernance: "partial",
want: "incomplete flag migration governance artifact set",
},
{
name: "base ledger ancestor symlink fails closed",
baseGovernance: "ancestor-symlink",
want: "incomplete flag migration governance artifact set",
},
{
name: "governed candidate cannot delete alias contract",
baseGovernance: "complete",
candidateMutation: "delete-alias-contract",
want: "must preserve the complete flag migration governance artifact set",
},
{
name: "governed candidate cannot replace helper parent with symlink",
baseGovernance: "complete",
candidateMutation: "symlink-helper-parent",
want: "must preserve the complete flag migration governance artifact set",
},
{
name: "candidate cannot forge alias evidence token",
baseGovernance: "complete",
candidateMutation: "forge-alias-token",
want: "may not forge framework-owned flag alias evidence",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
runBaseOwnedAuthorityCase(t, test)
})
}
}
func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
t.Helper()
repoRoot := repositoryRoot(t)
managedRoot := filepath.Join(repoRoot, ".worktrees", "commandcompat-authority-integration")
if err := os.MkdirAll(managedRoot, 0o755); err != nil {
t.Fatalf("create managed test root: %v", err)
}
fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano()))
if err != nil {
t.Fatalf("create managed fixture repo: %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(fixtureRoot); err != nil {
t.Errorf("remove managed fixture repo: %v", err)
}
})
copyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "check-command-compatibility.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "check-command-compatibility.sh"),
0o755,
)
copyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"),
0o755,
)
copyFile(t,
filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"),
filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"),
0o755,
)
writeFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-command-compat-authority-test\n\ngo 1.23\n", 0o644)
writeFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityHelperSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), stableSnapshotSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644)
run(t, fixtureRoot, "git", "init", "-b", "main")
run(t, fixtureRoot, "git", "config", "user.name", "Command Compatibility Test")
run(t, fixtureRoot, "git", "config", "user.email", "command-compat-test@example.invalid")
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "old stable without migration governance")
stableCommit := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD"))
run(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit)
writeFile(t, filepath.Join(fixtureRoot, "BASE_MARKER"), "newer merge-base\n", 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), baseSnapshotSource, 0o644)
switch test.baseGovernance {
case "complete":
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), governedSnapshotSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
writeFile(t,
filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"),
aliasContractSource,
0o644,
)
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
case "partial":
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
case "ancestor-symlink":
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
writeFile(t,
filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"),
aliasContractSource,
0o644,
)
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil {
t.Fatalf("symlink base ledger parent: %v", err)
}
case "none":
default:
t.Fatalf("unknown base governance state %q", test.baseGovernance)
}
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "base authority")
baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD"))
if baseRef == stableCommit {
t.Fatal("fixture stable tag must predate the governed merge-base")
}
// A withdrawn higher GA tag is reserved history, not the active stable
// compatibility baseline. The checker must agree with release-contract.sh.
run(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef)
run(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef)
// 候选提交故意把生成器与比较器改成无条件成功;若脚本错误地信任候选代码,
// 整个检查会输出 CANDIDATE_NOOP 并以 0 退出。
writeFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), candidateSnapshotSource, 0o644)
if test.baseGovernance != "complete" {
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
writeFile(t,
filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"),
aliasContractSource,
0o644,
)
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
}
switch test.candidateMutation {
case "":
case "delete-alias-contract":
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go")); err != nil {
t.Fatalf("remove candidate alias contract: %v", err)
}
case "forge-alias-token":
writeFile(t,
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
0o644,
)
case "symlink-helper-parent":
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
if err := os.Rename(helperPath, alternatePath); err != nil {
t.Fatalf("move candidate helper behind ancestor symlink: %v", err)
}
if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil {
t.Fatalf("symlink candidate helper parent: %v", err)
}
default:
t.Fatalf("unknown candidate mutation %q", test.candidateMutation)
}
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "malicious candidate comparator")
if test.baseGovernance == "complete" && test.candidateMutation == "" {
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[{\"uncommitted\":true}]}\n",
0o644,
)
}
cmd := exec.Command(
"sh",
"scripts/policy/check-command-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "v1.0.0",
"--candidate-ref", "HEAD",
)
cmd.Dir = fixtureRoot
cmd.Env = integrationEnvironment(fixtureRoot)
output, runErr := cmd.CombinedOutput()
got := string(output)
if runErr == nil {
t.Fatalf("compatibility script unexpectedly succeeded; output:\n%s", got)
}
if strings.Contains(got, "CANDIDATE_NOOP") {
t.Fatalf("candidate-owned no-op helper took control; output:\n%s", got)
}
if strings.Contains(got, "UNCOMMITTED_MANIFEST_USED") {
t.Fatalf("compatibility script mixed the live manifest with the committed candidate surface; output:\n%s", got)
}
if test.wantAuthorityDecision {
if count := strings.Count(got, "BASE_HELPER_GENERATE=BASE"); count != 3 {
t.Fatalf("compatibility script generated %d/3 snapshots with the base-owned internal helper; output:\n%s", count, got)
}
if !strings.Contains(got, "BASE_COMPARATOR_ENFORCED") {
t.Fatalf("compatibility script did not use the base-owned comparator; output:\n%s", got)
}
if test.baseGovernance == "complete" && !strings.Contains(got, "CANDIDATE_MANIFEST=") {
t.Fatalf("base comparator did not receive the committed candidate manifest; output:\n%s", got)
}
} else if !strings.Contains(got, test.want) {
t.Fatalf("compatibility script output missing %q:\n%s", test.want, got)
}
if !test.wantAuthorityDecision {
return
}
wrongStable := exec.Command(
"sh",
"scripts/policy/check-command-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "HEAD",
"--candidate-ref", "HEAD",
)
wrongStable.Dir = fixtureRoot
wrongStable.Env = integrationEnvironment(fixtureRoot)
wrongOutput, wrongErr := wrongStable.CombinedOutput()
if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") {
t.Fatalf("arbitrary stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput)
}
}
func repositoryRoot(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-command-compatibility.sh")); err != nil {
t.Fatalf("resolve repository root %s: %v", root, err)
}
return root
}
func copyFile(t *testing.T, source, destination string, mode os.FileMode) {
t.Helper()
content, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read %s: %v", source, err)
}
writeFile(t, destination, string(content), mode)
}
func writeFile(t *testing.T, path, content string, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create parent for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func run(t *testing.T, dir, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
}
return string(output)
}
func integrationEnvironment(fixtureRoot string) []string {
overrides := map[string]string{
"DWS_POLICY_TMPDIR": "",
"GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"),
"GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"),
"GOTMPDIR": "",
"GOTOOLCHAIN": "local",
"GOWORK": "off",
}
environment := make([]string, 0, len(os.Environ())+len(overrides))
for _, item := range os.Environ() {
key, _, _ := strings.Cut(item, "=")
if _, replaced := overrides[key]; !replaced {
environment = append(environment, item)
}
}
for key, value := range overrides {
environment = append(environment, key+"="+value)
}
return environment
}
+21 -9
View File
@@ -312,10 +312,13 @@ func checkCompatibility(root *cobra.Command, baseline interfaceContract) []strin
continue
}
if actual.Value.Type() != expectedFlag.Type {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(),
))
if !reviewedFlagTypeChange(displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type()) ||
flagContractOtherwiseChanged(expectedFlag, actual, actualScope) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(),
))
}
}
if expectedFlag.Shorthand != "" && actual.Shorthand != expectedFlag.Shorthand {
failures = append(failures, fmt.Sprintf(
@@ -443,11 +446,20 @@ func mergeContracts(historical, current interfaceContract) (interfaceContract, [
continue
}
if oldFlag.Type != newFlag.Type {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), name, oldFlag.Type, newFlag.Type,
))
continue
if !reviewedFlagTypeChange(displayPath(path), name, oldFlag.Type, newFlag.Type) ||
mergedFlagContractOtherwiseChanged(oldFlag, newFlag) {
failures = append(failures, fmt.Sprintf(
"%q flag --%s changed type from %s to %s",
displayPath(path), name, oldFlag.Type, newFlag.Type,
))
continue
}
// A reviewed migration keeps merging, and the merged entry keeps
// the historical type below, exactly as this function keeps the
// historical shorthand, required and hidden values. The baseline
// records what was promised, so a later --check still resolves
// this flag through the reviewed table rather than silently
// adopting the new type as history.
}
if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand {
failures = append(failures, fmt.Sprintf(
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/spf13/cobra"
)
@@ -239,6 +240,286 @@ func assertFailureContains(t *testing.T, failures []string, want string) {
t.Fatalf("failures %v do not contain %q", failures, want)
}
// policyRoot builds "dws old" with a single --policy flag declared by declare,
// so a baseline and a candidate can differ only in how that flag is typed.
func policyRoot(declare func(*cobra.Command)) *cobra.Command {
root := &cobra.Command{Use: "dws"}
old := &cobra.Command{Use: "old", Run: func(*cobra.Command, []string) {}}
declare(old)
root.AddCommand(old)
root.InitDefaultHelpCmd()
return root
}
// registerReviewedFixture puts a fixture command in the reviewed table for the
// duration of one test, so the behaviour tests exercise the real lookup instead
// of depending on whichever production entries happen to exist.
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
t.Helper()
if _, exists := reviewedFlagTypeChanges[change]; exists {
t.Fatalf("fixture %+v collides with a production entry", change)
}
reviewedFlagTypeChanges[change] = struct{}{}
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
}
const typeChangedFailure = "changed type from string to int"
func TestReviewedFlagTypeChangeAcceptsRegisteredMigration(t *testing.T) {
baseline := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
}))
current := policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
})
// Without an entry the migration is still a blocking change.
assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure)
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
if failures := checkCompatibility(current, baseline); len(failures) != 0 {
t.Fatalf("a reviewed string->int migration should pass: %v", failures)
}
}
func TestReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
// The reverse migration shares command and flag but not direction, so the
// string->int entry must not admit it.
baseline := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
}))
current := policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
})
assertFailureContains(t, checkCompatibility(current, baseline), "changed type from int to string")
}
func TestReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws other", Flag: "policy", From: "string", To: "int"})
baseline := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
}))
current := policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
})
// Same flag and direction, different command.
assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure)
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "elsewhere", From: "string", To: "int"})
// Same command and direction, different flag.
assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure)
}
// A reviewed migration is only accepted when the rest of the flag's contract
// held still. Each case bundles one unrelated regression with the reviewed type
// change and requires the type failure to reappear, which also anchors
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
// drifts out of sync fails here instead of silently widening the exemption.
func TestReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
tests := []struct {
name string
baseline func(*cobra.Command)
current func(*cobra.Command)
want string
}{
{
name: "became required",
baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.MarkFlagRequired("policy")
},
want: "became required",
},
{
name: "became hidden",
baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.Flags().MarkHidden("policy")
},
want: "became hidden",
},
{
name: "lost shorthand",
baseline: func(command *cobra.Command) { command.Flags().StringP("policy", "p", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
want: "lost shorthand",
},
{
name: "changed no-opt value",
baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
command.Flags().Lookup("policy").NoOptDefVal = "4"
},
want: "changed no-opt value",
},
{
name: "narrowed persistent scope",
baseline: func(command *cobra.Command) { command.PersistentFlags().String("policy", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
want: "narrowed persistent scope",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
failures := checkCompatibility(policyRoot(test.current), snapshot(policyRoot(test.baseline)))
assertFailureContains(t, failures, test.want)
assertFailureContains(t, failures, typeChangedFailure)
})
}
}
// mergeContracts runs the same admission decision on --merge. A reviewed
// migration keeps merging and the merged entry keeps the historical type, so a
// later --check still resolves the flag through the reviewed table.
func TestMergeAcceptsReviewedFlagTypeChangeAndKeepsHistoricalType(t *testing.T) {
historical := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().String("policy", "", "policy")
}))
current := snapshot(policyRoot(func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
}))
if _, failures := mergeContracts(historical, current); len(failures) == 0 {
t.Fatal("an unreviewed merge should report the type change")
}
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
merged, failures := mergeContracts(historical, current)
if len(failures) != 0 {
t.Fatalf("a reviewed merge should pass: %v", failures)
}
if got := merged.Commands["old"].Flags["policy"].Type; got != "string" {
t.Fatalf("merged type = %q, want the historical %q", got, "string")
}
}
// mergeContracts applies the same "nothing else moved" condition as
// checkCompatibility, but against two recorded contracts rather than a live
// pflag definition. Each case bundles one unrelated regression with the reviewed
// type change and requires the type failure to reappear, anchoring
// mergedFlagContractOtherwiseChanged against the checks it mirrors.
func TestMergeRejectsReviewedFlagTypeChangeWithBundledRegression(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"})
tests := []struct {
name string
historical func(*cobra.Command)
current func(*cobra.Command)
}{
{
name: "lost shorthand",
historical: func(command *cobra.Command) { command.Flags().StringP("policy", "p", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
},
{
name: "became required",
historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.MarkFlagRequired("policy")
},
},
{
name: "became hidden",
historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
_ = command.Flags().MarkHidden("policy")
},
},
{
name: "changed no-opt value",
historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") },
current: func(command *cobra.Command) {
command.Flags().Int("policy", 0, "policy")
command.Flags().Lookup("policy").NoOptDefVal = "4"
},
},
{
name: "narrowed persistent scope",
historical: func(command *cobra.Command) { command.PersistentFlags().String("policy", "", "policy") },
current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, failures := mergeContracts(
snapshot(policyRoot(test.historical)),
snapshot(policyRoot(test.current)),
)
assertFailureContains(t, failures, typeChangedFailure)
})
}
}
// The behaviour tests above register their own fixtures, so they cannot catch a
// production entry whose key is spelled in the wrong form — that mistake
// disables the exemption silently and only a real gate run reports it. Recompute
// every key through displayPath instead of trusting the spelling in the table.
func TestReviewedFlagTypeChangeKeysUseDisplayPaths(t *testing.T) {
if len(reviewedFlagTypeChanges) == 0 {
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
}
for change := range reviewedFlagTypeChanges {
if got := displayPath(internalCommandPath(change.CommandPath)); got != change.CommandPath {
t.Errorf("命令路径不是 displayPath 的规范形态\n 登记: %s\n 规范: %s", change.CommandPath, got)
}
if change.Flag == "" || strings.HasPrefix(change.Flag, "-") {
t.Errorf("%s: flag 名应是裸名(lookupFlag 用裸名查找),登记为 %q", change.CommandPath, change.Flag)
}
if change.From == "" || change.To == "" || change.From == change.To {
t.Errorf("%s --%s: %q -> %q 不构成类型迁移", change.CommandPath, change.Flag, change.From, change.To)
}
}
}
// Anchor every production entry to the real command tree. A misspelled command
// or flag name would leave the exemption permanently inert, and the behaviour
// tests cannot see that because they register their own paths.
func TestReviewedFlagTypeChangeEntriesResolveInTheRealCommandTree(t *testing.T) {
if len(reviewedFlagTypeChanges) == 0 {
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
}
root := app.NewRootCommand()
for change := range reviewedFlagTypeChanges {
command, resolved := resolveCommand(root, internalCommandPath(change.CommandPath))
if !resolved {
t.Errorf("%q 在真实命令树中不存在", change.CommandPath)
continue
}
flag, _ := lookupFlag(command, change.Flag)
if flag == nil {
t.Errorf("%q 上不存在 --%s", change.CommandPath, change.Flag)
continue
}
// Exactly one side holds depending on whether the migration has landed:
// From before it does, To afterwards. Asserting membership catches a
// misspelled type name without failing while the migration is pending.
if actual := flag.Value.Type(); actual != change.From && actual != change.To {
t.Errorf("%q --%s 当前类型 %q 既不是 %q 也不是 %q",
change.CommandPath, change.Flag, actual, change.From, change.To)
}
}
}
// internalCommandPath inverts displayPath so a table key can be fed back into
// resolveCommand and displayPath.
func internalCommandPath(displayed string) string {
if displayed == "dws" {
return "root"
}
return strings.ReplaceAll(strings.TrimPrefix(displayed, "dws "), " ", ".")
}
func testRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
old := &cobra.Command{Use: "old"}
@@ -0,0 +1,140 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import "github.com/spf13/pflag"
// flagTypeChange identifies one flag type migration exactly: which command,
// which flag, and in which direction. Nothing is matched by wildcard — an entry
// that differs in any of the four fields does not apply.
//
// CommandPath is the display form produced by displayPath ("dws minutes
// permission apply"), not the dot-separated internal path. Writing the internal
// form here would silently disable the exemption instead of failing loudly, so
// TestReviewedFlagTypeChangeKeysUseDisplayPaths recomputes every key through
// displayPath rather than trusting the spelling below.
//
// This table is duplicated in internal/interfacesnapshot/reviewed.go and the two
// must stay identical; the interfacesnapshot package has a test that fails if
// they drift. The duplication is forced rather than chosen:
// check-authoritative-interface-baselines.sh copies this whole directory into a
// worktree checked out at a historical revision and builds it there, so nothing
// here may import a package this branch adds — that build would fail with an
// unresolved import instead of reporting a compatibility result.
type flagTypeChange struct {
CommandPath string
Flag string
From string
To string
}
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
// migrations this gate accepts. A flag type is part of the published CLI
// contract, and a type swap cannot be proven safe from the type names alone —
// whether old invocations still parse depends on the concrete value domain the
// command enforces. So a migration is accepted only when this exact command,
// flag, and old→new pair appear here. Adding an entry is a contract decision
// and belongs in review, not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "int" is a
// separate key from "int" → "string" and only the reviewed direction is
// accepted. The reverse is not a candidate for this table — widening an int
// flag back to string lets values the parser used to reject reach RunE, which
// is a genuine loosening of validation rather than a type migration.
//
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
// only when nothing else about the flag moved. See the callers in
// checkCompatibility and mergeContracts.
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag parsed
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
// same [2,4] domain check in RunE.
//
// This is admissible because the historical set of *successful* invocations
// is a subset of the new one, not merely similar to it:
//
// - Old: the value had to parse as base-10 and land in [2,4]. That is
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
// ("+3", "03", "003", ...).
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
// resolves to the same number under base 0, so no historical success
// becomes a failure.
// - Base 0 additionally accepts "0x3" and friends, which the old parser
// rejected. That widens the accepted set; it cannot break a caller who
// was already succeeding.
// - Values outside [2,4] and non-numeric values still fail. Only the
// failure *message and timing* move, from RunE to flag parsing.
//
// The flag's default value changes from "" to "0" as an unavoidable
// consequence of the type. Neither gate compares defaults, and a default is
// not reachable by a caller here because RunE requires the flag to be
// explicitly set, so this is not a contract change.
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
}
// reviewedFlagTypeChange reports whether this exact command, flag and direction
// is a reviewed migration. Callers must additionally establish that the rest of
// the flag's contract is unchanged before skipping the failure.
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
CommandPath: commandPath,
Flag: flag,
From: from,
To: to,
}]
return reviewed
}
// flagContractOtherwiseChanged reports whether anything checkCompatibility
// enforces about a flag, other than its type, regressed against the baseline. A
// reviewed type migration is only accepted when this returns false, so the
// exemption cannot carry an unrelated regression in behind it.
//
// Every condition here mirrors one of the checks that follow the type check in
// checkCompatibility and must stay in step with them. The pairing is anchored by
// tests that bundle each regression with the reviewed type change and require
// the type failure to reappear, so a condition that drifts out of sync fails
// loudly instead of silently widening the exemption.
func flagContractOtherwiseChanged(expected flagContract, actual *pflag.Flag, actualScope string) bool {
if expected.Shorthand != "" && actual.Shorthand != expected.Shorthand {
return true
}
if expected.RequiredSet && !expected.Required && isRequiredFlag(actual) {
return true
}
if expected.HiddenSet && !expected.Hidden && actual.Hidden {
return true
}
if expected.NoOptSet && actual.NoOptDefVal != expected.NoOpt {
return true
}
if expected.ScopeSet && expected.Scope == "persistent" && actualScope == "local" {
return true
}
return false
}
// mergedFlagContractOtherwiseChanged is the mergeContracts counterpart of
// flagContractOtherwiseChanged, comparing two recorded contracts rather than a
// contract against a live pflag definition. It mirrors the checks that follow
// the type check in mergeContracts.
func mergedFlagContractOtherwiseChanged(oldFlag, newFlag flagContract) bool {
if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand {
return true
}
if oldFlag.RequiredSet && !oldFlag.Required && newFlag.Required {
return true
}
if oldFlag.HiddenSet && !oldFlag.Hidden && newFlag.Hidden {
return true
}
if oldFlag.NoOptSet && newFlag.NoOptSet && oldFlag.NoOpt != newFlag.NoOpt {
return true
}
if oldFlag.ScopeSet && oldFlag.Scope == "persistent" && newFlag.Scope == "local" {
return true
}
return false
}
@@ -0,0 +1 @@
{"version":1,"migrations":[]}
+685 -4
View File
@@ -12,12 +12,27 @@ import (
"fmt"
"io"
"os"
"reflect"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
const schemaContractVersion = 3
// propertySourceReviewedMappingExclusion is the provenance source a parameter
// reports when its property is deliberately omitted through the reviewed
// mapping exclusion table (internal/cli/schema_parameter_mapping_ledger.go).
// Only this source qualifies for the property-clearing carve-out in
// checkParameterCompatibility.
const propertySourceReviewedMappingExclusion = "reviewed_mapping_exclusion"
// interfaceModeMCP is the interface_mode of a tool backed by exactly one RPC.
// Only a tool that is mcp on both sides can qualify for the interface_ref
// redirect carve-out in compatibleInterfaceRefRedirect.
const interfaceModeMCP = "mcp"
type schemaContract struct {
Version int `json:"version"`
Products map[string]productSchema `json:"products"`
@@ -53,6 +68,7 @@ type positionalSchema struct {
type parameterSchema struct {
Type string `json:"type"`
Property string `json:"property,omitempty"`
PropertySource string `json:"property_source,omitempty"`
InterfaceType string `json:"interface_type,omitempty"`
Required bool `json:"required,omitempty"`
CLIRequired bool `json:"cli_required,omitempty"`
@@ -69,12 +85,19 @@ func main() {
func run(args []string, stdout, stderr io.Writer) int {
var normalizePath, checkPath, mergePath, currentPath string
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
flags.SetOutput(stderr)
flags.StringVar(&normalizePath, "normalize", "", "normalize a raw complete Schema response")
flags.StringVar(&checkPath, "check", "", "check against a normalized historical baseline")
flags.StringVar(&mergePath, "merge", "", "merge additions into a normalized historical baseline")
flags.StringVar(&currentPath, "current", "", "raw current complete Schema response")
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
if err := flags.Parse(args); err != nil {
return 2
}
@@ -89,6 +112,27 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
return 2
}
migrationInputs := []string{
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
}
migrationInputCount := 0
for _, path := range migrationInputs {
if path != "" {
migrationInputCount++
}
}
if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) {
fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs")
return 2
}
if migrationInputCount != 0 && checkPath == "" {
fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check")
return 2
}
if normalizePath != "" {
currentPath = normalizePath
@@ -115,6 +159,24 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
return 2
}
if migrationInputCount != 0 {
migrations, err := authorizeSchemaFlagMigrations(
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
)
if err != nil {
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
failures := checkCompatibility(baseline, current)
if len(failures) > 0 {
fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:")
@@ -146,6 +208,57 @@ func run(args []string, stdout, stderr io.Writer) int {
return 0
}
func authorizeSchemaFlagMigrations(
approvedManifestPath string,
candidateManifestPath string,
currentSnapshotPath string,
baseSnapshotPath string,
stableSnapshotPath string,
) ([]interfacesnapshot.FlagMigration, error) {
approved, err := readFlagMigrationManifestFile(approvedManifestPath)
if err != nil {
return nil, fmt.Errorf("read approved flag migrations: %w", err)
}
candidate, err := readFlagMigrationManifestFile(candidateManifestPath)
if err != nil {
return nil, fmt.Errorf("read candidate flag migrations: %w", err)
}
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration current snapshot: %w", err)
}
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration base snapshot: %w", err)
}
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
}
return interfacesnapshot.AuthorizeFlagMigrations(
current,
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
approved,
candidate,
)
}
func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigrationManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
}
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
return interfacesnapshot.Read(bytes.NewReader(data))
}
func normalizeRawFile(path string) (schemaContract, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -361,6 +474,11 @@ func normalizeParameter(raw json.RawMessage) (parameterSchema, error) {
InterfaceDefault json.RawMessage `json:"interface_default"`
Format string `json:"format"`
Enum []string `json:"enum"`
FieldProvenance struct {
Property struct {
Source string `json:"source"`
} `json:"property"`
} `json:"field_provenance"`
}
if err := json.Unmarshal(raw, &parameter); err != nil {
return parameterSchema{}, err
@@ -388,6 +506,7 @@ func normalizeParameter(raw json.RawMessage) (parameterSchema, error) {
return parameterSchema{
Type: parameterType,
Property: strings.TrimSpace(parameter.Property),
PropertySource: strings.TrimSpace(parameter.FieldProvenance.Property.Source),
InterfaceType: strings.TrimSpace(parameter.InterfaceType),
Required: parameter.Required,
CLIRequired: parameter.CLIRequired,
@@ -479,7 +598,6 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}{
{name: "primary_cli_path", old: oldTool.PrimaryCLIPath, new: newTool.PrimaryCLIPath},
{name: "interface_mode", old: oldTool.InterfaceMode, new: newTool.InterfaceMode},
{name: "interface_ref", old: oldTool.InterfaceRef, new: newTool.InterfaceRef},
{name: "availability", old: oldTool.Availability, new: newTool.Availability},
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
@@ -492,7 +610,8 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}
if oldTool.Constraints != newTool.Constraints &&
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
!compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
}
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
@@ -510,10 +629,109 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}
failures = append(failures, checkParameterCompatibility(toolPath, parameter, oldParameter, newParameter)...)
}
// interface_ref is evaluated last, because the redirect carve-out below is
// conditional on every other check for this tool having passed.
if oldTool.InterfaceRef != newTool.InterfaceRef &&
!compatibleInterfaceRefRedirect(toolPath, oldTool, newTool, failures) {
failures = append(failures, fmt.Sprintf("schema tool %q changed interface_ref", toolPath))
}
sort.Strings(failures)
return failures
}
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
// RPCs share business semantics, permissions, error behaviour, or side effects,
// so a redirect is only accepted when the specific tool and the specific
// old→new pair appear here. Any other ref change is still reported.
//
// Keyed by tool path ("<product>/<tool id>"), then by the previous
// interface_ref, with the value being the single accepted new ref. Both refs are
// the canonicalized interface_ref JSON exactly as parseTool produces it (see
// canonicalRawJSON) — not a bare RPC name. Adding an entry is a contract
// decision and belongs in review, not in a feature change.
var reviewedInterfaceRefRedirect = map[string]map[string]string{
// The style surface moved from update_range (which writes values) to
// set_cell_range's cellStyles payload (style-only, preserving values). This
// is the only channel that can express italic / underline / strike-through /
// font family / borders. Same product, same target range semantics, same
// permission scope; the flat style properties it loses are accepted
// separately as reviewed mapping exclusions.
"sheet/sheet.range_set_style": {
`{"product_id":"sheet","rpc_name":"update_range"}`: `{"product_id":"sheet","rpc_name":"set_cell_range"}`,
},
}
// reviewedConstraintTransition enumerates exact constraint changes that were
// explicitly reviewed even though the compatibility gate cannot infer their
// business semantics. Keep this narrow: removing a constraint can expose a new
// runtime route, so arbitrary removals must not pass as harmless drift.
var reviewedConstraintTransition = map[string]map[string]string{
// PR #933 aligned the Shortcut and Schema with the existing doc import
// runtime: omitting both targets imports into the default root. Keeping the
// historical require_one_of made the documented Golden Route unreachable.
"doc/doc.shortcut_import": {
`{"require_one_of":[["folder","workspace"]]}`: "",
},
}
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
transitions, ok := reviewedConstraintTransition[toolPath]
if !ok {
return false
}
want, ok := transitions[oldTool.Constraints]
if !ok {
return false
}
return want == newTool.Constraints
}
// compatibleInterfaceRefRedirect accepts repointing a tool at a different
// backing RPC when the migration is an explicitly reviewed entry in
// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably
// unchanged.
//
// interface_ref is audit and traceability metadata: it records which RPC backs a
// leaf. Nothing reads it at runtime — the tool a leaf invokes is decided in the
// CLI source, so a stale ref does not misroute a call, it only misinforms a
// reader. When the backing RPC genuinely moves, the honest options are to update
// the ref or to keep publishing a name that no longer matches the request being
// sent.
//
// Being audit-only is why a reviewed redirect can be accepted at all; it is not
// a reason to accept redirects in general. Two RPCs with compatible Schema
// parameters may still differ in permissions, quota, error taxonomy, or side
// effects, none of which this gate can see. Hence the allowlist below, plus:
//
// - interface_mode is unchanged and stays "mcp". A move to or from
// "composite" is a change in kind, not a redirect, and is still reported.
// - both refs are non-empty. Removing a ref is not a redirect.
// - no other compatibility failure was recorded for this tool. This is the
// operative meaning of "the CLI contract is unchanged": no parameter was
// lost, none became required, no type / default / format / enum moved, no
// constraint tightened, no positional or dry_run change. Any one of those
// re-reports the redirect, so the exemption cannot smuggle a surface change
// in behind a backend move.
//
// A cleared property that resolved through a reviewed mapping exclusion is
// already accepted by checkParameterCompatibility and so does not block this;
// that pairing is expected, since a leaf moving to a nested payload loses its
// flat property names in the same change.
func compatibleInterfaceRefRedirect(toolPath string, oldTool, newTool toolSchema, otherFailures []string) bool {
if oldTool.InterfaceMode != newTool.InterfaceMode || newTool.InterfaceMode != interfaceModeMCP {
return false
}
if oldTool.InterfaceRef == "" || newTool.InterfaceRef == "" {
return false
}
if reviewedInterfaceRefRedirect[toolPath][oldTool.InterfaceRef] != newTool.InterfaceRef {
return false
}
return len(otherFailures) == 0
}
// compatibleAdditiveConstraintEvolution accepts constraint evolution that
// cannot invalidate an invocation expressible by the historical public
// parameter contract. Existing groups may only gain members; additions to a
@@ -722,15 +940,120 @@ func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bo
return true
}
// parameterTypeChange identifies one published parameter type migration
// exactly: which tool, which parameter, and in which direction. Nothing is
// matched by wildcard — an entry differing in any of the four fields does not
// apply.
//
// ToolPath is "<product id>/<tool id>" exactly as the comparison builds it (see
// where toolPath is assembled), e.g.
// "minutes/minutes.apply_minutes_permission". From and To are the canonical
// type values schemaType produces — the JSON encoding of the "type" keyword,
// so `"string"` **with** its quotes, not a bare string. Spelling either one any
// other way silently disables the exemption instead of failing loudly (exactly
// how reviewedInterfaceRefRedirect was broken twice), so
// TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical
// recomputes both through schemaType rather than trusting the table.
type parameterTypeChange struct {
ToolPath string
Parameter string
From string
To string
}
// reviewedParameterTypeChanges enumerates the individually reviewed parameter
// type migrations this gate accepts. A published type is part of the Schema
// contract and a swap cannot be proven safe from the type names alone, so a
// migration is accepted only when this exact tool, parameter and old→new pair
// appear here. Adding an entry is a contract decision and belongs in review,
// not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "integer" is a
// separate key from "integer" → "string", and only the reviewed direction is
// accepted.
//
// The table alone does not admit a change: the migration is rejected unless
// every other published field of the parameter is identical — not merely free
// of compatibility failures. See compatibleReviewedParameterTypeChange.
var reviewedParameterTypeChanges = map[parameterTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag to a
// native Int flag, so the published type follows it from "string" to
// "integer". A parameter's type is projected from the Cobra flag type
// (provenance source cobra_flag_type), so it describes how the CLI accepts
// the value, not how the backing RPC receives it.
//
// Accepted because no historical caller is invalidated:
//
// - Consumers read this Schema to construct CLI invocations, and a command
// line is text either way: "--policy 4" is the same argv under both
// declarations, and a quoted "--policy \"4\"" still reaches pflag as 4.
// - The flag keeps enforcing the same [2,4] domain in RunE, and pflag's
// base-0 parse accepts every base-10 spelling the previous
// strconv.ParseInt(v, 10, 64) accepted, so the set of successful
// invocations only widens.
// - "integer" also matches what actually goes on the wire: this parameter
// maps to property "policyId", which the command has always sent as a
// number, so the new declaration is closer to the request than the old.
//
// The parameter's default stays absent on both sides here (a zero default is
// not published) and every other published field is identical, which is why
// the equality guard admits this entry. A migration that also moved the
// default — or relaxed required, cleared required_when, widened enum, cleared
// interface_type — would be rejected even though several of those are
// individually compatible, because none of them were reviewed under this
// entry.
{
ToolPath: "minutes/minutes.apply_minutes_permission",
Parameter: "policy",
From: `"string"`,
To: `"integer"`,
}: {},
}
// parameterContractUnchangedExceptType reports whether every published field of
// the parameter other than its type is identical.
//
// This is a full equality check rather than "the gate recorded no other
// failure", because several field changes are individually *compatible* and so
// produce no failure at all: relaxing required or cli_required, clearing
// required_when, widening enum, clearing interface_type, and clearing property
// through a reviewed mapping exclusion. Keying the carve-out on the failure list
// would let any of those ride along with a reviewed type migration without ever
// having been reviewed under that entry — the exemption would be wider than what
// it documents.
//
// Comparing the struct also means a field added to parameterSchema later is
// covered here automatically, instead of silently widening every existing entry.
func parameterContractUnchangedExceptType(oldParameter, newParameter parameterSchema) bool {
oldParameter.Type = ""
newParameter.Type = ""
return reflect.DeepEqual(oldParameter, newParameter)
}
// compatibleReviewedParameterTypeChange accepts a published parameter type
// migration when it is an explicitly reviewed entry **and** the rest of the
// parameter's published contract is unchanged.
func compatibleReviewedParameterTypeChange(toolPath, name string, oldParameter, newParameter parameterSchema) bool {
_, reviewed := reviewedParameterTypeChanges[parameterTypeChange{
ToolPath: toolPath,
Parameter: name,
From: oldParameter.Type,
To: newParameter.Type,
}]
return reviewed && parameterContractUnchangedExceptType(oldParameter, newParameter)
}
func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string {
var failures []string
if oldParameter.Type != newParameter.Type &&
!compatibleReviewedParameterTypeChange(toolPath, name, oldParameter, newParameter) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed type", toolPath, name))
}
for _, field := range []struct {
name string
old string
new string
}{
{name: "type", old: oldParameter.Type, new: newParameter.Type},
{name: "property", old: oldParameter.Property, new: newParameter.Property},
{name: "default", old: oldParameter.Default, new: newParameter.Default},
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
{name: "format", old: oldParameter.Format, new: newParameter.Format},
@@ -739,6 +1062,36 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name))
}
}
// Clearing property is accepted as compatible only when the new value is
// omitted through a reviewed mapping exclusion. This is the same shape as
// the interface_type carve-out below: a declaration that the flag has no
// single top-level RPC property, replacing a value that is no longer true.
//
// It exists because a leaf whose backing RPC gains a nested payload has no
// honest flat property to publish. The alternative outcomes are both worse:
// keep naming a field the request no longer contains, or let assembly fall
// back to flag_name_inference and publish a name that appears in no request
// at all.
//
// The predicate is deliberately narrow:
// - old non-empty AND new empty (a redirect to a different non-empty
// value stays a contract break)
// - the new value resolved through reviewed_mapping_exclusion, so an
// accidental or silent drop is still reported
//
// The exclusion table cannot be abused to wave through arbitrary clearing:
// internal/cli/schema_parameter_bindings.go verifies that every parameter
// claiming an exclusion really does deliver an empty property, and every
// entry carries a non-empty reviewed reason.
//
// Consumers must treat a missing property as "no direct mapping" and read
// the provenance reason for where the value actually lands. Re-populating a
// property requires an explicit ParamDecl declaration.
if oldParameter.Property != newParameter.Property &&
!(oldParameter.Property != "" && newParameter.Property == "" &&
newParameter.PropertySource == propertySourceReviewedMappingExclusion) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed property", toolPath, name))
}
// Clearing interface_type is accepted as compatible: a deliberate,
// wire-visible policy decision taken with the pinned MCP metadata
// retirement. Production no longer projects MCP-sourced types unless
@@ -798,6 +1151,334 @@ func cloneContract(source schemaContract) schemaContract {
return cloned
}
type schemaToolRef struct {
productID string
toolID string
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
// rename onto a cloned historical Schema contract. The ordinary compatibility
// checker still makes the final decision; this adapter never drops findings by
// matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
migrations []interfacesnapshot.FlagMigration,
) (schemaContract, error) {
normalized := cloneContract(baseline)
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
// A reviewed CLI-only command has no Schema compatibility surface.
continue
}
if len(matches) != 1 {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q matches %d historical Schema tools",
migration.Command,
len(matches),
)
}
ref := matches[0]
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
newProduct, productExists := current.Products[ref.productID]
newTool, toolExists := newProduct.Tools[ref.toolID]
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
// Preserve the original baseline so the ordinary checker reports the
// missing tool or changed primary_cli_path.
continue
}
oldLegacy, legacyExisted := oldTool.Parameters[migration.Legacy.Name]
oldCanonical, canonicalExisted := oldTool.Parameters[migration.Canonical.Name]
if !legacyExisted && !canonicalExisted {
// A CLI migration is not evidence that an unrelated Schema surface
// published either name. In particular, it must not authorize a
// constraints rewrite with no historical parameter to rename.
continue
}
if _, exists := newTool.Parameters[migration.Legacy.Name]; exists {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q still publishes legacy Schema parameter %q",
migration.Command,
migration.Legacy.Name,
)
}
newCanonical, exists := newTool.Parameters[migration.Canonical.Name]
if !exists {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q does not publish canonical Schema parameter %q",
migration.Command,
migration.Canonical.Name,
)
}
if migration.Canonical.After.Required && (!newCanonical.Required || !newCanonical.CLIRequired) {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q canonical Schema parameter %q must be required and cli_required",
migration.Command,
migration.Canonical.Name,
)
}
if !legacyExisted {
// A consumed receipt may be evaluated after the merge-base Schema has
// already reached the canonical-only state. In that case the ordinary
// checker needs no projection. More importantly, a CLI migration is not
// authority to promote a canonical-only historical Schema parameter from
// optional to required: without legacy Schema evidence there is no rename.
continue
}
normalizedProduct := normalized.Products[ref.productID]
normalizedTool := normalizedProduct.Tools[ref.toolID]
if err := validateRenamedSchemaParameter(migration, oldLegacy, newCanonical); err != nil {
return schemaContract{}, err
}
delete(normalizedTool.Parameters, migration.Legacy.Name)
if canonicalExisted {
if err := validateRenamedSchemaParameter(migration, oldCanonical, newCanonical); err != nil {
return schemaContract{}, err
}
normalizedCanonical := normalizedTool.Parameters[migration.Canonical.Name]
normalizedCanonical.Required = newCanonical.Required
normalizedCanonical.CLIRequired = newCanonical.CLIRequired
normalizedTool.Parameters[migration.Canonical.Name] = normalizedCanonical
} else {
normalizedTool.Parameters[migration.Canonical.Name] = newCanonical
}
normalizedProduct.Tools[ref.toolID] = normalizedTool
normalized.Products[ref.productID] = normalizedProduct
if renamesByTool[ref] == nil {
renamesByTool[ref] = map[string]string{}
}
if existing, ok := renamesByTool[ref][migration.Legacy.Name]; ok && existing != migration.Canonical.Name {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q maps Schema parameter %q to both %q and %q",
migration.Command,
migration.Legacy.Name,
existing,
migration.Canonical.Name,
)
}
renamesByTool[ref][migration.Legacy.Name] = migration.Canonical.Name
}
for ref, renames := range renamesByTool {
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
newTool := current.Products[ref.productID].Tools[ref.toolID]
if oldTool.Constraints == newTool.Constraints {
continue
}
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
newConstraints, newOK := canonicalizeMigratedConstraints(newTool.Constraints, nil)
if !oldOK || !newOK || oldConstraints != newConstraints {
// Keep the historical constraints unchanged. The ordinary checker
// will report every non-rename constraint change.
continue
}
product := normalized.Products[ref.productID]
tool := product.Tools[ref.toolID]
tool.Constraints = newTool.Constraints
product.Tools[ref.toolID] = tool
normalized.Products[ref.productID] = product
}
return normalized, nil
}
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
var matches []schemaToolRef
for productID, product := range contract.Products {
for toolID, tool := range product.Tools {
if tool.PrimaryCLIPath == primaryPath {
matches = append(matches, schemaToolRef{productID: productID, toolID: toolID})
}
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].productID != matches[j].productID {
return matches[i].productID < matches[j].productID
}
return matches[i].toolID < matches[j].toolID
})
return matches
}
func validateRenamedSchemaParameter(
migration interfacesnapshot.FlagMigration,
oldParameter parameterSchema,
newParameter parameterSchema,
) error {
if oldParameter.Type != newParameter.Type ||
oldParameter.Property != newParameter.Property ||
oldParameter.InterfaceType != newParameter.InterfaceType ||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
oldParameter.Default != newParameter.Default ||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
oldParameter.Format != newParameter.Format ||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q changed a non-migration field",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
if oldParameter.Required && !newParameter.Required {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q became optional",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
if oldParameter.CLIRequired && !newParameter.CLIRequired {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q stopped being cli_required",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
return nil
}
func equalStringSlices(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func canonicalizeMigratedConstraints(raw string, renames map[string]string) (string, bool) {
groups, ok := parseMigrationConstraintsStrict(raw)
if !ok {
return "", false
}
normalized := map[string][][]string{}
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
for _, group := range groups[kind] {
members := make([]string, 0, len(group))
for _, member := range group {
if replacement := renames[member]; replacement != "" {
member = replacement
}
members = append(members, member)
}
sort.Strings(members)
members = compactConstraintStrings(members)
normalized[kind] = append(normalized[kind], members)
}
sort.Slice(normalized[kind], func(i, j int) bool {
return strings.Join(normalized[kind][i], "\x00") < strings.Join(normalized[kind][j], "\x00")
})
normalized[kind] = compactConstraintGroups(normalized[kind])
if len(normalized[kind]) == 0 {
delete(normalized, kind)
}
}
if len(normalized) == 0 {
return "", true
}
encoded, err := json.Marshal(normalized)
return string(encoded), err == nil
}
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return map[string][][]string{}, true
}
decoder := json.NewDecoder(strings.NewReader(trimmed))
opening, err := decoder.Token()
if err != nil || opening != json.Delim('{') {
return nil, false
}
groups := map[string][][]string{}
for decoder.More() {
fieldToken, err := decoder.Token()
if err != nil {
return nil, false
}
// encoding/json guarantees that an object member name is a string token.
field := fieldToken.(string)
switch field {
case "mutually_exclusive", "require_one_of", "require_together":
default:
return nil, false
}
if _, duplicate := groups[field]; duplicate {
return nil, false
}
var encodedGroups json.RawMessage
if err := decoder.Decode(&encodedGroups); err != nil {
return nil, false
}
encodedGroups = bytes.TrimSpace(encodedGroups)
if len(encodedGroups) == 0 || encodedGroups[0] != '[' {
return nil, false
}
var fieldGroups [][]string
if err := json.Unmarshal(encodedGroups, &fieldGroups); err != nil {
return nil, false
}
for _, group := range fieldGroups {
if len(group) == 0 {
return nil, false
}
for _, member := range group {
if member == "" || member != strings.TrimSpace(member) {
return nil, false
}
}
}
groups[field] = fieldGroups
}
closing, err := decoder.Token()
if err != nil || closing != json.Delim('}') {
return nil, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, false
}
return groups, true
}
func compactConstraintStrings(values []string) []string {
if len(values) < 2 {
return values
}
out := values[:1]
for _, value := range values[1:] {
if value != out[len(out)-1] {
out = append(out, value)
}
}
return out
}
func compactConstraintGroups(groups [][]string) [][]string {
if len(groups) < 2 {
return groups
}
out := groups[:1]
for _, group := range groups[1:] {
if !equalStringSlices(group, out[len(out)-1]) {
out = append(out, group)
}
}
return out
}
func writeContract(w io.Writer, contract schemaContract) error {
contract.Version = schemaContractVersion
if contract.Products == nil {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,676 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package schemacompat
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const governedSchemaCheckerSource = `package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
normalize := flag.String("normalize", "", "raw schema")
check := flag.String("check", "", "base schema")
current := flag.String("current", "", "candidate schema")
approved := flag.String("approved-flag-migrations", "", "base ledger")
candidate := flag.String("candidate-flag-migrations", "", "candidate ledger")
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
flag.Parse()
if *normalize != "" {
data, err := os.ReadFile(*normalize)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Print(string(data))
return
}
if *check == "" || *current == "" {
fmt.Fprintln(os.Stderr, "missing check inputs")
os.Exit(2)
}
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(currentData), "COMMITTED_CANDIDATE") {
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", currentData)
os.Exit(2)
}
paths := []string{*approved, *candidate, *currentSnapshot, *baseSnapshot, *stableSnapshot}
for _, path := range paths {
if path == "" {
fmt.Fprintln(os.Stderr, "governed checker did not receive all migration inputs")
os.Exit(2)
}
}
for _, fixture := range []struct {
path string
want string
}{
{*approved, "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n"},
{*candidate, "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"},
{*currentSnapshot, "COMMITTED_CANDIDATE"},
{*baseSnapshot, "BASE_AUTHORITY"},
{*stableSnapshot, "STABLE_RELEASE"},
} {
data, err := os.ReadFile(fixture.path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), fixture.want) {
fmt.Fprintf(os.Stderr, "migration input %s does not contain %q: %s\n", fixture.path, fixture.want, data)
os.Exit(2)
}
}
for _, path := range []string{*currentSnapshot, *baseSnapshot, *stableSnapshot} {
data, err := os.ReadFile(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), "INTERFACE_HELPER=BASE") {
fmt.Fprintf(os.Stderr, "interface snapshot did not use base internal helper: %s\n", data)
os.Exit(2)
}
}
fmt.Fprintln(os.Stderr, "BASE_SCHEMA_CHECKER_ENFORCED")
os.Exit(23)
}
`
const bootstrapSchemaCheckerSource = `package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
normalize := flag.String("normalize", "", "raw schema")
check := flag.String("check", "", "base schema")
current := flag.String("current", "", "candidate schema")
flag.Parse()
if *normalize != "" {
data, err := os.ReadFile(*normalize)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Print(string(data))
return
}
if *check == "" || *current == "" {
fmt.Fprintln(os.Stderr, "missing check inputs")
os.Exit(2)
}
data, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), "COMMITTED_CANDIDATE") {
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", data)
os.Exit(2)
}
baselineData, err := os.ReadFile(*check)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
switch {
case strings.Contains(string(baselineData), "BASE_AUTHORITY"):
fmt.Fprintln(os.Stdout, "OLD_BASE_SCHEMA_CONTRACT_CHECKED")
case strings.Contains(string(baselineData), "STABLE_RELEASE"):
fmt.Fprintln(os.Stderr, "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED")
os.Exit(23)
default:
fmt.Fprintf(os.Stderr, "unexpected historical Schema contract: %s\n", baselineData)
os.Exit(2)
}
}
`
const stableSchemaGuardCheckerSource = `package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
normalize := flag.String("normalize", "", "raw schema")
check := flag.String("check", "", "historical schema")
current := flag.String("current", "", "candidate schema")
approved := flag.String("approved-flag-migrations", "", "base ledger")
candidate := flag.String("candidate-flag-migrations", "", "candidate ledger")
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
flag.Parse()
if *normalize != "" {
data, err := os.ReadFile(*normalize)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Print(string(data))
return
}
if *check == "" || *current == "" {
fmt.Fprintln(os.Stderr, "missing check inputs")
os.Exit(2)
}
for _, fixture := range []struct {
path string
want string
}{
{*approved, "\"authority\":\"BASE\""},
{*candidate, "\"candidate\":\"COMMITTED\""},
{*currentSnapshot, "COMMITTED_CANDIDATE"},
{*baseSnapshot, "BASE_AUTHORITY"},
{*stableSnapshot, "STABLE_RELEASE"},
} {
if fixture.path == "" {
fmt.Fprintln(os.Stderr, "stable Schema guard did not receive all migration inputs")
os.Exit(2)
}
data, err := os.ReadFile(fixture.path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), fixture.want) {
fmt.Fprintf(os.Stderr, "stable Schema guard input %s does not contain %q: %s\n", fixture.path, fixture.want, data)
os.Exit(2)
}
}
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(currentData), "COMMITTED_CANDIDATE") {
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", currentData)
os.Exit(2)
}
baselineData, err := os.ReadFile(*check)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
switch {
case strings.Contains(string(baselineData), "BASE_AUTHORITY"):
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_CONTRACT_CHECKED")
case strings.Contains(string(baselineData), "STABLE_RELEASE"):
fmt.Fprintln(os.Stderr, "STABLE_SCHEMA_CONTRACT_ENFORCED")
os.Exit(23)
default:
fmt.Fprintf(os.Stderr, "unexpected historical Schema contract: %s\n", baselineData)
os.Exit(2)
}
}
`
const authorityInterfaceHelperSource = `package main
import (
"flag"
"fmt"
"os"
"example.com/dws-schema-compat-authority-test/internal/interfacesnapshot"
)
func main() {
if len(os.Args) < 2 || os.Args[1] != "generate" {
fmt.Fprintln(os.Stderr, "expected generate")
os.Exit(2)
}
flags := flag.NewFlagSet("generate", flag.ExitOnError)
output := flags.String("output", "", "snapshot output")
_ = flags.Parse(os.Args[2:])
marker, err := os.ReadFile("SNAPSHOT_MARKER")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
marker = append(marker, []byte("INTERFACE_HELPER="+interfacesnapshot.AuthorityMarker()+"\n")...)
if err := os.WriteFile(*output, marker, 0o600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("BASE_INTERFACE_HELPER_GENERATE=%s\n", interfacesnapshot.AuthorityMarker())
}
`
const schemaGovernedSnapshotSource = `package interfacesnapshot
import "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate"
var (
_ = runtimeannotate.AnnotationFlagAliasOf
_ = runtimeannotate.AnnotationFlagAliasOrigin
_ = runtimeannotate.FlagAliasOriginCorecmdV1
)
func AuthorityMarker() string { return "BASE" }
`
const schemaBaseSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "BASE" }
`
const schemaStableSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "STABLE" }
`
const schemaCandidateSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "CANDIDATE" }
`
const schemaAliasContractSource = `package runtimeannotate
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
`
const candidateNoopSource = `package main
import "fmt"
func main() {
fmt.Println("CANDIDATE_NOOP")
}
`
func schemaBinarySource(marker string) string {
return fmt.Sprintf(`package main
import "fmt"
func main() {
fmt.Println(%q)
}
`, `{"kind":"schema","marker":"`+marker+`"}`)
}
type authorityScenario struct {
name string
baseGovernance string
candidateMutation string
checkerMode string
want string
authorityMarker string
}
func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
tests := []authorityScenario{
{
name: "governed base rejects candidate checker and live workspace",
baseGovernance: "complete",
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
},
{
name: "governed checker protects stable Schema contract",
baseGovernance: "complete",
checkerMode: "stable-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
name: "bootstrap uses old base checker and committed canonical empty ledger",
baseGovernance: "none",
authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED",
},
{
name: "partial base fails closed",
baseGovernance: "partial",
want: "incomplete Schema flag migration governance artifact set",
},
{
name: "base ledger ancestor symlink fails closed",
baseGovernance: "ancestor-symlink",
want: "incomplete Schema flag migration governance artifact set",
},
{
name: "governed candidate must preserve schema checker",
baseGovernance: "complete",
candidateMutation: "delete-checker",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "governed candidate must preserve migrations implementation",
baseGovernance: "complete",
candidateMutation: "delete-migrations",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "governed candidate ledger must be a regular file",
baseGovernance: "complete",
candidateMutation: "symlink-ledger",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "governed candidate helper parent must be a Git tree",
baseGovernance: "complete",
candidateMutation: "symlink-helper-parent",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "candidate cannot forge alias evidence token",
baseGovernance: "complete",
candidateMutation: "forge-alias-token",
want: "may not forge framework-owned flag alias evidence",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
runSchemaAuthorityCase(t, test)
})
}
}
func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
t.Helper()
repoRoot := schemaRepositoryRoot(t)
managedRoot := filepath.Join(repoRoot, ".worktrees", "schemacompat-authority-integration")
if err := os.MkdirAll(managedRoot, 0o755); err != nil {
t.Fatalf("create managed test root: %v", err)
}
fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano()))
if err != nil {
t.Fatalf("create managed fixture repo: %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(fixtureRoot); err != nil {
t.Errorf("remove managed fixture repo: %v", err)
}
})
schemaCopyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"),
0o755,
)
schemaCopyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"),
0o755,
)
schemaCopyFile(t,
filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"),
filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"),
0o755,
)
schemaWriteFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-schema-compat-authority-test\n\ngo 1.23\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("STABLE_RELEASE"), 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaStableSnapshotSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), bootstrapSchemaCheckerSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "STABLE_RELEASE\n", 0o644)
schemaRun(t, fixtureRoot, "git", "init", "-b", "main")
schemaRun(t, fixtureRoot, "git", "config", "user.name", "Schema Compatibility Test")
schemaRun(t, fixtureRoot, "git", "config", "user.email", "schema-compat-test@example.invalid")
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "stable release")
stableCommit := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
schemaRun(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("BASE_AUTHORITY"), 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityInterfaceHelperSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "BASE_AUTHORITY\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaBaseSnapshotSource, 0o644)
switch test.baseGovernance {
case "complete":
checkerSource := governedSchemaCheckerSource
if test.checkerMode == "stable-schema-guard" {
checkerSource = stableSchemaGuardCheckerSource
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaGovernedSnapshotSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), schemaAliasContractSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n", 0o644)
case "partial":
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
case "ancestor-symlink":
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), governedSchemaCheckerSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil {
t.Fatalf("symlink base Schema ledger parent: %v", err)
}
case "none":
default:
t.Fatalf("unknown base governance state %q", test.baseGovernance)
}
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority")
baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
schemaRun(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef)
schemaRun(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef)
if test.baseGovernance == "ancestor-symlink" {
ledgerPath := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")
if err := os.Remove(ledgerPath); err != nil {
t.Fatalf("remove inherited base ledger parent symlink: %v", err)
}
if err := os.Rename(
filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger"),
ledgerPath,
); err != nil {
t.Fatalf("restore regular candidate ledger tree: %v", err)
}
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("COMMITTED_CANDIDATE"), 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), candidateNoopSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCandidateSnapshotSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644)
candidateLedger := "{\"version\":1,\"migrations\":[]}\n"
if test.baseGovernance == "complete" {
candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644)
switch test.candidateMutation {
case "":
case "delete-checker":
if err := os.Remove(filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go")); err != nil {
t.Fatalf("remove candidate schema checker: %v", err)
}
case "delete-migrations":
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go")); err != nil {
t.Fatalf("remove candidate migrations implementation: %v", err)
}
case "symlink-ledger":
ledger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json")
if err := os.Remove(ledger); err != nil {
t.Fatalf("remove candidate ledger: %v", err)
}
if err := os.Symlink("../../../CANONICAL_EMPTY", ledger); err != nil {
t.Fatalf("symlink candidate ledger: %v", err)
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "CANONICAL_EMPTY"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
case "forge-alias-token":
schemaWriteFile(t,
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
0o644,
)
case "symlink-helper-parent":
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
if err := os.Rename(helperPath, alternatePath); err != nil {
t.Fatalf("move candidate Schema helper behind ancestor symlink: %v", err)
}
if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil {
t.Fatalf("symlink candidate Schema helper parent: %v", err)
}
default:
t.Fatalf("unknown candidate mutation %q", test.candidateMutation)
}
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "candidate revision")
candidateRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
// 让调用者工作区前进到另一提交,并放入可自批的二进制与 ledger;显式
// candidate ref 必须隔离这些内容。
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("LIVE_WORKTREE_BINARY"), 0o644)
liveLedger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json")
if err := os.Remove(liveLedger); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove selected candidate ledger from live revision: %v", err)
}
schemaWriteFile(t, liveLedger, "{\"version\":1,\"migrations\":[{\"live\":true}]}\n", 0o644)
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "unselected live revision")
schemaWriteFile(t, filepath.Join(fixtureRoot, "dws"), "#!/bin/sh\nprintf '%s\\n' 'LIVE_WORKTREE_BINARY'\n", 0o755)
cmd := exec.Command(
"sh",
"scripts/policy/check-authoritative-schema-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "v1.0.0",
"--candidate-ref", candidateRef,
)
cmd.Dir = fixtureRoot
cmd.Env = schemaIntegrationEnvironment(fixtureRoot)
output, runErr := cmd.CombinedOutput()
got := string(output)
if runErr == nil {
t.Fatalf("Schema compatibility script unexpectedly succeeded; output:\n%s", got)
}
for _, forbidden := range []string{"CANDIDATE_NOOP", "LIVE_WORKTREE_BINARY"} {
if strings.Contains(got, forbidden) {
t.Fatalf("candidate-controlled authority %q took effect; output:\n%s", forbidden, got)
}
}
if test.authorityMarker != "" {
if !strings.Contains(got, test.authorityMarker) {
t.Fatalf("Schema compatibility script did not use base authority %q; output:\n%s", test.authorityMarker, got)
}
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
}
wrongStable := exec.Command(
"sh",
"scripts/policy/check-authoritative-schema-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "HEAD",
"--candidate-ref", candidateRef,
)
wrongStable.Dir = fixtureRoot
wrongStable.Env = schemaIntegrationEnvironment(fixtureRoot)
wrongOutput, wrongErr := wrongStable.CombinedOutput()
if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") {
t.Fatalf("arbitrary Schema stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput)
}
return
}
if !strings.Contains(got, test.want) {
t.Fatalf("Schema compatibility script output missing %q:\n%s", test.want, got)
}
}
func schemaRepositoryRoot(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-authoritative-schema-compatibility.sh")); err != nil {
t.Fatalf("resolve repository root %s: %v", root, err)
}
return root
}
func schemaCopyFile(t *testing.T, source, destination string, mode os.FileMode) {
t.Helper()
content, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read %s: %v", source, err)
}
schemaWriteFile(t, destination, string(content), mode)
}
func schemaWriteFile(t *testing.T, path, content string, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create parent for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func schemaRun(t *testing.T, dir, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
}
return string(output)
}
func schemaIntegrationEnvironment(fixtureRoot string) []string {
overrides := map[string]string{
"DWS_BIN": filepath.Join(fixtureRoot, "dws"),
"DWS_POLICY_TMPDIR": "",
"GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"),
"GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"),
"GOTMPDIR": "",
"GOTOOLCHAIN": "local",
"GOWORK": "off",
}
environment := make([]string, 0, len(os.Environ())+len(overrides))
for _, item := range os.Environ() {
key, _, _ := strings.Cut(item, "=")
if _, replaced := overrides[key]; !replaced {
environment = append(environment, item)
}
}
for key, value := range overrides {
environment = append(environment, key+"="+value)
}
return environment
}
+16 -1
View File
@@ -142,12 +142,27 @@ omit_global:
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
# Byte-identical pairs (content surfaces that must stay in sync).
# Reviewed file pairs. Most are byte-identical; layout-only link differences
# must be declared as data-driven substitutions.
paired_files:
- mono: references/products/sheet.md
multi: dingtalk-misc/references/sheet.md
mode: link-normalized
link_substitutions:
- mono: "../url-patterns.md"
multi: "../../dingtalk-shared/references/url-patterns.md"
- mono: "../intent-guide.md"
multi: "sheet-intent-guide.md"
- mono: references/products/whiteboard/recipes.md
multi: dingtalk-misc/references/whiteboard/recipes.md
mode: identical
# Directory pairs whose relative file sets and contents must stay byte-identical.
paired_trees:
- mono: references/products/sheet
multi: dingtalk-misc/references/sheet
mode: identical
# Scripts under skills/multi/*/scripts not referenced by any .md in that skill tree.
# yida/finance/aiapp are referenced via dingtalk-misc/references/unsupported-scripts.md
orphan_scripts_allowlist:
+1 -1
View File
@@ -50,7 +50,7 @@ cli_version: ">=1.0.15"
| `contact` | 14 | `dingtalk-contact` |
| `devapp` | 19 | `dingtalk-misc` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 41 | `dingtalk-doc` |
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 6 | `dingtalk-minutes` |
+3 -1
View File
@@ -46,6 +46,7 @@
| "查看这条话题的所有回复" | 话题回复 | `chat +thread-replies` | `chat message list-topic-replies` | 已知 threadId/topicId 时自动投影回复人、正文和时间 |
| "把这些消息里的附件下载下来" | 消息资源下载 | 查询 Shortcut 的 `--download-resources` | `resourceRefs.shortcut` 返回的下载命令 | 先用查询结果的 resourceRefs;单资源再执行返回的可运行参数 |
| "消息发没发成功/查询消息发送状态" | 查询消息发送状态 | `chat message query-send-status` | — | 需要 send 返回的 openTaskId |
| "编辑/撤回我刚发的消息" | 发后编辑/撤回 | `send` → `query-send-status` → `edit` / `recall` | 按消息内容反查 | 使用 send 返回的 openTaskId 直接获取两个后续 ID |
| "撤回我发的消息/撤回消息" | 撤回个人消息 | `chat message recall` | `chat message recall-by-bot` | recall 撤回个人消息,recall-by-bot 撤回机器人消息 |
| "未读消息会话/未读会话列表/我的未读会话" | 未读会话列表 | `chat message list-unread-conversations` | `chat message read-status` | list-unread-conversations 查哪些会话有未读;read-status 查具体消息的已读状态 |
| "谁看了这条消息/消息已读未读/查读状态" | 查询消息已读状态 | `chat message read-status` | `chat message list-unread-conversations` | read-status 查具体消息的已读人员;list-unread-conversations 查未读会话列表 |
@@ -312,6 +313,7 @@ dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type
用 `chat message query-send-status` 的场景:
- "消息发没发成功/查询消息发送状态" — 查询个人发送消息的状态,需要 send 返回的 openTaskId
- 发送成功时返回 openMessageId 和 openConversationId,可直接用于 edit/recall
用 `chat +search-msg` 的场景(推荐首选):
- "按发送者搜索消息/指定多个群搜索/@我的消息多维度搜" — 支持关键词、发送者、@我、@指定人、多个会话等维度组合搜索
@@ -325,7 +327,7 @@ dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type
上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。输出只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
**不支持的场景**:
- "撤回我刚发的消息"(但不知道消息 ID) — 需先通过消息拉取或搜索接口(如 `chat message list`、`chat message search-advanced` 等)获取 openMessageId,再调用 `chat message recall`
- "撤回历史消息"(且已丢失发送返回的 openTaskId) — 通过消息拉取或搜索接口获取 openMessageId,再调用 `chat message recall`
判断关键:普通发送统一走 `+messages-send` 并显式选择 `--as`;个人撤回→ `recall`;机器人撤回→ `recall-by-bot`;查发送状态→ `query-send-status`;消息搜索类意图优先路由到 `+search-msg`,仅在不适用时降级到原子命令。
+22 -4
View File
@@ -653,6 +653,8 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
#### 查询消息发送状态 — 查询以当前用户身份发送的消息的发送状态
查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。
发送成功时返回 openMessageId 和 openConversationId,可直接用于后续编辑或撤回。
```
Usage:
dws chat message query-send-status [flags]
@@ -666,6 +668,23 @@ Flags:
- openTaskId 由 `dws chat message send` 发送消息成功后返回
- 用于确认消息是否已成功发送或获取发送失败的原因
- 返回结果中含发送成功消息的 openMessageId,可用于后续 recall(撤回)、read-status(查已读)等命令
- 返回结果同时含 openConversationId;与 openMessageId 组合后可直接用于 edit(编辑)或 recall(撤回)
```
发送后编辑/撤回时,优先使用下列 ID 链,无需按消息内容反查:
```bash
# 1. 发送后保留 openTaskId
dws chat message send --group <openConversationId> --text "原始内容"
# 2. 查询得到 openMessageId 和 openConversationId
dws chat message query-send-status --open-task-id <openTaskId>
# 3. 编辑消息
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
# 发送后撤回使用同一 ID 链
dws chat message send --group <openConversationId> --text "待撤回的内容"
dws chat message query-send-status --open-task-id <openTaskId>
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
```
#### 撤回消息 — 撤回当前用户自己发出的消息
@@ -676,15 +695,13 @@ Usage:
dws chat message recall [flags]
Example:
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMessageId (必填)
注意:
- --conversation-id 的别名: --group, --id, --chat (均可替代 --conversation-id)
- 消息 ID 可通过 `dws chat message list` 命令获取
- 刚由 `chat message send` 发出的消息,使用 `query-send-status` 返回的 openConversationId 和 openMessageId;只有历史消息或已丢失 openTaskId 时才通过消息拉取/搜索获取 ID
- 仅支持撤回当前用户以个人身份发出的消息,不能撤回他人发送的消息,也不能撤回机器人发出的消息
- 与 `recall-by-bot` 的区别:本命令通过 IM 接口撤回用户自己发出的消息(需要 openConversationId + openMessageId),`recall-by-bot` 通过机器人接口撤回机器人发出的消息(需要 robot-code + processQueryKey)
```
@@ -2373,7 +2390,8 @@ Flags:
| `chat bot find` | `botOpenDingTalkId` | 给机器人发单聊消息(send --open-dingtalk-id;字段名是 botOpenDingTalkId,非 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
| `chat message send` | `openTaskId` | query-send-status 的 --open-task-id |
| `chat message list` | `openMessageId` | recall 的 --msg-id |
| `chat message query-send-status` | `openMessageId` + `openConversationId` | 刚发送消息的 edit / recall |
| `chat message list` | `openMessageId` | 历史消息或已丢失 openTaskId 时的 recall |
| `chat message search` | `nextCursor` | 下次 message search 的 --cursor |
| `chat message search-advanced` | `nextCursor` | 下次 message search-advanced 的 --cursor |
| `chat search-common` | `openConversationId` | message send/list 等的 --group |
+19 -33
View File
@@ -1,27 +1,20 @@
# 文档 (doc) 命令参考
## 查询命令帮助
## 路由与帮助预算
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
已知精确命令和参数时直接执行,禁止预调用 Help。参数语义不确定时先查精确 leaf Schema;真实返回 `unknown flag` 后最多读取一次该 leaf Help。`unknown command` 只执行一次 `dws shortcut list --service doc --format json`,禁止试探候选后缀或执行产品级 `dws doc --help | grep/head`。
本页已给出精确命令且参数足够时,禁止读取 reference 子页。只有复杂 JSONML、block/划词评论高级参数、分页或部分成功恢复仍无法由本页和精确 leaf Schema处理时,才最多读取一个对应子 reference;普通创建、全文读取、append/overwrite、导出和导入直接执行。
版本快照、列表和恢复的 Agent 主入口固定为:
```bash
# 查看 doc 下所有子命令
dws doc --help
# 查看具体命令的完整参数说明
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc permission --help
dws doc +version-save --node <DOC_ID>
dws doc +version-list --node <DOC_ID> --limit 20
dws doc +version-revert --node <DOC_ID> --version <N>
```
规则:
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表
`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。
## 命令总览
@@ -1005,26 +998,25 @@ dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=749936706
| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) |
| `--content-file path` | 从文件读取(UTF-8),推荐 |
### 短/中等长度(< 200KB)— 单步创建
### 创建并写入 — Runtime 自动分片
```bash
# 1. 把内容写入 UTF-8 文本文件:
# Linux/Mac: /tmp/<name>.md;Windows: %TEMP%\<name>.md
# 2. 一步创建+写入:
dws doc create --name "<文档名>" --content-file <tmp> [--folder <ID>] [--workspace <ID>]
# 2. 一步创建、必要时自动分片并回读验证:
dws doc +create --name "<文档名>" --content @<工作目录相对文件> [--folder <ID>] [--workspace <ID>]
```
### 超长(> 200KB 兜底)— 创建空文档 + 分片追加
`+create` 会按安全边界拆分长 Markdown,首片创建、后续片追加,每个写调用最多执行一次。若返回 `partial_success` 或 `unknown`,先按返回的 `nodeId/steps` 回读文档,不得重跑整个创建。
### 更新并验证
```bash
# 1. 创建空文档拿 nodeId
dws doc create --name "<文档名>" [--folder/--workspace] # → nodeId
# 2. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格)
# 3. 逐个追加:
dws doc update --node <nodeId> --content-file <part> --mode append
dws doc +update --node <nodeId> --command append --content @<工作目录相对文件>
```
Runtime 自动分片并在最后回读验证。重要覆盖使用 `+checkpoint-update`;不要自行编写重试循环,创建和追加在超时后可能已经提交。
### stdin 变体
```bash
@@ -1085,12 +1077,6 @@ dws doc media upload --node <DOC_ID> --file ./icon.svg \
media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板
Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` |
## 相关产品
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,8 @@
`batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束:
1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / 工作表。规划 range 时先确认每个子操作的边界。
2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地预先计算的预期值对照。
2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,单元格值用 `range read` 或 `csv-get` 抽样回读受影响区域;合并用裸 `sheet info`,行列分组用 `sheet info --include groups` 回读校验。
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `batch-update` 补齐,禁止交付半成品。
4. **统一确认语义**:`range batch-clear` 和 `batch-update` 都可删除内容或结构;先用 `--dry-run` 预览,真实执行必须获得用户确认后追加 `--yes`。
## 使用场景
@@ -23,17 +22,28 @@
- 原子事务:任一操作失败则整批回滚
- 传 `--continue-on-error` 可切换为宽松模式(失败继续)
用户说"批量写入多个结构化 table/跨 sheet 写入 dataframe/table":
- 不使用 `batch-update`;直接调用 `dws sheet table-put --node <NODE_ID> --sheets '{"sheets":[...]}'`
- `table-put` 自身支持一次写入多个 sheet spec,写入后用 `table-get` 回读 dtype / format / 数据行数
用户说"批量创建行列分组/批量取消行列分组/同时调整尺寸并分组":
- 连续行/列分组 → `group-dimension`
- 取消连续行/列分组 → `ungroup-dimension`
- 分组回读用 `sheet info --include groups`
- `group-dimension` 在 batch 中只适合默认展开分组;需要创建后立即折叠时,用独立 `dws sheet group-dimension --group-state fold`
**何时推荐使用 `batch-update`**:
- 需要对**多个**不同区域执行 `merge-cells` / `unmerge-cells` 时(如按分组合并多列相同内容)
- 需要对**多个**不同区域执行 `update-dimension` 时(如统一调整多列列宽或多行行高,含 hidden + pixelSize)
- 需要对**多个**连续行/列范围执行 `group-dimension` / `ungroup-dimension` 时(默认展开分组)
- 需要先插入行列再写入数据时(`add-dimension` + `range update` / `csv-put`)
- 需要批量创建或取消行列分组时(`group-dimension` / `ungroup-dimension`)
- 需要对多个区域执行不同写入操作时(多次 `range update` + `range clear` 等组合)
当同一工具需要对多个区域重复调用时,**推荐**改用 `batch-update` 合并为单次请求——`batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
**不可放进 `--operations` 的操作**(强行写入会被校验拒或行为未定义):
- `range read` / `csv-get`(只读操作,不属于写入)
- `range read` / `csv-get` / `table-get`(读取操作,不在 batch dispatch 表中;结构化 table 回读需在 batch 外单独调用)
- `table-put`(当前 `batch-update` 不支持结构化 table 写入;请直接调用独立 `dws sheet table-put`)
- `range sort` / `range move-to`(尚未支持,后续版本补充)
- `write-image` / `media-upload` / `create-float-image` / `update-float-image`(需本地上传或依赖前置上传句柄)
- `set-style` / `range batch-set-style`(自身已是批量入口,不可再嵌套)
@@ -50,9 +60,9 @@
Usage:
dws sheet range batch-clear [flags]
Example:
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]' --yes
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:Z1000"]' --type all --yes
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A2:A100","Sheet1!C2:C100"]' --type format --yes
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]'
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A1:Z1000"]' --type all
dws sheet range batch-clear --node <NODE_ID> --ranges '["Sheet1!A2:A100","Sheet1!C2:C100"]' --type format
Flags:
--node string 表格文档 ID 或 URL (必填)
--ranges string 目标区域 JSON 数组,每项带 sheet 前缀 (必填)
@@ -72,9 +82,10 @@ Example:
{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
{"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}}
]' --yes
dws sheet batch-update --node <NODE_ID> --continue-on-error --operations '[...]' --yes
{"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}},
{"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}
]'
dws sheet batch-update --node <NODE_ID> --continue-on-error --operations '[...]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--operations string 操作数组 JSON (必填,每项 {toolName, input})
@@ -91,7 +102,8 @@ Notes:
- 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁)
- `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本
- 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽
- 真实执行必须获得用户确认后加 --yes;--dry-run 不需要 --yes
- `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension`
- `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put`
```
### 子操作定位规则
@@ -107,6 +119,8 @@ Notes:
]
```
`table-put` / `table-get` 不支持放进 `batch-update`。结构化 table 写入请直接调用 `dws sheet table-put`,写入完成后在 batch 外单独调用 `table-get` 回读校验。
## 校验与预览
### Validate 阶段
@@ -121,27 +135,10 @@ CLI 在发送请求前执行以下本地校验(不消耗网络请求):
| 每项必须是 object | `operations[N] 不是 object` | `--operations '["string"]'` |
| `toolName` 必须是支持的 CLI 命令名 | `unsupported toolName "xxx"` | 传 `"batch-update"`(禁止嵌套)或拼写错误 |
| 禁止嵌套 `batch-update` | 同上(`batch-update` 不在 dispatch 表中,自动拦截) | `toolName: "batch-update"` |
| 禁止 `table-put` / `table-get` | `unsupported toolName "table-put"` | 结构化 table 应使用独立 `table-put` |
> `input` 内的字段(如 `sheet-id`、`range`、`values` 等)由服务端校验,CLI 不提前拦截——传入空值或缺失必填字段时,请求会到达服务端再返回错误。
### DryRun 阶段
加 `--dry-run` 预览翻译后的完整 MCP 请求,不实际执行任何操作:
```bash
dws sheet batch-update --node NODE_ID --dry-run --operations '[...]'
```
DryRun 输出内容:
- `Tool: batch_update`(最终调用的 MCP 工具名)
- `Arguments:` 完整 JSON(含翻译后的 `operations` 数组,展示每个子操作的 MCP toolName + 参数名 + 参数值)
- 若传了 `--continue-on-error`,输出 JSON 中会包含 `"continueOnError": true`
DryRun 适用场景:
- 确认 CLI 命令名 → MCP toolName 的翻译是否正确
- 确认参数名转换(如 `--range` → `rangeAddress`)是否符合预期
- 检查 `csv-put` 的 `@filepath` 是否被正确解析为文件内容,并确认 `=...` / `'=...` 字段原样进入子操作
### --continue-on-error 行为
| 模式 | flag | 子操作失败时 | 返回格式 |
@@ -149,7 +146,7 @@ DryRun 适用场景:
| 严格事务(默认) | 不传 | 整批回滚到初始状态,后续子操作不执行 | 顶层 `error` 对象 |
| 宽松模式 | `--continue-on-error` | 已执行的子操作不回滚,继续执行后续子操作 | `results` 数组 |
**严格事务失败的错误信息**由服务端(flex-table-app)生成,包含以下内容:
**严格事务失败的错误信息**由服务端生成,包含以下内容:
- 失败操作索引:`operations[N]`(0-based,即 `--operations` JSON 数组中的下标)
- 回滚告知:"All previously executed operations have been rolled back."
- 失败原因:原始错误码和错误消息
@@ -177,13 +174,13 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
| `merge-cells` | [sheet-style-format](./sheet-style-format.md) |
| `unmerge-cells` | [sheet-style-format](./sheet-style-format.md) |
| `update-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `range fill` | [sheet-range-operations](./sheet-range-operations.md) |
| `range copy-to` | [sheet-range-operations](./sheet-range-operations.md) |
| `add-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `delete-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `move-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
| `set-dropdown` | [sheet-dropdown](./sheet-dropdown.md) |
| `delete-dropdown` | [sheet-dropdown](./sheet-dropdown.md) |
| `csv-put` | [sheet-write-data](./sheet-write-data.md) |
@@ -237,32 +234,44 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
]
```
### 批量创建行列分组
### 批量创建/取消行列分组
分组属于工作表结构元数据,批次完成后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups`,不要用裸 `sheet info` 或 `range read` 校验。回读项中的 `level` 是 1-based,`collapsed` 表示当前折叠状态。
```jsonc
[
{"toolName":"group-dimension",
"input":{"sheet-id":"...","range":"3:7","group-state":"expand"}},
{"toolName":"group-dimension",
"input":{"sheet-id":"...","range":"C:F"}},
{"toolName":"ungroup-dimension",
"input":{"sheet-id":"...","range":"10:12"}}
"input":{"sheet-id":"...","range":"C:F","group-state":"expand"}}
]
```
```jsonc
[
{"toolName":"ungroup-dimension",
"input":{"sheet-id":"...","range":"3:7"}},
{"toolName":"ungroup-dimension",
"input":{"sheet-id":"...","range":"C:F"}}
]
```
注意:batch 中不要用 `group-state:"fold"` 来创建折叠分组;需要折叠初始状态时,用独立 `dws sheet group-dimension --group-state fold`。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | batch-clear 的 --ranges sheet 前缀 / batch-update 子操作 input 的 sheet-id |
| `batch-update` | 各子操作的执行结果 | 回读校验(range read / csv-get) |
| `batch-update` | 各子操作的执行结果 | 回读校验(值用 range read / csv-get;合并用 sheet info;分组用 sheet info --include groups) |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **需要对多个区域执行相同清除操作时,用 `range batch-clear`**:一次原子请求清除多个区域(可跨工作表),失败时整批回滚
- ★ **需要组合多个不同写操作(清除+写入等)时,用 `batch-update`**:原子事务,任一操作失败则整批回滚,避免留下半成品
- ★ **批次完成后必须回读校验**:用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与预期值对照
- ★ **table 写入不进 batch-update**:当前 `batch-update` 不支持结构化 table 写入;结构化 table 请直接调用 `dws sheet table-put`
- ★ **批次完成后必须回读校验**:值写入用 `range read` 或 `csv-get` 抽样回读受影响区域;结构变更用 `sheet info` 回读
- ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update`
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;`input.range` 只接受整行或整列范围,如 `3:7`、`C:F`
- 需要创建后立即折叠分组时,优先使用独立 `dws sheet group-dimension --group-state fold`
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;分组结果用 `sheet info --include groups` 回读
- `batch-update` 中 `group-dimension` 只适合默认展开分组;需要 `fold` 初始状态时使用独立 `group-dimension`
@@ -34,6 +34,7 @@
| "百分比面积" | 百分比堆积面积图(areaPercentStacked) | 占比式面积 |
| "散点图"、"相关性" | 散点图(scatter) | 两变量相关性 |
| "雷达图"、"多维度" | 雷达图(radar) | 多维度对比 |
| "柱+线"、"组合图"、"双轴" | 组合图(combo) | 多种图表叠加,支持副轴 |
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
@@ -55,8 +56,6 @@ Flags:
--chart-id string 浮动图表 ID (可选,不传则返回全部)
```
- **判空看 `totalCount` / `message`,不要看数组长度**:无图表时 `floatCharts` 数组里仍会带 1 个全 null 的幽灵元素(`chart.category`/`chart.series` 全 null,无 id),但 `totalCount` 为 0、`message` 为「Successfully retrieved 0 float chart(s).」。判断有没有图表以 `totalCount` 为准,或过滤掉没有 `id` 的元素。
### 创建浮动图表
```
Usage:
@@ -130,7 +129,7 @@ Flags:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `row` | number | 是 | 锚点行索引(0-based),如 0 表示第一行 |
| `col` | string | 是 | 锚点列。使用字母表示法(`"A"`=第1列, `"B"`=第2列, `"Z"`=第26列, `"AA"`=第27列),不支持数字形式 |
| `col` | string/number | 是 | 锚点列。支持字母表示法(`"A"`=第1列, `"B"`=第2列, `"Z"`=第26列, `"AA"`=第27列)或 0-based 数字(`0`=第1列) |
**offset 字段**(可选,不传时默认 0):
@@ -150,9 +149,10 @@ Flags:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | 是 | 图表类型,枚举值:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked` / `pie` / `doughnut` / `scatter` / `radar` |
| `series` | object[] | 是 | 数据系列数组,至少一项,详见下方 series 字段表 |
| `category` | string[] | 否 | 分类轴(X轴)数据区域,A1 表示法字符串数组(如 `["A2:A10"]`) |
| `type` | string | 是 | 图表类型,枚举值:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked` / `pie` / `doughnut` / `scatter` / `radar` / `combo` |
| `series` | object[] | 非 combo 必填 | 数据系列数组,至少一项,详见下方 series 字段表。combo 类型不使用此字段 |
| `subCharts` | object[] | combo 必填 | 组合图子图数组,至少一项,详见下方 subCharts 字段表。仅 combo 类型使用 |
| `category` | string[] | 否 | 分类轴(X轴)数据区域,A1 表示法字符串数组(如 `["A2:A10"]`)。combo 类型在 subCharts 内声明 |
| `title` | object | 否 | 图表标题配置,详见下方 title 字段表 |
| `legend` | object | 否 | 图例配置,详见下方 legend 字段表 |
| `catAx` | object | 否 | 分类轴(X轴)样式配置,详见下方轴配置表 |
@@ -165,6 +165,15 @@ Flags:
| `name` | string | 否 | 系列名称的单元格引用,A1 表示法(如 `"B1"`)。不传时图例显示为"系列1"等默认名 |
| `value` | string[] | 是 | 系列数据区域,A1 表示法字符串数组(如 `["B2:B10"]`)。每个元素为一段连续区域 |
**subCharts 数组项**(仅 combo 类型):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | 是 | 子图类型,仅允许笛卡尔坐标系类型:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked`。不允许 pie/doughnut/scatter/radar/combo |
| `series` | object[] | 是 | 子图数据系列,格式同上方 series 数组项 |
| `category` | string[] | 是 | 子图分类轴数据区域(笛卡尔坐标系必须提供) |
| `useSecondaryAxis` | boolean | 否 | 是否使用副轴(右侧 Y 轴),默认 false |
**title 字段**:
| 字段 | 类型 | 必填 | 说明 |
@@ -199,7 +208,7 @@ Flags:
- 格式:`"A2:B10"`(默认使用 `--sheet-id` 对应的工作表)
- 跨 sheet 引用:`"SHEET_ID!A2:B10"`(前缀为工作表 ID)
- 单元格引用:`"B1"`(等同于 `"B1:B1"`)
- MCP 内部自动将 A1 引用转换为 ISheetRange 结构,CLI 直接透传即可
- 直接传 A1 表示法字符串即可,命令会处理成图表所需的区域引用
## 图表位置选择(创建前必做)
@@ -277,32 +286,19 @@ dws sheet chart create --node <NODE_ID> --sheet-id <SHEET_ID> --properties '{
}
}'
# ── 工作流 4: 多图表组合分析 ──
# 当用户需要"柱+线"等多种图表组合分析时,分别创建对应类型的图表:
# ── 工作流 4: 创建组合图(combo)──
# 当用户需要"柱+线"叠加、双轴对比时,使用 combo 类型:
# 创建柱形图
dws sheet chart create --node <NODE_ID> --sheet-id <SHEET_ID> --properties '{
"position": {"row": 40, "col": "A"},
"dimensions": {"width": 700, "height": 450},
"dimensions": {"width": 800, "height": 500},
"chart": {
"type": "column",
"series": [{"name": "B1", "value": ["B2:B10"]}],
"category": ["A2:A10"],
"title": {"show": true, "text": "销售额"},
"legend": {"show": true, "pos": "t"}
}
}'
# 创建折线图
dws sheet chart create --node <NODE_ID> --sheet-id <SHEET_ID> --properties '{
"position": {"row": 40, "col": "J"},
"dimensions": {"width": 700, "height": 450},
"chart": {
"type": "line",
"series": [{"name": "C1", "value": ["C2:C10"]}],
"category": ["A2:A10"],
"title": {"show": true, "text": "增长率"},
"legend": {"show": true, "pos": "t"}
"type": "combo",
"subCharts": [
{"type": "column", "series": [{"name": "B1", "value": ["B2:B10"]}], "category": ["A2:A10"]},
{"type": "line", "series": [{"name": "C1", "value": ["C2:C10"]}], "category": ["A2:A10"], "useSecondaryAxis": true}
],
"title": {"show": true, "text": "销售额与增长率"}
}
}'
```
@@ -344,10 +340,10 @@ dws sheet chart update --node <NODE_ID> --sheet-id <SHEET_ID> --chart-id <CHART_
- [强制] **`--sheet-id` 获取规范**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- [强制] **创建后必须验证**:图表创建后必须调用 `chart list` 验证配置是否正确
- ⚠️ **`chart list` 判空看 `totalCount` 不看数组长度**:无图表时 `floatCharts` 仍含 1 个全 null 幽灵元素(无 id),`totalCount` 为 0、`message` 说明为空。判断有没有图表以 `totalCount` 为准或过滤无 `id` 的元素
- [强制] **chart-id 禁止臆测**:必须通过 `chart list` 获取真实的图表 ID,不可编造
- **图表类型映射**:用户说"柱形图"用 `column`,"条形图"(横向)用 `bar`,"散点图"用 `scatter`,"雷达图"用 `radar`,"环形图"用 `doughnut`
- **图表类型映射**:用户说"柱形图"用 `column`,"条形图"(横向)用 `bar`,"散点图"用 `scatter`,"雷达图"用 `radar`,"环形图"用 `doughnut`,"柱+线/组合/双轴"用 `combo`
- **堆积类型后缀**:堆叠版本的图表在基本类型后加 `Stacked`(如 `columnStacked`、`lineStacked`、`areaStacked`)
- **combo 组合图**:不用顶层 `series`/`category`,改用 `chart.subCharts` 数组,每个子图独立声明 `type`/`series`/`category`,可选 `useSecondaryAxis: true` 指定副轴。子图 type 仅允许笛卡尔坐标系类型(column/bar/line/area 及其 Stacked 变体),不允许 pie/doughnut/scatter/radar/combo
- **位置不得压在数据区上**:图表浮层会遮挡原始数据,必须放在空白区域
- **多图表需求**:多次调用 `chart create`,不要试图用一个图表包含所有分析
- **跨 sheet 引用数据**:在 A1 区域前加 `SHEET_ID!` 前缀,如 `"sheetXXX!A2:B10"`
@@ -0,0 +1,166 @@
# sheet comment(表格单元格评论:list / create / reply / update / delete)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../sheet.md`](../sheet.md) — 命令路由 + 场景索引 + 意图判断 + 全局约束
>
> **同任务常配合**:`dws aisearch person`(查 `--mention` 用 userId)/ [`sheet-workbook.md`](sheet-workbook.md)(未知工作表名称时先 `list` 确认真实名称)
---
## 适用范围
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
---
## sheet comment list(查询表格评论列表)
```
Usage:
dws sheet comment list [flags]
Example:
dws sheet comment list --node <SHEET_ID>
dws sheet comment list --node <SHEET_ID> --sheet-id Sheet1 --range A2
dws sheet comment list --node <SHEET_ID> --resolve-status unresolved
dws sheet comment list --node <SHEET_ID> --cursor <TOKEN>
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
--sheet-id string 工作表 ID 或名称,如 Sheet1(与 --range 一起指定时按单元格过滤)
--range string 单元格位置,A1 表示法,如 A2、B5:C10(与 --sheet-id 一起指定时按单元格过滤)
```
- 同时传入 `--sheet-id` 和 `--range` 时,仅返回该单元格的评论(服务端按单元格精确过滤);不传则返回表格全部单元格评论。
---
## sheet comment create(创建单元格评论)
```
Usage:
dws sheet comment create [flags]
Example:
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "这个数字有问题"
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "请核实" --mention uid1,uid2
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--sheet-id string 工作表 ID 或名称,如 Sheet1 (必填)
--range string 单元格位置,A1 表示法,仅支持单个单元格,如 A2 (必填)
--content string 评论的文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
- 未知工作表名称时,先 `dws sheet list --node <SHEET_ID> --format json` 确认真实名称,禁止臆测 `Sheet1` / `0` / `default`。
---
## sheet comment reply(回复评论)
```
Usage:
dws sheet comment reply [flags]
Example:
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已核实"
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--emoji 设为 true 时作为表情贴图回复 (默认 false)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
- 回复自动归属到被回复评论所在的单元格线程,无需再传 `--sheet-id` / `--range`。
---
## sheet comment update(更新评论)
```
Usage:
dws sheet comment update [flags]
Example:
dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--comment-key string 待更新评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--content string 更新后的评论文字内容,纯文本 (必填)
```
---
## sheet comment delete(删除评论)
> [强制] 危险操作:删除不可恢复。必须先向用户展示操作摘要并获得明确同意,用户同意后才加 `--yes` 执行。
```
Usage:
dws sheet comment delete [flags]
Example:
dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --yes
Flags:
--node string 目标表格的标识,支持传入 URL 或 ID (必填)
--comment-key string 待删除评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
```
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws aisearch person --keyword "<姓名>" --dimension name` 拿到 userId。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` 返回中提取,用于 `reply` / `update` / `delete`。
- `create` / `list`(按单元格过滤)的 `--sheet-id` 是工作表 ID 或名称;未知时先 `dws sheet list` 确认,禁止臆测。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
- `delete` 是不可逆操作;AI Agent 必须先向用户展示操作摘要并获得明确同意,同意后才追加 `--yes`。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| `comment create` 的 `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| [`sheet-workbook.md`](sheet-workbook.md) `sheet list` 的工作表名称 | `comment create/list` 的 `--sheet-id` |
| `dws aisearch person` 的 `userId` | `comment create/reply` 的 `--mention` |
## 常用模板
```bash
# 查看表格全部单元格评论
dws sheet comment list --node <SHEET_ID> --format json
# 仅看某个单元格的评论
dws sheet comment list --node <SHEET_ID> --sheet-id Sheet1 --range A2 --format json
# 仅看未解决的评论
dws sheet comment list --node <SHEET_ID> --resolve-status unresolved --format json
# 在单元格上创建评论(未知工作表名先 sheet list 确认)
dws sheet list --node <SHEET_ID> --format json
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "这个数字有问题" --format json
# 创建评论 + @人(先 aisearch person 拿 userId)
dws aisearch person --keyword "张三" --dimension name --format json
dws sheet comment create --node <SHEET_ID> --sheet-id Sheet1 --range A2 --content "请确认" --mention <uid1>,<uid2> --format json
# 文字回复
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已核实" --format json
# 表情回复(--content 填表情名称)
dws sheet comment reply --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 更新评论
dws sheet comment update --node <SHEET_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
# 删除评论(不可逆;必须用户确认后再加 --yes)
dws sheet comment delete --node <SHEET_ID> --comment-key <COMMENT_KEY> --yes --format json
```
## 参考
- [`../sheet.md`](../sheet.md)(如何路由到本命令族)
- [`./sheet-workbook.md`](sheet-workbook.md)(取工作表名称)
- `dws aisearch person`(取 mention 用的 userId,跨产品命令)
@@ -64,14 +64,13 @@
**正确做法(两步走)**:
```bash
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列)
# 每个单元格必须是 object,公式写在 text(以 = 开头);不支持裸字符串
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H100" \
--values '[[{"type":"text","text":"=IF(A2>B2,\"是\",\"否\")"}],...]'
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列,以 H2:H4 为例)
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H4" \
--values '[[{"type":"text","text":"=IF(A2>B2,\"是\",\"否\")"}],[{"type":"text","text":"=IF(A3>B3,\"是\",\"否\")"}],[{"type":"text","text":"=IF(A4>B4,\"是\",\"否\")"}]]'
# Step 2: 基于辅助列值做条件格式(用 formulaCondition 引用辅助列)
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
--ranges '["A2:H100"]' \
--ranges '["A2:H4"]' \
--condition '{"formulaCondition":{"formula":"=$H2=\"是\""}}' \
--cell-style '{"backgroundColor":"#FFECEC"}'
```
@@ -143,8 +142,7 @@ Flags:
- **用途**:查看指定工作表中已有的条件格式规则,或获取单个规则的详情。
- **场景**:创建/更新/删除条件格式前后验证规则状态;获取 ruleId 供后续 update/delete 使用。
- **返回**:`rules` 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
- **判空要看 `message`,不要看数组长度**:无规则时 `rules` 数组里仍会带 1 个全 null 的幽灵元素(`colorScaleCondition`/`dataBarStyle`/`iconSetCondition` 全 null,无 id),`message` 会是「No conditional formatting rules found.」。该命令**没有 `totalCount` 字段**,判空以 message 为准,或过滤掉没有 `id` 的元素。
- **返回**:rules 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
### 创建条件格式规则
```
@@ -271,4 +269,3 @@ Flags:
- **辅助列+条件格式两步走**:用户明确要求"辅助列"时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),禁止直接用 `formulaCondition` 一步绕过
- **大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入
- **判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规
- ⚠️ **`cond-format list` 判空看 `message` 不看数组长度**:无规则时 `rules` 仍含 1 个全 null 幽灵元素(无 id),`message` 为「No conditional formatting rules found.」。该命令无 `totalCount`,判空以 message 为准或过滤无 `id` 的元素
@@ -14,7 +14,7 @@
用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性":
- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false`
- 设置行高/列宽 → `update-dimension --pixel-size`
- 设置行高/列宽 → `update-dimension --pixel-size`;恢复默认尺寸 → `--size-type standard`;行高按内容自适应 → `--size-type auto`(列宽不提供 auto)
- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden`
用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行":
@@ -26,11 +26,12 @@
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
用户说"创建行分组/创建列分组/折叠几行/折叠几列/展开行列分组/取消行列分组":
- 创建分组 → `group-dimension --range <整行或整列范围>`
- 创建并折叠 → `group-dimension --group-state fold`
- 取消分组 → `ungroup-dimension --range <整行或整列范围>`
- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`
用户说"创建行分组/创建列分组/新建分组并折叠/新建分组并展开/取消分组":
- 创建连续行/列分组 → `group-dimension`
- 取消连续行/列分组 → `ungroup-dimension`
- 分组创建后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups`
- 仅创建分组时可用 `--group-state expand|fold` 指定初始展开/折叠;当前没有单独调整已有分组折叠状态的命令
- 用户要求"折叠/展开已有分组"时,当前不支持直接修改已有分组的 `collapsed` 状态,不能承诺可用 `group-dimension` 完成
**结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。
@@ -116,6 +117,12 @@ Example:
# 使用工作表前缀(忽略 --sheet-id)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden
# 恢复第 2~3 行为默认行高(无需 --pixel-size)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "2" --length 2 --size-type standard
# 第 2~3 行按内容自适应行高
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "2" --length 2 --size-type auto
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
@@ -124,14 +131,25 @@ Flags:
--length string 更新数量,正整数 (必填),最大 5000
--hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一
--pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一
--size-type string 尺寸模式: pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)
```
批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。
`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。
支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
`--hidden` 与 `--pixel-size` 至少必须提供一个(或用 `--size-type standard/auto` 让服务端决定尺寸)。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。
`--size-type` 尺寸模式(对齐飞书 row_sizes/col_sizes 的 type):
| 值 | 行为 | ROWS | COLUMNS | 是否需要 --pixel-size |
|----|------|------|---------|----------------------|
| `pixel`(默认) | 使用 `--pixel-size` 指定的像素值 | 支持 | 支持 | 需要 |
| `standard` | 恢复为工作表默认行高/列宽 | 支持 | 支持 | 不需要 |
| `auto` | 按内容自适应行高 | 支持 | 不提供 | 不需要 |
列宽不提供 `auto`(与飞书 `col_sizes` 的 type 枚举一致,只有 `pixel` / `standard`)。需要控制列宽时用 `--pixel-size` 指定,或用 `--size-type standard` 恢复默认列宽。
### 移动行或列
```
Usage:
@@ -183,32 +201,51 @@ Flags:
Usage:
dws sheet group-dimension [flags]
Example:
# 创建第 3~7 行分组,默认展开
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
# 创建 C~F 列分组,并在创建后折叠
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F" --group-state fold
# 使用工作表前缀(忽略 --sheet-id)
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!3:7"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
--group-state string 创建后的分组状态: expand 或 fold,默认 expand
--range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F"
--group-state string 创建后的分组状态: expand 或 fold (默认 expand)
```
为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持 `A1:C5` 这类普通单元格矩形范围。
创建连续整行或整列分组。`--range` 只能是整行范围或整列范围,不支持普通单元格矩形(如 `A1:C5`)。支持单行/单列(如 `"3:3"` / `"C:C"`)以及带工作表前缀(如 `Sheet1!3:7`),带前缀时忽略 `--sheet-id`。
返回中使用 `level` 表示分组展示层级,`level` 为 1-based;不要查找或依赖 `depth`。`--group-state fold` 只用于创建后的初始折叠状态,当前不提供"仅修改已有分组 collapsed 状态"的独立命令。
创建后用以下命令回读:
```bash
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --include groups --format json
```
回读字段为 `rowGroups` / `columnGroups`,单项包含 `range`、起止行列、`count`、`level`、`collapsed`。`collapsed=true` 表示当前分组折叠。
### 取消行或列分组
```
Usage:
dws sheet ungroup-dimension [flags]
Example:
# 取消第 3~7 行分组
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
# 取消 C~F 列分组
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
--range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F"
```
取消指定连续行或连续列的分组。
取消指定连续整行或整列范围上的分组。取消后同样用 `sheet info --include groups` 回读确认目标 `range` 已从 `rowGroups` / `columnGroups` 中移除。
`group-dimension` / `ungroup-dimension` 可以放进 `batch-update` 做原子组合;但 batch 中的 `group-dimension` 只适合默认展开分组。需要创建后立即折叠时,请使用独立 `dws sheet group-dimension --group-state fold`。
## 核心工作流
@@ -282,15 +319,15 @@ dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 |
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 |
| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 |
| `group-dimension` | `range` / `level` / `groupState` | 确认创建的分组范围、层级和初始状态 |
| `ungroup-dimension` | `range` / `level` | 确认取消分组的范围和层级 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `sheet info` 的 `mergedRanges` 是行列结构操作的重要预检信息。插入列时尤其要检查多行表头合并区,原有合并区域通常不会自动扩展到新列,必要时需重新设置合并区域
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`;如需"先插入行列再写入数据"的组合操作,使用 [batch-update](./sheet-batch-operations.md) 打包为原子事务
- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `insert-dimension` 的 `--length` 最大为 5000
@@ -313,7 +350,8 @@ dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5`
- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand`
- 分组操作返回 `level` 表示当前分组层级;不要把它误认为固定深度参数
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold`
- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,不接受普通单元格矩形
- `group-dimension` 输出和 `sheet info --include groups` 回读均使用 `level`,且为 1-based;不要使用旧的 `depth` 字段
- `sheet info --include groups` 是分组回读入口;裸 `sheet info` 不承诺返回行列分组,`range read` / `csv-get` 不返回行列分组
- 当前不能直接调整已有分组的 `collapsed` 状态;`--group-state fold` 只在创建分组时生效
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`,但不适合创建后立即折叠;需要 `fold` 时使用独立 `group-dimension`
@@ -55,11 +55,10 @@ Flags:
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
```
查询指定范围内的下拉列表配置信息,包括选项值和颜色。
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `multipleValues` 和 `colorValueMap`)。**判空以 `hasDropdown` 为准**:范围内无下拉列表时 `hasDropdown` 为 false,但 `dataValidations` 里仍会带 1 个全 null 的幽灵条目,不要用数组长度判空。
- ⚠️ **已知限制**:`options.multipleValues` 服务端恒返回 `null`(即使该下拉是用 `--multi-select` 建的),也不返回 `enableMultiSelect` 字段——**`get-dropdown` 读回无法判断该下拉是否多选**。要判断是否多选,改用 `range read`:它返回的 `cells[].dataValidation.enableMultiSelect` 字段是准确的(实测有效)。
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -91,6 +90,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。判空以 `hasDropdown` 为准(无下拉时为 false,但 `dataValidations` 仍含 1 个全 null 幽灵条目)
- ⚠️ `get-dropdown` 的 `options.multipleValues` 恒为 `null`、无 `enableMultiSelect` 字段,读回无法判断是否多选;要判断是否多选改用 `range read`(其 `dataValidation.enableMultiSelect` 准确)
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -11,6 +11,11 @@
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
- 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式
- 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理
## 命令详细参考
### 导出表格为 xlsx(异步任务一站式)
@@ -47,17 +52,60 @@ Flags:
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
**命令返回**(`--format json`,默认):输出规整 JSON `{success, jobId, downloadUrl[, outputPath]}`。轮询进度以 `[INFO] [N/30] 状态: ...` 打到 stderr,不污染 stdout 的 JSON(可直接 `python3 -m json.tool` 解析 stdout)。
- `--output` 未指定:JSON 含 `jobId` + `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径,JSON 额外含 `outputPath`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名保存,JSON 含 `outputPath`
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
### 导出单个工作表为纯 CSV(同步)
```
Usage:
dws sheet export-csv [flags]
Example:
dws sheet export-csv --node <NODE_ID>
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv
dws sheet export-csv --node <NODE_ID> --range A1:Z1000 --value-render-option raw_value
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称(不传则第一个工作表)
--range string 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
--value-render-option string 取值模式: formatted_value(默认) / raw_value / formula
--output string 本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout
--allow-truncated 允许数据被截断时仍然导出。默认截断即报错且不写文件
```
同步读取**单个**工作表并输出 RFC4180 CSV,不走异步导出任务。与 `export` 是两条独立命令:`export` 导整篇工作簿的 xlsx(异步提交+轮询),`export-csv` 只导一个工作表的纯值(一次请求即返回)。不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理。
**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。`--output` 指向已存在目录时保存为该目录下的 `sheet-export.csv`。
**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式:
- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …)
- 改用 `dws sheet export` 导出完整表格的 xlsx
- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格"
```bash
# 落盘到本地
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv
# 输出到 stdout 便于管道处理
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID>
# 大表分块导出(避免截断;不分块时默认会因截断而报错)
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:Z1000" --output ./part1.csv
# 明确接受不完整数据(否则截断即失败)
dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --allow-truncated --output ./partial.csv
```
注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用 `dws sheet export` 导 xlsx。只是让 Agent 读取内容(带 `[row=N]` 行号前缀)请用 `dws sheet csv-get`。
## 核心工作流
```bash
@@ -83,6 +131,8 @@ dws sheet export --node <NODE_ID> --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: <path>`(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
@@ -93,3 +143,5 @@ dws sheet export --node <NODE_ID> --output ./
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
@@ -219,7 +219,6 @@ Flags:
- **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。
- **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。
- **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。
- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。
### 列出筛选视图所有列条件
```
@@ -238,7 +237,6 @@ Flags:
- **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。
- **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。
- **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。
- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。
### 获取单列筛选条件
```
@@ -261,7 +259,6 @@ Flags:
- **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。
- **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。
- **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。
## 核心工作流
@@ -328,7 +325,7 @@ dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id`
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),按 `--filter-view-id` 返回对应视图
- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}`
- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示
- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置
@@ -32,7 +32,7 @@ Flags:
- **用途**:查看当前工作表上是否存在全局筛选及其配置。
- **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。
- **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。
- **返回**:`range`(筛选范围,A1 表示法)、`id`(筛选 ID)和 `criteria`(各列条件对象,key 为列偏移量;无条件时为 `{}`)。如果未设置筛选,返回筛选信息为空。
- **返回**:`range`(筛选范围,A1 表示法)和 `columnFilterCriteria`(各列条件,key 为列偏移量)。如果未设置筛选,返回筛选信息为空。
### 创建筛选
```
@@ -152,7 +152,7 @@ Flags:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `filter get` | `range`(筛选范围)、`id`、`criteria`(各列条件对象,无条件为 `{}`) | 查看当前筛选配置,确认筛选是否存在 |
| `filter get` | `range`(筛选范围)、`columnFilterCriteria`(各列条件) | 查看当前筛选配置,确认筛选是否存在 |
| `filter create` | 筛选创建成功的确认 | 确认筛选已建立,后续可通过 `filter update` 设置条件 |
| `filter delete` | 删除成功的确认 | 确认筛选已删除 |
| `filter update` | 更新成功的确认 | 确认条件已设置 |
@@ -164,7 +164,7 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)、`id` 和 `criteria`(各列条件对象,无条件时为 `{}`)。无筛选时返回空
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)和 `columnFilterCriteria`(各列条件)。无筛选时返回空
- `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错
- `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件
- `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复

Some files were not shown because too many files have changed in this diff Show More