Compare commits

...
Author SHA1 Message Date
chichuan 305ccf0984 Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 13:53:37 +08:00
chichuan c2c1131079 fix: match the release archive directory literally, not as a regex
release_version was interpolated into an awk regex, where '.' matches any
character. Version 1.0.1-beta.1 therefore also admitted
.changes/released/1x0x1-betaX1/, letting the archive drift from the
CHANGELOG version while every other seal assertion still passed and
breaking the documented audit trail.

Compare the archive prefix with index() and split the basename off with
substr(), matching the literal-comparison idiom already used throughout
check-changelog-pr.sh. Only the basename, whose character class is fixed,
stays a pattern.
2026-08-12 13:52:25 +08:00
github-actions[bot] 38e387bcd6 Merge pull request #959 from DingTalk-Real-AI/codex/drive-shortcuts
feat(drive): harden and expand shortcut workflows
2026-08-12 12:18:58 +08:00
chichuan 12435e6e54 refactor: stage the .changes diff once for both fragment triggers
Both trigger predicates ran the same git diff, which the script already
avoids elsewhere by staging --name-status into $tmp_root/status. Write the
path list once and let each awk predicate read it, matching that idiom.
2026-08-12 12:07:07 +08:00
chichuan 1d8182bcfb Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 12:01:38 +08:00
chichuan 4243676739 fix: trigger release fragment tree validation on nested .changes paths
Git records no diff entry for a directory itself, so adding
.changes/foo/bar.md only surfaced the nested path, which the single-level
trigger regex skipped. The entry validation and the renderer were both
bypassed, letting a nested directory reach main and break every later
fragment render with 'unexpected directory'.

Trigger the top-level tree validation on any .changes change outside
.changes/released/ (which keeps its own immutability and release-seal
checks), and assert .changes itself is still a tree so replacing it with a
blob or symlink cannot empty the child listing unnoticed.

Re-rendering stays keyed on fragment changes so a README-only edit does
not fail on an empty fragment set.
2026-08-12 12:00:42 +08:00
Dennis 4324fa72f2 fix(drive): preserve copy schema properties 2026-08-12 11:56:10 +08:00
Dennis ef5462a4dc fix(drive): scan paginated file versions 2026-08-12 11:36:33 +08:00
Dennis e1da6ba356 fix(drive): preserve download output shorthand 2026-08-12 11:21:03 +08:00
Dennis ae309b5846 feat(drive): harden and expand shortcut workflows 2026-08-12 11:11:46 +08:00
github-actions[bot] 9ef26055fa chore: update beta formula for v1.0.58-beta.4 [skip ci] 2026-08-12 02:45:42 +00:00
chichuan e0dd800378 docs: state the release fragment filename and file-kind contract 2026-08-11 21:24:43 +08:00
chichuan 309c39a8e0 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 21:24:25 +08:00
chichuan 39d6caa24d fix: validate every top-level .changes entry in the fragment gate
The fragment gate only ran validation when the changed path matched the
legal fragment name pattern, so `.changes/Foo.md`, `.changes/notes.txt`
and a symlinked fragment slipped through untouched and then broke the
next PR that added a legal fragment. The trigger now fires on any
top-level `.changes/` change other than README.md and rejects every
entry that is not README.md, released/, or a 100644 blob named
^[a-z0-9][a-z0-9._-]*\.md$.

The renderer had the same hole from the other side: `find -type f`
is false for symlinks, so a symlinked fragment was silently dropped
from the rendered notes, and the `[a-z0-9]*.md` glob only constrained
the first character so `chat reply.md` passed. It now walks every
top-level entry and fails on symlinks, unexpected directories,
non-regular files and illegal names. Both scripts pin LC_ALL=C so the
ASCII ranges cannot match uppercase under a different collation.

Adds regression coverage for illegal names, non-markdown entries,
symlinks and executable modes on both the gate and the renderer.
2026-08-11 21:07:12 +08:00
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +08:00
chichuan 1f127881c9 Merge branch 'main' into codex/release-fragments 2026-08-07 10:24:51 +08:00
chichuan c52f2b6e05 release: use isolated changelog fragments 2026-08-06 10:49:46 +08:00
37 changed files with 4693 additions and 470 deletions
+34
View File
@@ -0,0 +1,34 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```markdown
---
category: Added
---
- **Chat reply mentions** (#1234) — supports mentioning selected members.
```
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
+4 -2
View File
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
- [ ] Release-seal validation (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
+6
View File
@@ -1288,6 +1288,12 @@ jobs:
./scripts/policy/check-changelog-pr.sh \
"$mode" "$PR_BASE_SHA" HEAD
- name: Validate release fragment lifecycle
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
+1 -1
View File
@@ -2793,7 +2793,7 @@ jobs:
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
+4 -1
View File
@@ -83,7 +83,10 @@ coverage is additionally selected for platform-sensitive code.
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
the release-seal workflow renders and archives fragments into the versioned
changelog section.
## Submission Flow
+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.58-beta.3"
version "1.0.58-beta.4"
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.58-beta.3/dws-darwin-arm64.tar.gz"
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz"
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
end
def install
+14 -8
View File
@@ -48,8 +48,12 @@ It then runs:
--fast-path "$PR_BASE_SHA" HEAD
```
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
its policy dependencies in that merge tree are byte-for-byte the current base
The exact fast path remains limited to historic one-file maintenance. A
release-seal PR uses `--content-only`, which permits the generated
`CHANGELOG.md` change together with archival moves from `.changes/` to
`.changes/released/`; it receives the normal scoped admission instead of this
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
content contract in `Policy` with `--content-only`. That mode accepts only
fragment archival moves (`.changes/<name>.md` to
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
documentation changes are rejected. It still rejects invalid dates or
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
changes, and unsafe tree modes.
## Risk tiers and downstream boundaries
@@ -184,11 +190,11 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
For a release-seal branch that archives rendered fragments:
```sh
base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
+312
View File
@@ -0,0 +1,312 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
<title>Drive Shortcut 能力全景|业务评审版</title>
<style>
:root {
color-scheme: light;
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
a { color: inherit; text-decoration: none; }
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
.nav .wrap { display: flex; overflow-x: auto; }
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
.nav a:hover { color: var(--forest); background: #eaf3ee; }
main { padding: 38px 0 74px; }
section { margin-top: 50px; scroll-margin-top: 72px; }
section:first-child { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
h3 { margin: 0 0 7px; font-size: 18px; }
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
.callout strong { color: #13513d; }
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
.callout.warn strong { color: #784b00; }
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.score { padding: 19px; }
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
.domain-grid { grid-template-columns: repeat(4, 1fr); }
.domain-card { position: relative; padding: 21px; overflow: hidden; }
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
.domain-card small { color: var(--green); font-weight: 800; }
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
.compare-column:last-child { border-right: 0; }
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
.compare-column.covered { border-top: 5px solid var(--green); }
.compare-column.partial { border-top: 5px solid #c78b22; }
.compare-column.gap { border-top: 5px solid var(--red); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #f8fbf8; }
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
.v-covered, .badge.read { color: #116045; background: var(--mint); }
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
.truth-step strong, .truth-step span { display: block; }
.truth-step span { color: var(--muted); font-size: 12px; }
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-row:hover { background: #f8fbf8; }
.command code { font-size: 12px; }
.row-main p { margin: 0; font-size: 13px; }
.row-main small { color: var(--muted); }
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.hidden-row { display: none; }
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
.evidence-card { padding: 19px; }
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
.event { position: relative; padding: 0 0 17px 22px; }
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
.event b { display: block; }
.event span { color: var(--muted); font-size: 12px; }
.review-grid { grid-template-columns: repeat(3, 1fr); }
.review-card { position: relative; padding: 20px; }
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
</style>
</head>
<body>
<header class="hero">
<div class="wrap hero-grid">
<div>
<p class="eyebrow">Business Review · Drive</p>
<h1>Drive Shortcut<br>能力全景</h1>
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
<div class="meta-row">
<span class="meta-pill good">真实账号 E2E 已执行</span>
<span class="meta-pill">28 个公开入口</span>
<span class="meta-pill">统一 Result / Pagination</span>
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
</div>
</div>
<div class="hero-stats" aria-label="关键统计">
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
</div>
</div>
</header>
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
<main class="wrap">
<section id="overview">
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
<div class="score-grid">
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
</div>
</section>
<section>
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
<div class="domain-grid">
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
</div>
</section>
<section id="compare">
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
<div class="compare">
<div class="compare-top">
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
</div>
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
</tbody></table></div>
</div>
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
</section>
<section id="ahead">
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
<div class="domain-grid">
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
</div>
</section>
<section id="truth">
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
<div class="truth-grid">
<article class="truth-card"><h3>四层成功证据</h3>
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
</article>
<article class="truth-card"><h3>明确失败的情况</h3>
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
</article>
</div>
</section>
<section id="catalog">
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
<div class="catalog">
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
</div>
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
</section>
<section id="e2e">
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
<div class="evidence-grid">
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
</div>
<div class="timeline">
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
</div>
</section>
<section id="review">
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
<div class="review-grid">
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
</div>
</section>
</main>
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
<script>
const q = document.querySelector('#q');
const domain = document.querySelector('#domain');
const risk = document.querySelector('#risk');
const rows = [...document.querySelectorAll('[data-tool]')];
const count = document.querySelector('#result-count');
function filterTools() {
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
let visible = 0;
rows.forEach((row) => {
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
row.classList.toggle('hidden-row', !show);
if (show) visible += 1;
});
count.textContent = `显示 ${visible} / ${rows.length}`;
}
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
</script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
# Drive Shortcut 对齐与超越 Lark CLI
## 目标与判定口径
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
## Lark 38 项映射
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|---|---|---|
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
| `+add-reply` | `doc +comment-reply` | 对齐。 |
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
## DWS 超出 Lark Drive 的可挖掘能力
- `+list`:严格目录分页,而不是把缺字段当空目录。
- `+recent`:最近访问/编辑与创建人筛选。
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
- `+rename`:写后读回验证。
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
## 端到端门禁
每个公开 Drive shortcut 必须至少覆盖:
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
- 写入的 ID/终态证据与读回不一致;
- 下载的本地路径边界、no-clobber、真实字节数;
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
+13 -2
View File
@@ -17,7 +17,8 @@
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
### Release fragments
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
+262 -31
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-10T17:25:46.245552",
"count": 378,
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"results": [
{
"suite": "semantic",
@@ -2650,8 +2650,8 @@
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。",
"availability": "available"
},
{
@@ -2660,8 +2660,8 @@
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。",
"availability": "available"
},
{
@@ -2670,8 +2670,8 @@
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。",
"availability": "available"
},
{
@@ -2841,7 +2841,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"semantic_delta": "按名称或关键词检索模板并返回可消歧候选和 templateId。",
"availability": "available"
},
{
@@ -2860,8 +2860,8 @@
"command": "+version-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"disposition": "semantic_adapter",
"semantic_delta": "版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。",
"availability": "available"
},
{
@@ -2870,8 +2870,8 @@
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"disposition": "primary_smart",
"semantic_delta": "版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。",
"availability": "available"
},
{
@@ -2880,58 +2880,289 @@
"command": "+version-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"disposition": "semantic_adapter",
"semantic_delta": "版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+copy",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+cover",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-folder",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建普通钉盘文件夹后要求 fileId,并读回名称验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-shortcut",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "明确 shortcut 与 copy 语义差异,创建后读取新节点验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+find-file",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+info",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+move",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+publish-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+publish-unset",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后关闭互联网公开,并读回状态验证外链已失效。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recent",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+recycle-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recycle-restore",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+rename",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "重命名后读取真实节点元数据验证最终名称。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+search-docs",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出当前用户收藏并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义取消收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+stats",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按正整数版本号精确匹配元数据;零命中显式失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-history",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出普通文件版本,区分合法空历史与响应契约错误。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。",
"availability": "available"
},
{
"suite": "read",
@@ -3180,7 +3411,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; +minutes-search is a compatibility alias.",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.",
"availability": "available"
},
{
@@ -3260,7 +3491,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, cancellation compensation and final task read-back verification.",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.",
"availability": "available"
},
{
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 378
publicShortcutCount = 399
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 379
schemaPublishedShortcutCount = 401
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 378
publiclyDeliveredShortcutCount = 399
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
+79
View File
@@ -281,6 +281,85 @@ func parseDriveUploadInfo(text string) (resourceURL, uploadID string, headers ma
return
}
// DriveUploadRequest describes the reusable Drive upload transaction used by
// the native leaf and the curated +upload shortcut. FilePath must already be
// resolved and validated by the caller.
type DriveUploadRequest struct {
FilePath string
FileName string
FileSize int64
SpaceID string
ParentID string
OverwriteFile string
MIMEType string
}
// UploadDriveFileData runs credentials -> OSS PUT -> commit exactly once and
// returns the parsed commit response without rendering it. Unlike the legacy
// leaf helper, this path fails when the commit has no non-empty JSON response;
// the Shortcut can then require terminal success evidence and read back the
// created node before reporting success.
func UploadDriveFileData(ctx context.Context, request DriveUploadRequest) (map[string]any, error) {
if strings.TrimSpace(request.FilePath) == "" || strings.TrimSpace(request.FileName) == "" || request.FileSize <= 0 {
return nil, fmt.Errorf("invalid drive upload request")
}
step1Args := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
}
if request.SpaceID != "" {
step1Args["spaceId"] = request.SpaceID
}
if request.MIMEType != "" {
step1Args["mimeType"] = request.MIMEType
}
if request.OverwriteFile != "" {
step1Args["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
step1Args["parentId"] = request.ParentID
}
credentialText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_upload_info", step1Args)
if err != nil {
return nil, err
}
uploadID, err := driveUploadPut(ctx, credentialText, func(refreshCtx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(refreshCtx, "drive", "get_upload_info", step1Args)
}, request.FilePath, request.FileSize)
if err != nil {
return nil, err
}
commitArgs := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
"uploadId": uploadID,
}
if request.SpaceID != "" {
commitArgs["spaceId"] = request.SpaceID
}
if request.OverwriteFile != "" {
commitArgs["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
commitArgs["parentId"] = request.ParentID
}
commitText, err := callMCPToolReturnTextOnServer(ctx, "drive", "commit_upload", commitArgs)
if err != nil {
return nil, err
}
if strings.TrimSpace(commitText) == "" {
return nil, fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
}
var result map[string]any
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
return nil, fmt.Errorf("parse commit_upload response: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
}
return result, nil
}
func newDriveCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/drive.json
// products.drive). Catalog assembly stamps provenance contract_final.
@@ -154,6 +154,88 @@ func TestCrossPlatformCoverageDriveUploadTransportCoverage(t *testing.T) {
})
}
func TestCrossPlatformCoverageUploadDriveFileDataStrictTransaction(t *testing.T) {
credential := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u?upload_key=u1","uploadId":"u1","headers":{"dentry-token":"token"}}}`
request := DriveUploadRequest{FilePath: "fixture.bin", FileName: "fixture.bin", FileSize: 7, SpaceID: "space", ParentID: "folder", MIMEType: "application/octet-stream"}
t.Run("success with parent", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true,"result":{"fileId":"n1"}}`}}}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
result, err := UploadDriveFileData(context.Background(), request)
if err != nil || result["success"] != true || caller.calls != 2 {
t.Fatalf("result=%v calls=%d error=%v", result, caller.calls, err)
}
if caller.args["spaceId"] != "space" || caller.args["parentId"] != "folder" {
t.Fatalf("commit args=%v", caller.args)
}
})
t.Run("success with overwrite", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true}`}}}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
overwrite := request
overwrite.ParentID = "ignored"
overwrite.OverwriteFile = "existing"
if _, err := UploadDriveFileData(context.Background(), overwrite); err != nil {
t.Fatal(err)
}
if caller.args["overwriteFileId"] != "existing" {
t.Fatalf("commit args=%v", caller.args)
}
})
t.Run("credential refresh callback", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: credential}, {text: `{"success":true}`}}}
installScriptedCaller(t, caller)
putCalls := 0
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
putCalls++
if putCalls == 1 {
return &httpStatusError{StatusCode: 401, Body: "expired"}
}
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
if _, err := UploadDriveFileData(context.Background(), request); err != nil {
t.Fatal(err)
}
if caller.calls != 3 || putCalls != 2 {
t.Fatalf("caller calls=%d put calls=%d", caller.calls, putCalls)
}
})
for _, tc := range []struct {
name string
request DriveUploadRequest
steps []scriptedToolStep
putErr error
want string
}{
{name: "invalid request", request: DriveUploadRequest{}, want: "invalid drive upload request"},
{name: "credential failure", request: request, steps: []scriptedToolStep{{err: errors.New("credentials")}}, want: "credentials"},
{name: "put failure", request: request, steps: []scriptedToolStep{{text: credential}}, putErr: errors.New("put failed"), want: "put failed"},
{name: "commit failure", request: request, steps: []scriptedToolStep{{text: credential}, {err: errors.New("commit failed")}}, want: "commit failed"},
{name: "empty commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: " "}}, want: "no business result"},
{name: "malformed commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: "{"}}, want: "parse commit_upload response"},
{name: "empty object commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: `{}`}}, want: "empty JSON object"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: tc.steps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
t.Cleanup(func() { SetHTTPPutFile(nil) })
_, err := UploadDriveFileData(context.Background(), tc.request)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error=%v, want %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageDriveCommandRemainingEdges(t *testing.T) {
file := filepath.Join(t.TempDir(), "fixture.txt")
_ = os.WriteFile(file, []byte("fixture"), 0o600)
@@ -0,0 +1,83 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDriveSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_drive.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "drive" {
t.Fatalf("semantic catalog service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service != "drive" {
continue
}
if _, duplicate := registered[item.Command]; duplicate {
t.Fatalf("duplicate registered Drive Shortcut %s", item.Command)
}
registered[item.Command] = item
}
if len(registered) != 29 || len(source.Shortcuts) != 29 {
t.Fatalf("registered/catalog = %d/%d, want 29/29", len(registered), len(source.Shortcuts))
}
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed {
t.Errorf("%s: reviewed delivery mismatch", command)
}
if got := shortcut.InPublicCatalog("drive", command); got != record.Public || item.Hidden == record.Public {
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
}
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
t.Errorf("%s: semantic catalog facts drifted", command)
}
if item.Risk != record.Risk {
t.Errorf("%s: risk = %q, want %q", command, item.Risk, record.Risk)
}
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" || strings.TrimSpace(item.Safety.Confirmation) == "" {
t.Errorf("%s: incomplete explicit contract/safety", command)
}
if command != "+find-file" && record.Public {
if item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s: output rollout = %q", command, item.OutputRollout)
}
if item.Contract.Result == nil {
t.Errorf("%s: public Drive shortcut lacks Result declaration", command)
}
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("semantic catalog mismatch: missing=%v stale=%v", missing, stale)
}
}
@@ -0,0 +1,524 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var uploadDriveFile = helpers.UploadDriveFileData
var driveRestoreWait = time.Sleep
var (
driveGetwd = os.Getwd
driveEvalSymlinks = filepath.EvalSymlinks
driveRel = filepath.Rel
driveStat = os.Stat
)
var RecycleList = shortcut.Shortcut{
Service: "drive", Command: "+recycle-list", Product: "drive",
Description: "严格分页列出钉盘回收站",
Intent: "查找可恢复的回收项并获取 recycleItemId 时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+recycle-list", "严格分页列出钉盘回收站",
"查找可恢复的回收项并获取 recycleItemId 时使用。",
[]string{"恢复前必须先确认具体回收项;普通目录浏览使用 drive +list"},
[]string{`dws drive +recycle-list --limit 20`},
driveCollectionResult("items", "严格校验的回收站条目页"), driveCursorPagination(),
contract.ParamDecl{Name: "cursor", Property: "nextCursor"},
),
Flags: []shortcut.Flag{
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
},
Tips: []string{`dws drive +recycle-list --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"maxResults": rt.Int("limit")}
if rt.Str("space-id") != "" {
params["spaceId"] = rt.Str("space-id")
}
if rt.Str("cursor") != "" {
params["nextCursor"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("drive", "list_recycle_items", params)
if err != nil {
return err
}
items, page, err := requireDriveCollection(data, "drive/list_recycle_items", "recycleItems")
if err != nil {
return err
}
rows := projectDriveRows(items, map[string][]string{
"recycleItemId": {"recycleItemId", "id"},
"originalName": {"originalName", "name", "fileName", "title"},
"originalPath": {"originalPath", "path"},
"type": {"type", "fileType", "nodeType", "contentType"},
"deleteTime": {"operatorTime", "deleteTime", "deletedTime", "recycleTime"},
"fileSize": {"fileSize", "size"},
})
out := map[string]any{"count": len(rows), "items": rows}
addDrivePagination(out, page)
return rt.Output(out)
},
}
var RecycleRestore = shortcut.Shortcut{
Service: "drive", Command: "+recycle-restore", Product: "drive",
Description: "恢复已确认的回收站条目并读回节点",
Intent: "已经通过 drive +recycle-list 确认回收项,并明确要求恢复时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+recycle-restore", "恢复已确认的回收站条目并读回节点",
"已经通过 drive +recycle-list 确认回收项,并明确要求恢复时使用。",
[]string{"尚未确认回收项时先列表;不要把原节点 ID 当 recycleItemId"},
[]string{`dws drive +recycle-restore --id <recycleItemId>`},
driveObjectResult("恢复并读回验证后的节点"), nil,
contract.ParamDecl{Name: "id", Property: "recycleItemId"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "回收项 ID", Required: true},
},
Tips: []string{`dws drive +recycle-restore --id <recycleItemId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
recycleItem, err := findDriveRecycleItem(rt, rt.Str("id"))
if err != nil {
return err
}
written, err := rt.CallMCPWriteDataStrict("drive", "restore_recycle_item", map[string]any{"recycleItemId": rt.Str("id")})
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "drive/restore_recycle_item"); err != nil {
return err
}
nodeID := nestedString(written, "fileId", "nodeId", "dentryUuid", "id")
if nodeID == "" {
nodeID, err = findRestoredDriveNode(rt, recycleItem)
if err != nil {
return err
}
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "file": verified})
},
}
func findDriveRecycleItem(rt *shortcut.RuntimeContext, recycleItemID string) (map[string]any, error) {
params := map[string]any{"maxResults": 50}
for pageNumber := 0; pageNumber < 20; pageNumber++ {
data, err := rt.CallMCPData("drive", "list_recycle_items", params)
if err != nil {
return nil, err
}
items, page, err := requireDriveCollection(data, "drive/list_recycle_items", "recycleItems")
if err != nil {
return nil, err
}
for _, item := range items {
entry := item.(map[string]any)
if nestedString(entry, "recycleItemId", "id") == recycleItemID {
return entry, nil
}
}
nextCursor := nestedString(page, "nextCursor", "nextToken", "nextPageToken")
hasMore, hasMorePresent := boolField(page, "hasMore")
if nextCursor == "" || (hasMorePresent && !hasMore) {
break
}
params["nextCursor"] = nextCursor
}
return nil, driveResponseError("drive/list_recycle_items", "recycle_item_not_found", "回收站中没有找到指定 recycleItemId;未执行恢复")
}
func findRestoredDriveNode(rt *shortcut.RuntimeContext, recycleItem map[string]any) (string, error) {
name := nestedString(recycleItem, "originalName", "name", "fileName", "title")
if name == "" {
return "", driveResponseError("drive/restore_recycle_item", "missing_restored_identity", "恢复响应没有节点 ID,且恢复前回收项没有名称,无法安全读回终态")
}
searchName := name
if extension := filepath.Ext(name); extension != "" {
searchName = strings.TrimSuffix(name, extension)
}
originalPath := nestedString(recycleItem, "originalPath", "path")
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
driveRestoreWait(750 * time.Millisecond)
}
if originalPath != "" {
if nodeID, found, err := findRestoredDriveNodeAtOriginalPath(rt, originalPath, name); err != nil {
return "", err
} else if found {
return nodeID, nil
}
}
data, err := rt.CallMCPData("drive", "search_files", map[string]any{"keyword": searchName, "pageSize": 30})
if err != nil {
return "", err
}
items, _, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return "", err
}
candidates := make([]string, 0, 1)
for _, item := range items {
entry := item.(map[string]any)
entryName := nestedString(entry, "name", "fileName", "dentryName", "title")
entryExtension := strings.TrimPrefix(nestedString(entry, "extension", "fileExtension", "ext"), ".")
fullEntryName := entryName
if entryExtension != "" && filepath.Ext(entryName) == "" {
fullEntryName += "." + entryExtension
}
if entryName != name && fullEntryName != name && entryName != searchName {
continue
}
if originalPath != "" {
path := nestedString(entry, "path", "originalPath")
if path != "" && path != originalPath {
continue
}
}
if id := nestedString(entry, "fileId", "dentryUuid", "nodeId", "id"); id != "" {
candidates = append(candidates, id)
}
}
if len(candidates) == 1 {
return candidates[0], nil
}
if len(candidates) > 1 {
return "", driveResponseError("drive/restore_recycle_item", "restored_node_ambiguous", "服务端已接受恢复,但按原名称找到多个节点,无法唯一确认恢复终态;请用 +list 核对")
}
}
return "", driveResponseError("drive/restore_recycle_item", "restored_node_not_found", "服务端已接受恢复,但没有返回节点 ID,且在有界等待后仍按原名称搜索不到恢复后的节点;远端效果未知,请先用 +list 确认")
}
func findRestoredDriveNodeAtOriginalPath(rt *shortcut.RuntimeContext, originalPath, name string) (string, bool, error) {
parentName := filepath.Base(filepath.Dir(originalPath))
if parentName == "." || parentName == string(filepath.Separator) || parentName == "" {
return "", false, nil
}
data, err := rt.CallMCPData("drive", "search_files", map[string]any{"keyword": parentName, "pageSize": 30})
if err != nil {
return "", false, err
}
items, _, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return "", false, err
}
parentIDs := make([]string, 0, 1)
for _, item := range items {
entry := item.(map[string]any)
if nestedString(entry, "name", "fileName", "dentryName", "title") != parentName {
continue
}
if id := nestedString(entry, "fileId", "dentryUuid", "nodeId", "id"); id != "" {
parentIDs = append(parentIDs, id)
}
}
if len(parentIDs) != 1 {
return "", false, nil
}
data, err = rt.CallMCPData("drive", "list_files", map[string]any{"parentId": parentIDs[0], "maxResults": 50})
if err != nil {
return "", false, err
}
items, _, err = requireDriveCollection(data, "drive/list_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return "", false, err
}
var nodeID string
for _, item := range items {
entry := item.(map[string]any)
if nestedString(entry, "name", "fileName", "dentryName", "title") != name {
continue
}
if nodeID != "" {
return "", false, driveResponseError("drive/list_files", "restored_node_ambiguous", "恢复后的原目录中存在多个同名节点,无法唯一确认终态")
}
nodeID = nestedString(entry, "fileId", "dentryUuid", "nodeId", "id")
}
return nodeID, nodeID != "", nil
}
var StarList = shortcut.Shortcut{
Service: "drive", Command: "+star-list", Product: "drive",
Description: "严格分页列出当前用户收藏",
Intent: "浏览当前用户收藏并获取后续可操作节点 ID 时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+star-list", "严格分页列出当前用户收藏",
"浏览当前用户收藏并获取后续可操作节点 ID 时使用。",
[]string{"最近访问使用 drive +recent;目录浏览使用 drive +list"},
[]string{`dws drive +star-list --limit 20`},
driveCollectionResult("items", "严格校验的收藏条目页"), driveCursorPagination(),
contract.ParamDecl{Name: "cursor", Property: "cursor"},
),
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "content-types", Type: shortcut.FlagStringSlice, Desc: "内容类型过滤"},
},
Tips: []string{`dws drive +star-list --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"limit": rt.Int("limit")}
if rt.Str("cursor") != "" {
params["cursor"] = rt.Str("cursor")
}
if values := rt.StrSlice("content-types"); len(values) > 0 {
params["contentTypes"] = values
}
data, err := rt.CallMCPData("drive", "get_star_list", params)
if err != nil {
return err
}
items, page, err := requireDriveCollection(data, "drive/get_star_list", "starList")
if err != nil {
return err
}
rows := projectDriveRows(items, map[string][]string{
"nodeId": {"nodeId", "fileId", "dentryUuid", "id"},
"name": {"name", "fileName", "title"},
"type": {"type", "contentType", "nodeType"},
"createTime": {"createTime", "starTime"},
})
out := map[string]any{"count": len(rows), "items": rows}
addDrivePagination(out, page)
return rt.Output(out)
},
}
var StarAdd = starMutationShortcut("+star-add", "收藏指定节点", "mark_star", "将指定文件或文档加入当前用户收藏。")
var StarRemove = starMutationShortcut("+star-remove", "取消收藏指定节点", "unmark_star", "将指定文件或文档从当前用户收藏移除。")
func starMutationShortcut(command, description, tool, useWhen string) shortcut.Shortcut {
return shortcut.Shortcut{
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(command, description, useWhen,
[]string{"查看收藏状态和列表使用 drive +star-list"},
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
driveObjectResult(description+"的终态证据"), nil, contract.ParamDecl{Name: "node", Property: "nodeId"}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
Execute: func(rt *shortcut.RuntimeContext) error {
written, err := rt.CallMCPWriteDataStrict("drive", tool, map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
written, err = requireDriveWrite(written, "drive/"+tool)
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
},
}
}
var PublishGet = shortcut.Shortcut{
Service: "drive", Command: "+publish-get", Product: "drive",
Description: "查询文件互联网公开状态",
Intent: "查询文件当前互联网公开状态和权限时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+publish-get", "查询文件互联网公开状态", "查询文件当前互联网公开状态和权限时使用。",
[]string{"开启或关闭公开分别使用 drive +publish-set / +publish-unset"},
[]string{`dws drive +publish-get --node <dentryUuid>`}, driveObjectResult("互联网公开状态"), nil,
contract.ParamDecl{Name: "node", Property: "fileId"},
),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID", Required: true}},
Tips: []string{`dws drive +publish-get --node <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("drive", "get_file_publish_status", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
data, err = requireDriveObject(data, "drive/get_file_publish_status")
if err != nil {
return err
}
return rt.Output(data)
},
}
var PublishSet = publishMutationShortcut("+publish-set", true)
var PublishUnset = publishMutationShortcut("+publish-unset", false)
func publishMutationShortcut(command string, published bool) shortcut.Shortcut {
description := "开启文件互联网公开发布"
useWhen := "用户明确同意任何持链接者可访问,并已确认公开权限时使用。"
if !published {
description = "关闭文件互联网公开发布"
useWhen = "用户明确要求让现有互联网公开链接失效时使用。"
}
flags := []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID", Required: true}}
if published {
flags = append(flags, shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Default: "DOWNLOADER", Desc: "公开权限", Enum: []string{"READER", "DOWNLOADER", "EDITOR"}})
}
return shortcut.Shortcut{
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
Risk: shortcut.RiskHighWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(command, description, useWhen,
[]string{"只查询状态使用 drive +publish-get;企业内部协作者权限使用 doc +access-grant"},
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
driveObjectResult(description+"并读回验证的状态"), nil, contract.ParamDecl{Name: "node", Property: "fileId"}),
Flags: flags,
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"fileId": rt.Str("node"), "published": published}
if published {
params["publishPermission"] = rt.Str("permission")
}
written, err := rt.CallMCPWriteDataStrict("drive", "set_file_publish", params)
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "drive/set_file_publish"); err != nil {
return err
}
verified, err := rt.CallMCPData("drive", "get_file_publish_status", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_publish_status")
if err != nil {
return err
}
actual, ok := boolField(verified, "published", "isPublished", "publish")
if !ok || actual != published {
return driveResponseError("drive/set_file_publish", "readback_mismatch", "公开状态写入后读回不一致")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "publish": verified})
},
}
}
func boolField(data map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := data[key].(bool); ok {
return value, true
}
}
return false, false
}
var Upload = shortcut.Shortcut{
Service: "drive", Command: "+upload", Product: "drive",
Description: "从工作目录上传本地文件并读回验证",
Intent: "把工作目录内普通文件上传到钉盘,并要求验证远端文件 ID、名称和大小时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+upload", "从工作目录上传本地文件并读回验证",
"把工作目录内普通文件上传到钉盘,并要求验证远端文件 ID、名称和大小时使用。",
[]string{"在线文档导入转换使用 doc +import;作为正文附件使用 doc +media-insert;覆盖已有文件必须显式 --node"},
[]string{`dws drive +upload --file report.pdf`, `dws drive +upload --file report.pdf --folder <dentryUuid>`},
driveObjectResult("上传并读回验证后的远端文件"), nil,
contract.ParamDecl{Name: "folder", Property: "parentId"},
contract.ParamDecl{Name: "node", Property: "overwriteFileId"},
),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内的相对文件路径", Required: true},
{Name: "file-name", Type: shortcut.FlagString, Desc: "远端显示名称,默认使用本地文件名"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
{Name: "node", Type: shortcut.FlagString, Desc: "覆盖目标文件 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"folder", "node"}}},
Tips: []string{`dws drive +upload --file report.pdf`, `dws drive +upload --file report.pdf --folder <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
path, info, err := resolveDriveUploadInput(rt.Str("file"))
if err != nil {
return err
}
name := rt.Str("file-name")
if name == "" {
name = info.Name()
}
if rt.DryRun() {
return rt.Output(map[string]any{"dry_run": true, "executed": false, "operation": "drive.upload", "file": rt.Str("file"), "fileName": name, "sizeBytes": info.Size()})
}
committed, err := uploadDriveFile(rt.Command().Context(), helpers.DriveUploadRequest{
FilePath: path, FileName: name, FileSize: info.Size(), SpaceID: rt.Str("space-id"), ParentID: rt.Str("folder"), OverwriteFile: rt.Str("node"), MIMEType: rt.Str("mime-type"),
})
if err != nil {
return err
}
committed, err = requireDriveWrite(committed, "drive/commit_upload")
if err != nil {
return err
}
nodeID := rt.Str("node")
if nodeID == "" {
nodeID = nestedString(committed, "fileId", "dentryUuid", "nodeId", "id")
}
if nodeID == "" {
return driveResponseError("drive/commit_upload", "missing_created_id", "上传提交没有返回文件 ID;远端效果未知")
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) {
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
},
}
func resolveDriveUploadInput(raw string) (string, os.FileInfo, error) {
if strings.TrimSpace(raw) == "" || filepath.IsAbs(raw) {
return "", nil, fmt.Errorf("--file 只接受工作目录内的相对文件路径")
}
cwd, err := driveGetwd()
if err != nil {
return "", nil, err
}
base, err := driveEvalSymlinks(cwd)
if err != nil {
return "", nil, err
}
path, err := driveEvalSymlinks(filepath.Join(base, filepath.Clean(raw)))
if err != nil {
return "", nil, fmt.Errorf("读取上传文件失败: %w", err)
}
rel, err := driveRel(base, path)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", nil, fmt.Errorf("--file 不能逃逸工作目录")
}
info, err := driveStat(path)
if err != nil {
return "", nil, err
}
if info.IsDir() || info.Size() <= 0 {
return "", nil, fmt.Errorf("--file 必须是非空普通文件")
}
return path, info, nil
}
+227
View File
@@ -0,0 +1,227 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"encoding/json"
"fmt"
"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"
)
const driveCompositeInterfaceReason = "Reviewed Drive Shortcut composite: the executable CLI owns strict response validation, optional multi-step orchestration, local I/O, read-back verification, output projection, and confirmation; no single MCP interface represents the complete command contract."
func driveContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "drive " + command
return corecmd.ContractDecl{
Description: description,
Parameters: params,
Result: result,
Pagination: pagination,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: driveCompositeInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: name,
CanonicalPath: "drive." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
func driveObjectResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"additionalProperties":true}`,
description,
)),
}
}
func driveCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Drive 资源条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
description, collection, description, collection,
)),
}
}
func driveCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func requireDriveResponse(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, driveResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
}
if success, present := data["success"]; present {
value, ok := success.(bool)
if !ok {
return nil, driveResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !value {
message := firstString(data, "errorMsg", "message", "error")
if message == "" {
message = "服务明确返回 success=false"
}
return nil, driveResponseError(operation, "remote_failure", message)
}
}
return data, nil
}
func requireDriveObject(data map[string]any, operation string) (map[string]any, error) {
data, err := requireDriveResponse(data, operation)
if err != nil {
return nil, err
}
if value, present := data["result"]; present {
result, ok := value.(map[string]any)
if !ok || len(result) == 0 {
return nil, driveResponseError(operation, "malformed_result", "响应 result 缺失有效对象")
}
return result, nil
}
if value, present := data["data"]; present {
result, ok := value.(map[string]any)
if !ok || len(result) == 0 {
return nil, driveResponseError(operation, "malformed_data", "响应 data 缺失有效对象")
}
return result, nil
}
if len(data) == 1 {
return nil, driveResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
}
return data, nil
}
func requireDriveWrite(data map[string]any, operation string) (map[string]any, error) {
data, err := requireDriveResponse(data, operation)
if err != nil {
return nil, err
}
success, ok := data["success"].(bool)
if !ok || !success {
return nil, driveResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
}
return data, nil
}
func requireDriveCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
data, err := requireDriveResponse(data, operation)
if err != nil {
return nil, nil, err
}
containers := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if value, present := data[wrapper]; present {
inner, ok := value.(map[string]any)
if !ok {
return nil, nil, driveResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
}
containers = append(containers, inner)
}
}
for _, container := range containers {
for _, key := range keys {
value, present := container[key]
if !present {
continue
}
items, ok := value.([]any)
if !ok {
return nil, nil, driveResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
for index, item := range items {
if _, ok := item.(map[string]any); !ok {
return nil, nil, driveResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
}
}
return items, container, nil
}
}
return nil, nil, driveResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段投影成空结果")
}
func projectDriveRows(items []any, aliases map[string][]string) []map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
source := item.(map[string]any)
row := make(map[string]any)
for canonical, candidates := range aliases {
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
rows = append(rows, row)
}
return rows
}
func addDrivePagination(out map[string]any, container map[string]any) {
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"hasMore", "hasMore"}} {
if value, ok := container[pair[0]]; ok && value != nil {
out[pair[1]] = value
}
}
}
func firstString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func nestedString(data map[string]any, keys ...string) string {
if value := firstString(data, keys...); value != "" {
return value
}
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if value := firstString(inner, keys...); value != "" {
return value
}
}
}
return ""
}
func driveResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
+235 -239
View File
@@ -20,11 +20,17 @@
package drive
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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var driveDownload = localio.Download
// ── 钉盘文件(drive MCP server)────────────────────────────────
// List → list_files
@@ -32,9 +38,20 @@ var List = shortcut.Shortcut{
Service: "drive",
Command: "+list",
Product: "drive",
Description: "列出钉盘文件/文件夹",
Intent: "当你想浏览钉盘某个空间或文件夹下有哪些文件和子文件夹、需要拿到文件的 dentryUuid 以便后续下载/移动/删除时使用;可指定 space-id、folder 逐层进入,支持分页和按创建/修改时间、名称排序,返回文件列表(含 ID、名称、类型等)。",
Description: "严格分页列出钉盘文件和文件夹",
Intent: "浏览钉盘根目录或已知文件夹时使用;服务端明确空数组才表示空目录,缺字段、坏元素或空响应都会失败。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+list", "严格分页列出钉盘文件和文件夹",
"浏览钉盘根目录或已知文件夹时使用;服务端明确空数组才表示空目录,缺字段、坏元素或空响应都会失败。",
[]string{"按关键词定位文件改用 drive +search;查看单个节点详情改用 drive +inspect"},
[]string{`dws drive +list --limit 20`, `dws drive +list --folder <dentryUuid> --limit 20`},
driveCollectionResult("files", "严格校验并投影的钉盘目录页"), driveCursorPagination(),
contract.ParamDecl{Name: "space-id", Property: "spaceId"},
contract.ParamDecl{Name: "folder", Property: "parentId"},
contract.ParamDecl{Name: "cursor", Property: "nextToken"},
),
Flags: []shortcut.Flag{
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID (纯数字),不传则使用「我的文件」"},
{Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID (dentryUuid),不传则列出空间根目录"},
@@ -72,68 +89,23 @@ var List = shortcut.Shortcut{
if err != nil {
return err
}
files := listFilesProject(data)
return rt.Output(map[string]any{"count": len(files), "files": files})
items, page, err := requireDriveCollection(data, "drive/list_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return err
}
files := projectDriveRows(items, map[string][]string{
"name": {"name", "fileName", "dentryName", "title"},
"type": {"type", "dentryType", "fileType", "spaceType"},
"nodeId": {"fileId", "dentryUuid", "nodeId", "id"},
"dentryId": {"dentryId"},
"fileSize": {"fileSize", "size", "byteSize", "length"},
})
out := map[string]any{"count": len(files), "files": files}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// listFilesProject reshapes the raw list_files response into a clean,
// stable 钉盘 file/folder list ({name,type,dentryId,fileSize}) — the
// output-projection fidelity applied to every list shortcut. Both the list
// container and each field are probed defensively across candidate keys, so a
// missing container or unknown alias simply yields an empty list rather than a
// fabricated value.
func listFilesProject(data map[string]any) []map[string]any {
raw := listFilesContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
listFilesPick(row, m, "name", "name", "fileName", "dentryName", "title")
listFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType")
listFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId")
listFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length")
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// listFilesContainer locates the file list array inside the response by trying
// the common container keys emitted across drive backends; the payload itself
// may also already be the array.
func listFilesContainer(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} {
if v, ok := data[k].([]any); ok {
return v
}
// The container may be nested one level (e.g. {"data":{"list":[...]}}).
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} {
if v, ok := inner[ik].([]any); ok {
return v
}
}
}
}
return nil
}
// listFilesPick copies the first matching alias from src into dst under the
// canonical key, leaving dst untouched when no alias is present.
func listFilesPick(dst, src map[string]any, canonical string, aliases ...string) {
for _, a := range aliases {
if v, ok := src[a]; ok {
dst[canonical] = v
return
}
}
}
// Info → get_file_info
var Info = shortcut.Shortcut{
Service: "drive",
@@ -177,7 +149,15 @@ var Info = shortcut.Shortcut{
if rt.Changed("space-id") {
params["spaceId"] = rt.Str("space-id")
}
return rt.CallMCP("get_file_info", params)
data, err := rt.CallMCPData("drive", "get_file_info", params)
if err != nil {
return err
}
result, err := requireDriveObject(data, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(result)
},
}
@@ -186,20 +166,55 @@ var Download = shortcut.Shortcut{
Service: "drive",
Command: "+download",
Product: "drive",
Description: "获取钉盘文件下载链接",
Intent: "当你需要把钉盘里某个文件下载到本地或转给他人时使用;输入文件的 dentryUuid,返回带签名的临时下载 URL 和请求头,用它去真正拉取文件内容(本命令本身只取链接、不落盘)。",
Description: "安全下载钉盘文件到工作目录",
Intent: "下载普通钉盘文件并要求验证本地字节产物时使用;不是只返回临时 URL。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+download", "安全下载钉盘文件到工作目录",
"下载普通钉盘文件并要求验证本地字节产物时使用;不是只返回临时 URL。",
[]string{"在线文档导出为 docx/pdf 使用 doc +export;只查元数据使用 drive +inspect"},
[]string{`dws drive +download --node <dentryUuid> --output downloads/report.pdf`},
driveObjectResult("已验证的本地下载产物"), nil,
contract.ParamDecl{Name: "node", Property: "fileId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID (dentryUuid)", Required: true},
{Name: "space-id", Type: shortcut.FlagString, Desc: "文件所属空间 ID"},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "工作目录内的相对输出路径", Required: true},
},
Tips: []string{`dws drive +download --node <dentryUuid>`},
Tips: []string{`dws drive +download --node <dentryUuid> --output downloads/report.pdf`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"fileId": rt.Str("node")}
if rt.Changed("space-id") {
params["spaceId"] = rt.Str("space-id")
}
return rt.CallMCP("download_file", params)
data, err := rt.CallMCPData("drive", "download_file", params)
if err != nil {
return err
}
payload, err := requireDriveObject(data, "drive/download_file")
if err != nil {
return err
}
url, preferredName, headers, err := driveDownloadPayload(payload, "drive/download_file")
if err != nil {
return err
}
cwd, err := driveGetwd()
if err != nil {
return err
}
artifact, err := driveDownload(rt.Command().Context(), url, localio.DownloadOptions{
BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferredName, Headers: headers,
})
if err != nil {
return err
}
if artifact.SizeBytes <= 0 {
return driveResponseError("drive/download_file", "empty_download_artifact", "下载完成但本地产物为 0 字节")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "savedPath": artifact.RelativePath, "sizeBytes": artifact.SizeBytes})
},
}
@@ -296,68 +311,24 @@ var Search = shortcut.Shortcut{
if err != nil {
return err
}
files := searchFilesProject(data)
return rt.Output(map[string]any{"count": len(files), "files": files})
items, page, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return err
}
files := projectDriveRows(items, map[string][]string{
"name": {"name", "fileName", "dentryName", "title"},
"type": {"type", "dentryType", "fileType", "spaceType"},
"nodeId": {"fileId", "dentryUuid", "nodeId", "id"},
"dentryId": {"dentryId"},
"fileSize": {"fileSize", "size", "byteSize", "length"},
"creatorId": {"creatorId", "creatorUserId", "creator", "creatorUid"},
})
out := map[string]any{"count": len(files), "files": files}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// searchFilesProject reshapes the raw search_files response into a clean,
// stable 钉盘 file list ({name,type,dentryId,fileSize,creatorId}) — the
// output-projection fidelity applied to every list/search shortcut. Both the
// list container and each field are probed defensively across candidate keys,
// so a missing container or unknown alias yields an empty list rather than a
// fabricated value.
func searchFilesProject(data map[string]any) []map[string]any {
raw := searchFilesContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
searchFilesPick(row, m, "name", "name", "fileName", "dentryName", "title")
searchFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType")
searchFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId")
searchFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length")
searchFilesPick(row, m, "creatorId", "creatorId", "creatorUserId", "creator", "creatorUid")
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// searchFilesContainer locates the file list array inside the response by
// trying the common container keys emitted across drive backends; the payload
// itself may also already wrap the array one level deeper.
func searchFilesContainer(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} {
if v, ok := data[k].([]any); ok {
return v
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} {
if v, ok := inner[ik].([]any); ok {
return v
}
}
}
}
return nil
}
// searchFilesPick copies the first matching alias from src into dst under the
// canonical key, leaving dst untouched when no alias is present.
func searchFilesPick(dst, src map[string]any, canonical string, aliases ...string) {
for _, a := range aliases {
if v, ok := src[a]; ok {
dst[canonical] = v
return
}
}
}
// RecycleList → list_recycle_items
// RecycleRestore → restore_recycle_item
// PublishSet → set_file_publish (published=true)
@@ -413,66 +384,22 @@ var SearchDocs = shortcut.Shortcut{
if err != nil {
return err
}
docs := searchDocsProject(data)
return rt.Output(map[string]any{"count": len(docs), "docs": docs})
items, page, err := requireDriveCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list")
if err != nil {
return err
}
docs := projectDriveRows(items, map[string][]string{
"name": {"name", "title", "docName", "nodeName", "fileName"},
"nodeId": {"nodeId", "id", "docId", "dentryUuid", "fileId"},
"type": {"type", "docType", "nodeType", "fileType"},
"url": {"url", "docUrl", "link", "webUrl"},
})
out := map[string]any{"count": len(docs), "docs": docs}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// searchDocsProject reshapes the raw search_documents response into a clean,
// stable document list ({name,nodeId,type,url}) — the output-projection
// fidelity applied to every list/search shortcut. Both the list container and
// each field are probed defensively across candidate keys, so a missing
// container or unknown alias yields an empty list rather than fabricated data.
func searchDocsProject(data map[string]any) []map[string]any {
raw := searchDocsContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
searchDocsPick(row, m, "name", "name", "title", "docName", "nodeName", "fileName")
searchDocsPick(row, m, "nodeId", "nodeId", "id", "docId", "dentryUuid", "fileId")
searchDocsPick(row, m, "type", "type", "docType", "nodeType", "fileType")
searchDocsPick(row, m, "url", "url", "docUrl", "link", "webUrl")
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// searchDocsContainer locates the document list array inside the response by
// trying the common container keys; the payload may also wrap the array one
// level deeper under a common envelope.
func searchDocsContainer(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "documents", "docs", "nodes"} {
if v, ok := data[k].([]any); ok {
return v
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "documents", "docs", "nodes", "result"} {
if v, ok := inner[ik].([]any); ok {
return v
}
}
}
}
return nil
}
// searchDocsPick copies the first matching alias from src into dst under the
// canonical key, leaving dst untouched when no alias is present.
func searchDocsPick(dst, src map[string]any, canonical string, aliases ...string) {
for _, a := range aliases {
if v, ok := src[a]; ok {
dst[canonical] = v
return
}
}
}
// Delete → delete_document (doc)
// Copy → copy_document (doc)
var Copy = shortcut.Shortcut{
@@ -514,6 +441,17 @@ var Copy = shortcut.Shortcut{
},
Tips: []string{`dws drive +copy --node <nodeId> --folder <targetFolderId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
preflight, err = requireDriveObject(preflight, "doc/get_document_info")
if err != nil {
return err
}
if !isOnlineDriveObject(preflight) {
return driveResponseError("doc/copy_document", "ordinary_file_copy_unsupported", "普通钉盘文件当前没有独立副本接口;doc/copy_document 会生成 .dlink 快捷方式。需要快捷入口请用 +create-shortcut;需要独立副本请先 +download 再 +upload")
}
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("folder") {
params["targetFolderId"] = rt.Str("folder")
@@ -521,7 +459,27 @@ var Copy = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
return rt.CallMCP("copy_document", params)
written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params)
if err != nil {
return err
}
written, err = requireDriveWrite(written, "doc/copy_document")
if err != nil {
return err
}
createdID := nestedString(written, "nodeId", "fileId", "dentryUuid", "id")
if createdID == "" {
return driveResponseError("doc/copy_document", "missing_created_id", "复制响应没有新节点 ID;远端效果未知")
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": createdID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "doc/get_document_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": createdID, "copy": verified})
},
}
@@ -572,7 +530,23 @@ var Move = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
return rt.CallMCP("move_document", params)
written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params)
if err != nil {
return err
}
written, err = requireDriveWrite(written, "doc/move_document")
if err != nil {
return err
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "doc/get_document_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
},
}
@@ -647,65 +621,57 @@ var Recent = shortcut.Shortcut{
if err != nil {
return err
}
return rt.Output(recentListProject(data))
items, page, err := requireDriveCollection(data, "doc/get_recent_list", "recentItems")
if err != nil {
return err
}
rows := projectDriveRows(items, map[string][]string{
"name": {"name"},
"nodeType": {"nodeType"},
"contentType": {"contentType"},
"accessTime": {"accessTime"},
"docUrl": {"docUrl"},
"nodeId": {"nodeId"},
})
out := map[string]any{"count": len(rows), "items": rows}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// recentListProject reshapes a get_recent_list response into a clean paginated
// document list, dropping transport noise (logId) while keeping the pagination
// cursor.
func recentListProject(data map[string]any) map[string]any {
// get_recent_list nests its payload under result.recentItems; earlier this
// read data["recentItems"] at the top level only, so the whole list silently
// projected to empty. Resolve the payload container (top-level or one level
// under result/data) before reading recentItems / pagination fields.
payload := data
if inner, ok := recentListPayload(data); ok {
payload = inner
}
items := []map[string]any{}
raw, _ := payload["recentItems"].([]any)
for _, it := range raw {
m, ok := it.(map[string]any)
if !ok {
continue
}
items = append(items, map[string]any{
"name": m["name"],
"nodeType": m["nodeType"],
"contentType": m["contentType"],
"accessTime": m["accessTime"],
"docUrl": m["docUrl"],
"nodeId": m["nodeId"],
})
}
out := map[string]any{"count": len(items), "items": items}
if nc, ok := payload["nextCursor"]; ok && nc != nil {
out["nextCursor"] = nc
}
if hm, ok := payload["hasMore"]; ok {
out["hasMore"] = hm
}
return out
}
// recentListPayload returns the map that actually holds recentItems, tolerating
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
func recentListPayload(data map[string]any) (map[string]any, bool) {
if _, ok := data["recentItems"]; ok {
return data, true
}
for _, k := range []string{"result", "data"} {
if inner, ok := data[k].(map[string]any); ok {
if _, ok := inner["recentItems"]; ok {
return inner, true
}
}
}
return nil, false
}
func init() {
Copy.Description = "复制在线文档到指定位置并读回验证"
Copy.Intent = "复制钉钉在线文档到新位置并保留原件时使用;先验证对象类型,普通文件不伪装成复制成功。"
Copy.Contract = driveContract(
"+copy", Copy.Description,
"复制钉钉在线文档到新位置并保留原件时使用;先验证对象类型,普通文件不伪装成复制成功。",
[]string{"普通钉盘文件独立复制当前无等价接口:需要快捷入口用 +create-shortcut,需要独立字节副本用 +download 后 +upload;移动原件用 +move"},
[]string{`dws drive +copy --node <ONLINE_DOC_ID> --folder <TARGET_FOLDER_ID>`},
driveObjectResult("在线文档复制并读回后的新节点"), nil,
// Preserve the historical public Schema properties. Execute translates
// these CLI concepts to nodeId/targetFolderId/workspaceId for the RPC.
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "folder", Property: "folder"},
contract.ParamDecl{Name: "workspace", Property: "workspace"},
)
Copy.Tips = []string{`dws drive +copy --node <ONLINE_DOC_ID> --folder <TARGET_FOLDER_ID>`}
Info.Contract.Result = driveObjectResult("钉盘节点元数据")
Search.Contract.Result = driveCollectionResult("files", "严格校验并投影的钉盘搜索结果页")
Search.Contract.Pagination = driveCursorPagination()
SearchDocs.Contract.Result = driveCollectionResult("docs", "兼容入口的在线文档搜索结果页")
Copy.Contract.Result = driveObjectResult("复制操作的终态证据")
Move.Contract.Result = driveObjectResult("移动操作的终态证据")
Recent.Contract.Result = driveCollectionResult("items", "严格校验的最近访问或编辑结果页")
Recent.Contract.Pagination = driveCursorPagination()
for _, declaration := range []*shortcut.Shortcut{
&List, &Info, &Download, &Search, &SearchDocs, &Copy, &Move, &Recent,
&Inspect, &Upload, &CreateFolder, &CreateShortcut, &Rename, &Delete, &Stats,
&RecycleList, &RecycleRestore, &StarList, &StarAdd, &StarRemove,
&PublishGet, &PublishSet, &PublishUnset, &Cover,
&VersionHistory, &VersionGet, &VersionDownload, &VersionRevert,
} {
declaration.OutputRollout = output.RolloutUnifiedActive
}
shortcut.Register(
List,
Info,
@@ -715,5 +681,35 @@ func init() {
Copy,
Move,
Recent,
Inspect,
Upload,
CreateFolder,
CreateShortcut,
Rename,
Delete,
Stats,
RecycleList,
RecycleRestore,
StarList,
StarAdd,
StarRemove,
PublishGet,
PublishSet,
PublishUnset,
Cover,
VersionHistory,
VersionGet,
VersionDownload,
VersionRevert,
)
}
func isOnlineDriveObject(info map[string]any) bool {
extension := strings.ToLower(firstString(info, "extension", "fileExtension", "ext"))
switch extension {
case "adoc", "axls", "able", "amind", "adraw":
return true
}
contentType := strings.ToUpper(firstString(info, "contentType", "docType"))
return contentType == "DOC" || contentType == "SHEET" || contentType == "TABLE" || contentType == "MIND" || contentType == "DRAW"
}
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"fmt"
"strings"
"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 Inspect = shortcut.Shortcut{
Service: "drive", Command: "+inspect", Product: "drive",
Description: "聚合检查节点元数据及可选统计、公开状态和封面",
Intent: "已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+inspect", "聚合检查节点元数据及可选统计、公开状态和封面",
"已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
[]string{"读取在线文档正文使用 doc +fetch;浏览目录使用 drive +list"},
[]string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
driveObjectResult("Drive 节点聚合检查结果"), nil,
contract.ParamDecl{Name: "node", Property: "fileId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "include-stats", Type: shortcut.FlagBool, Desc: "附带阅读、编辑、下载等统计"},
{Name: "include-publish", Type: shortcut.FlagBool, Desc: "附带互联网公开状态"},
{Name: "include-cover", Type: shortcut.FlagBool, Desc: "附带封面或缩略图地址"},
},
Tips: []string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
params := map[string]any{"fileId": node}
if rt.Str("space-id") != "" {
params["spaceId"] = rt.Str("space-id")
}
data, err := rt.CallMCPData("drive", "get_file_info", params)
if err != nil {
return err
}
info, err := requireDriveObject(data, "drive/get_file_info")
if err != nil {
return err
}
result := map[string]any{"file": info}
steps := []map[string]any{{"tool": "get_file_info", "status": "success"}}
reads := []struct {
flag, key, tool string
params map[string]any
}{
{"include-stats", "stats", "get_node_stats", map[string]any{"nodeId": node}},
{"include-publish", "publish", "get_file_publish_status", map[string]any{"fileId": node}},
{"include-cover", "cover", "get_cover", map[string]any{"nodeId": node}},
}
failures := []map[string]any{}
for _, read := range reads {
if !rt.Bool(read.flag) {
continue
}
value, callErr := rt.CallMCPReadData("drive", read.tool, read.params)
if callErr == nil {
value, callErr = requireDriveObject(value, "drive/"+read.tool)
}
if callErr != nil {
steps = append(steps, map[string]any{"tool": read.tool, "status": "failed"})
failures = append(failures, map[string]any{"tool": read.tool, "error": callErr.Error()})
continue
}
result[read.key] = value
steps = append(steps, map[string]any{"tool": read.tool, "status": "success"})
}
if len(failures) > 0 {
return apperrors.NewAPI("Drive 聚合检查只完成了部分读取",
apperrors.WithOperation("drive.inspect"),
apperrors.WithReason("drive_inspect_partial"),
apperrors.WithFailureStage("optional_reads"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithDetails(map[string]any{"status": "partial_success", "complete": false, "data": result, "steps": steps, "failures": failures}),
)
}
return rt.Output(map[string]any{"status": "success", "complete": true, "data": result, "steps": steps})
},
}
var CreateFolder = shortcut.Shortcut{
Service: "drive", Command: "+create-folder", Product: "drive",
Description: "创建钉盘文件夹并读回验证",
Intent: "在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: driveContract(
"+create-folder", "创建钉盘文件夹并读回验证",
"在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
[]string{"知识库目录使用 wiki node create;创建在线文档使用 doc +create"},
[]string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
driveObjectResult("创建并验证后的文件夹"), nil,
contract.ParamDecl{Name: "folder", Property: "parentId"},
),
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "文件夹名称", Required: true},
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
},
Tips: []string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"name": rt.Str("name")}
if rt.Str("space-id") != "" {
params["spaceId"] = rt.Str("space-id")
}
if rt.Str("folder") != "" {
params["parentId"] = rt.Str("folder")
}
created, err := rt.CallMCPWriteDataStrict("drive", "create_folder", params)
if err != nil {
return err
}
created, err = requireDriveWrite(created, "drive/create_folder")
if err != nil {
return err
}
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
if nodeID == "" {
return driveResponseError("drive/create_folder", "missing_created_id", "创建响应没有文件夹 fileId;远端效果未知")
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
if name := firstString(verified, "name", "fileName"); name != rt.Str("name") {
return driveResponseError("drive/create_folder", "readback_mismatch", fmt.Sprintf("创建后读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "folder": verified})
},
}
var CreateShortcut = shortcut.Shortcut{
Service: "drive", Command: "+create-shortcut", Product: "drive",
Description: "为已有节点创建快捷方式并验证新节点",
Intent: "给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
Contract: driveContract(
"+create-shortcut", "为已有节点创建快捷方式并验证新节点",
"给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
[]string{"需要独立副本使用 drive +copy;需要迁移原节点使用 drive +move"},
[]string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
driveObjectResult("创建并验证后的快捷方式节点"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "folder", Property: "targetFolderId"},
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Tips: []string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Str("folder") != "" {
params["targetFolderId"] = rt.Str("folder")
}
if rt.Str("workspace") != "" {
params["workspaceId"] = rt.Str("workspace")
}
created, err := rt.CallMCPWriteDataStrict("drive", "create_shortcut", params)
if err != nil {
return err
}
created, err = requireDriveWrite(created, "drive/create_shortcut")
if err != nil {
return err
}
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
if nodeID == "" {
return driveResponseError("drive/create_shortcut", "missing_created_id", "创建快捷方式未返回新节点 ID")
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "shortcut": verified})
},
}
var Rename = shortcut.Shortcut{
Service: "drive", Command: "+rename", Product: "doc",
Description: "重命名文件或文件夹并读回验证",
Intent: "改变已有节点名称并要求验证最终名称时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+rename", "重命名文件或文件夹并读回验证",
"改变已有节点名称并要求验证最终名称时使用。",
[]string{"改变位置使用 drive +move;替换文件内容使用 drive +upload --node"},
[]string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
driveObjectResult("重命名后的节点元数据"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "name", Property: "newName"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新名称", Required: true},
},
Tips: []string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
Execute: func(rt *shortcut.RuntimeContext) error {
preflight, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
preflight, err = requireDriveObject(preflight, "drive/get_file_info")
if err != nil {
return err
}
requestName, expectedNames := normalizedDriveRename(rt.Str("name"), preflight)
written, err := rt.CallMCPWriteDataStrict("doc", "rename_document", map[string]any{"nodeId": rt.Str("node"), "newName": requestName})
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "doc/rename_document"); err != nil {
return err
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
name := firstString(verified, "name", "fileName")
if name == "" || !expectedNames[name] {
return driveResponseError("doc/rename_document", "readback_mismatch", fmt.Sprintf("重命名读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
},
}
func normalizedDriveRename(name string, info map[string]any) (string, map[string]bool) {
name = strings.TrimSpace(name)
expected := map[string]bool{name: true}
nodeType := strings.ToLower(firstString(info, "type", "nodeType", "fileType"))
if nodeType == "folder" || nodeType == "dir" || nodeType == "directory" {
return name, expected
}
extension := strings.TrimLeft(strings.ToLower(firstString(info, "extension", "fileExtension", "ext")), ".")
if extension == "" {
return name, expected
}
suffix := "." + extension
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
return name[:len(name)-len(suffix)], expected
}
expected[name+suffix] = true
return name, expected
}
var Delete = shortcut.Shortcut{
Service: "drive", Command: "+delete", Product: "doc",
Description: "将已确认节点移入回收站",
Intent: "用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+delete", "将已确认节点移入回收站",
"用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
[]string{"只是调整位置使用 drive +move;目标不明确时先 drive +inspect"},
[]string{`dws drive +delete --node <dentryUuid>`},
driveObjectResult("删除到回收站的终态证据"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
Tips: []string{`dws drive +delete --node <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
written, err = requireDriveWrite(written, "doc/delete_document")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
},
}
var Stats = driveReadObjectShortcut(
"+stats", "读取节点访问和协作统计", "get_node_stats", "nodeId",
"用户要查看指定节点阅读、编辑、评论、点赞、预览或下载统计时使用。",
[]string{"只要文件名称和类型使用 drive +inspect"},
)
var Cover = driveReadObjectShortcut(
"+cover", "读取节点封面或缩略图地址", "get_cover", "nodeId",
"用户需要节点封面、首图或缩略图 URL 时使用。",
[]string{"这是封面资源,不等于 Lark 服务端多格式预览转换"},
)
func driveReadObjectShortcut(command, description, tool, property, useWhen string, avoidWhen []string) shortcut.Shortcut {
return shortcut.Shortcut{
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(command, description, useWhen, avoidWhen,
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
driveObjectResult(description), nil, contract.ParamDecl{Name: "node", Property: property}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("drive", tool, map[string]any{property: rt.Str("node")})
if err != nil {
return err
}
data, err = requireDriveObject(data, "drive/"+tool)
if err != nil {
return err
}
return rt.Output(data)
},
}
}
@@ -1,66 +0,0 @@
// 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 drive
import (
"encoding/json"
"testing"
)
// TestRecentListProjectResultWrapper guards against projection-data-loss:
// get_recent_list nests its payload under result.recentItems; the projection
// must descend into result or +recent silently returns empty despite backend
// records.
func TestRecentListProjectResultWrapper(t *testing.T) {
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
{"name":"weekly report","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
{"name":"budget sheet","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
out := recentListProject(data)
if got, _ := out["count"].(int); got != 2 {
t.Fatalf("lower/upper mismatch: result.recentItems has 2 entries, projection count=%v (%v)", out["count"], out)
}
if out["hasMore"] != true || out["nextCursor"] != "c2" {
t.Fatalf("pagination fields lost from result wrapper: %v", out)
}
}
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
// found under any wrapper), which must yield an empty list, not a panic.
func TestRecentListProjectNoItems(t *testing.T) {
const raw = `{"result":{"totalCount":0},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 0 {
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
}
}
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
func TestRecentListProjectTopLevel(t *testing.T) {
const raw = `{"recentItems":[{"name":"weekly report","nodeId":"n1"}],"hasMore":false}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 1 {
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
}
}
@@ -0,0 +1,292 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"fmt"
"strings"
"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"
)
var VersionHistory = shortcut.Shortcut{
Service: "drive", Command: "+version-history", Product: "drive",
Description: "严格分页列出普通文件历史版本",
Intent: "普通文件需要浏览历史版本并获取明确版本号时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+version-history", "严格分页列出普通文件历史版本",
"普通文件需要浏览历史版本并获取明确版本号时使用。",
[]string{"在线文档版本使用 doc +version-list;在线表格版本使用 sheet version"},
[]string{`dws drive +version-history --node <dentryUuid> --limit 20`},
driveCollectionResult("versions", "严格校验的普通文件历史版本页"), driveCursorPagination(),
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "cursor", Property: "nextCursor"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
},
Tips: []string{`dws drive +version-history --node <dentryUuid> --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
page, err := versionPage(rt, rt.Str("node"), rt.Int("limit"), rt.Str("cursor"))
if err != nil {
return err
}
return rt.Output(page)
},
}
var VersionGet = shortcut.Shortcut{
Service: "drive", Command: "+version-get", Product: "drive",
Description: "按版本号精确读取普通文件版本元数据",
Intent: "已知普通文件版本号,需要确认该版本存在并读取其元数据时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+version-get", "按版本号精确读取普通文件版本元数据",
"已知普通文件版本号,需要确认该版本存在并读取其元数据时使用。",
[]string{"尚未确定版本号时使用 drive +version-history;需要版本字节使用 drive +version-download"},
[]string{`dws drive +version-get --node <dentryUuid> --version 3`},
driveObjectResult("精确匹配的普通文件版本元数据"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("version") <= 0 {
return fmt.Errorf("--version 必须为正整数")
}
return nil
},
Tips: []string{`dws drive +version-get --node <dentryUuid> --version 3`},
Execute: func(rt *shortcut.RuntimeContext) error {
version, err := findVersion(rt, rt.Str("node"), rt.Int("version"))
if err != nil {
return err
}
return rt.Output(map[string]any{"nodeId": rt.Str("node"), "version": version})
},
}
var VersionDownload = shortcut.Shortcut{
Service: "drive", Command: "+version-download", Product: "drive",
Description: "安全下载普通文件指定历史版本",
Intent: "已确认普通文件版本号,需要安全落盘并验证实际字节时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+version-download", "安全下载普通文件指定历史版本",
"已确认普通文件版本号,需要安全落盘并验证实际字节时使用。",
[]string{"下载最新版本使用 drive +download;在线文档版本使用 doc +version-list/+export"},
[]string{`dws drive +version-download --node <dentryUuid> --version 3 --output downloads/report-v3.pdf`},
driveObjectResult("已验证的历史版本本地产物"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内相对输出路径", Required: true},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("version") <= 0 {
return fmt.Errorf("--version 必须为正整数")
}
return nil
},
Tips: []string{`dws drive +version-download --node <dentryUuid> --version 3 --output downloads/report-v3.pdf`},
Execute: func(rt *shortcut.RuntimeContext) error {
if _, err := findVersion(rt, rt.Str("node"), rt.Int("version")); err != nil {
return err
}
data, err := rt.CallMCPData("drive", "download_file_version", map[string]any{"nodeId": rt.Str("node"), "version": rt.Int("version")})
if err != nil {
return err
}
payload, err := requireDriveObject(data, "drive/download_file_version")
if err != nil {
return err
}
url, name, headers, err := driveDownloadPayload(payload, "drive/download_file_version")
if err != nil {
return err
}
cwd, err := driveGetwd()
if err != nil {
return err
}
artifact, err := driveDownload(rt.Command().Context(), url, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: name, Headers: headers})
if err != nil {
return err
}
if artifact.SizeBytes <= 0 {
return driveResponseError("drive/download_file_version", "empty_download_artifact", "历史版本下载产物为 0 字节")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "version": rt.Int("version"), "savedPath": artifact.RelativePath, "sizeBytes": artifact.SizeBytes})
},
}
var VersionRevert = shortcut.Shortcut{
Service: "drive", Command: "+version-revert", Product: "drive",
Description: "预检并回滚普通文件到指定历史版本",
Intent: "用户明确选定普通文件历史版本并要求回滚时使用。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"},
Contract: driveContract(
"+version-revert", "预检并回滚普通文件到指定历史版本",
"用户明确选定普通文件历史版本并要求回滚时使用。",
[]string{"尚未确认版本号时先 drive +version-history;在线文档使用 doc +version-revert"},
[]string{`dws drive +version-revert --node <dentryUuid> --version 3`},
driveObjectResult("版本回滚终态及节点读回"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("version") <= 0 {
return fmt.Errorf("--version 必须为正整数")
}
return nil
},
Tips: []string{`dws drive +version-revert --node <dentryUuid> --version 3`},
Execute: func(rt *shortcut.RuntimeContext) error {
if _, err := findVersion(rt, rt.Str("node"), rt.Int("version")); err != nil {
return err
}
written, err := rt.CallMCPWriteDataStrict("drive", "revert_file_version", map[string]any{"nodeId": rt.Str("node"), "version": rt.Int("version")})
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "drive/revert_file_version"); err != nil {
return err
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "revertedTo": rt.Int("version"), "file": verified})
},
}
func versionPage(rt *shortcut.RuntimeContext, nodeID string, limit int, cursor string) (map[string]any, error) {
params := map[string]any{"nodeId": nodeID, "maxResults": limit}
if cursor != "" {
params["nextCursor"] = cursor
}
data, err := rt.CallMCPData("drive", "list_file_versions", params)
if err != nil {
return nil, err
}
items, page, err := requireDriveCollection(data, "drive/list_file_versions", "versions", "versionList", "items")
if err != nil {
return nil, err
}
versions := projectDriveRows(items, map[string][]string{
"version": {"version", "versionNumber", "versionNum"},
"createTime": {"createTime", "createdTime"},
"creatorId": {"creatorId", "creatorUserId", "operatorId"},
"fileSize": {"fileSize", "size"},
"name": {"name", "fileName"},
})
out := map[string]any{"count": len(versions), "versions": versions}
addDrivePagination(out, page)
return out, nil
}
func findVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (map[string]any, error) {
const maxPages = 20
cursor := ""
seenCursors := map[string]bool{}
for pageNumber := 1; pageNumber <= maxPages; pageNumber++ {
page, err := versionPage(rt, nodeID, 50, cursor)
if err != nil {
return nil, err
}
for _, version := range page["versions"].([]map[string]any) {
if number, ok := versionNumber(version); ok && number == target {
return version, nil
}
}
nextCursor := strings.TrimSpace(nestedString(page, "nextCursor", "nextToken", "nextPageToken"))
hasMore, hasMoreKnown := boolField(page, "hasMore")
if hasMoreKnown && !hasMore {
return nil, driveResponseError("drive/list_file_versions", "version_not_found", fmt.Sprintf("完整版本历史中不存在版本 %d", target))
}
if nextCursor == "" {
if hasMoreKnown && hasMore {
return nil, versionPaginationError("missing_next_cursor", pageNumber, cursor)
}
return nil, driveResponseError("drive/list_file_versions", "version_not_found", fmt.Sprintf("完整版本历史中不存在版本 %d", target))
}
if seenCursors[nextCursor] {
return nil, versionPaginationError("stalled_cursor", pageNumber, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
return nil, versionPaginationError("max_pages", maxPages, cursor)
}
func versionPaginationError(reason string, page int, cursor string) error {
return driveResponseError(
"drive/list_file_versions",
"version_pagination_"+reason,
fmt.Sprintf("版本预检无法证明分页已经完整,已停止操作(page=%d, cursor=%q)", page, cursor),
)
}
func versionNumber(version map[string]any) (int, bool) {
switch value := version["version"].(type) {
case float64:
return int(value), value == float64(int(value))
case int:
return value, true
case string:
var number int
if _, err := fmt.Sscanf(value, "%d", &number); err == nil {
return number, true
}
}
return 0, false
}
func driveDownloadPayload(payload map[string]any, operation string) (string, string, map[string]string, error) {
url := firstString(payload, "downloadUrl", "resourceUrl", "url")
if url == "" {
if resources, ok := payload["resourceUrls"].([]any); ok && len(resources) > 0 {
if first, ok := resources[0].(map[string]any); ok {
url = firstString(first, "url", "downloadUrl", "resourceUrl")
payload = first
}
}
}
if url == "" {
return "", "", nil, driveResponseError(operation, "missing_download_url", "下载响应没有有效 URL")
}
headers := map[string]string{}
if raw, ok := payload["headers"].(map[string]any); ok {
for key, value := range raw {
if text, ok := value.(string); ok {
headers[key] = text
}
}
}
return url, firstString(payload, "fileName", "name"), headers, nil
}
@@ -318,12 +318,33 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"doc\u0000+version-revert": {},
"doc\u0000+version-save": {},
"drive\u0000+copy": {},
"drive\u0000+cover": {},
"drive\u0000+create-folder": {},
"drive\u0000+create-shortcut": {},
"drive\u0000+delete": {},
"drive\u0000+download": {},
"drive\u0000+find-file": {},
"drive\u0000+info": {},
"drive\u0000+inspect": {},
"drive\u0000+list": {},
"drive\u0000+move": {},
"drive\u0000+publish-get": {},
"drive\u0000+publish-unset": {},
"drive\u0000+recent": {},
"drive\u0000+recycle-list": {},
"drive\u0000+recycle-restore": {},
"drive\u0000+rename": {},
"drive\u0000+search": {},
"drive\u0000+search-docs": {},
"drive\u0000+star-add": {},
"drive\u0000+star-list": {},
"drive\u0000+star-remove": {},
"drive\u0000+stats": {},
"drive\u0000+upload": {},
"drive\u0000+version-download": {},
"drive\u0000+version-get": {},
"drive\u0000+version-history": {},
"drive\u0000+version-revert": {},
"mail\u0000+contact-list": {},
"mail\u0000+find-mail-user": {},
"mail\u0000+folder-list": {},
+4
View File
@@ -22,6 +22,9 @@ var aitableSemanticCatalogJSON []byte
//go:embed semantic_catalog_minutes.json
var minutesSemanticCatalogJSON []byte
//go:embed semantic_catalog_drive.json
var driveSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -44,6 +47,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
docSemanticCatalogJSON,
aitableSemanticCatalogJSON,
minutesSemanticCatalogJSON,
driveSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,39 @@
{
"version": 1,
"service": "drive",
"default_availability": "available",
"shortcuts": {
"+list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。","risk":"read","public":true,"reviewed":true},
"+inspect": {"disposition":"primary_smart","semantic_delta":"以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。","risk":"read","public":true,"reviewed":true},
"+info": {"disposition":"alias_internal","semantic_delta":"保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。","risk":"read","primary":"+inspect","public":true,"reviewed":true},
"+search": {"disposition":"semantic_adapter","semantic_delta":"统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。","risk":"read","public":true,"reviewed":true},
"+find-file": {"disposition":"alias_internal","semantic_delta":"保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+search-docs": {"disposition":"alias_internal","semantic_delta":"保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。","risk":"read","primary":"doc +search","public":true,"reviewed":true},
"+recent": {"disposition":"semantic_adapter","semantic_delta":"严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。","risk":"read","public":true,"reviewed":true},
"+upload": {"disposition":"primary_smart","semantic_delta":"组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。","risk":"write","public":true,"reviewed":true},
"+download": {"disposition":"primary_smart","semantic_delta":"不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。","risk":"read","public":true,"reviewed":true},
"+create-folder": {"disposition":"primary_smart","semantic_delta":"创建普通钉盘文件夹后要求 fileId,并读回名称验证。","risk":"write","public":true,"reviewed":true},
"+create-shortcut": {"disposition":"primary_smart","semantic_delta":"明确 shortcut 与 copy 语义差异,创建后读取新节点验证。","risk":"write","public":true,"reviewed":true},
"+copy": {"disposition":"primary_smart","semantic_delta":"复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。","risk":"write","public":true,"reviewed":true},
"+move": {"disposition":"semantic_adapter","semantic_delta":"移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。","risk":"write","public":true,"reviewed":true},
"+rename": {"disposition":"primary_smart","semantic_delta":"重命名后读取真实节点元数据验证最终名称。","risk":"write","public":true,"reviewed":true},
"+delete": {"disposition":"semantic_adapter","semantic_delta":"将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true},
"+stats": {"disposition":"schema_leaf","semantic_delta":"读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。","risk":"read","public":true,"reviewed":true},
"+cover": {"disposition":"schema_leaf","semantic_delta":"读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。","risk":"read","public":true,"reviewed":true},
"+recycle-list": {"disposition":"semantic_adapter","semantic_delta":"严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。","risk":"read","public":true,"reviewed":true},
"+recycle-restore": {"disposition":"primary_smart","semantic_delta":"只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。","risk":"write","public":true,"reviewed":true},
"+star-list": {"disposition":"semantic_adapter","semantic_delta":"严格分页列出当前用户收藏并保留游标。","risk":"read","public":true,"reviewed":true},
"+star-add": {"disposition":"schema_leaf","semantic_delta":"以幂等用户偏好语义收藏指定节点。","risk":"write","public":true,"reviewed":true},
"+star-remove": {"disposition":"schema_leaf","semantic_delta":"以幂等用户偏好语义取消收藏指定节点。","risk":"write","public":true,"reviewed":true},
"+publish-get": {"disposition":"schema_leaf","semantic_delta":"只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。","risk":"read","public":true,"reviewed":true},
"+publish-set": {"disposition":"primary_smart","semantic_delta":"实现了高风险确认和读回,但真实普通文件与在线文档夹具均返回 operation.notSupported;在获得可验证的 eligible 节点前不公开。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
"+publish-unset": {"disposition":"primary_smart","semantic_delta":"高风险确认后关闭互联网公开,并读回状态验证外链已失效。","risk":"high-risk-write","public":true,"reviewed":true},
"+version-history": {"disposition":"semantic_adapter","semantic_delta":"严格分页列出普通文件版本,区分合法空历史与响应契约错误。","risk":"read","public":true,"reviewed":true},
"+version-get": {"disposition":"primary_smart","semantic_delta":"按正整数版本号精确匹配元数据;零命中显式失败。","risk":"read","public":true,"reviewed":true},
"+version-download": {"disposition":"primary_smart","semantic_delta":"预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。","risk":"read","public":true,"reviewed":true},
"+version-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。","risk":"high-risk-write","public":true,"reviewed":true}
}
}
+1
View File
@@ -23,6 +23,7 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json",
]
+2 -2
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", "doc"}
COMPACT_PRODUCT_SERVICES = {"chat", "doc", "drive"}
def md_escape(value: Any) -> str:
@@ -170,7 +170,7 @@ def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
# 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":
if service in {"doc", "drive"}:
discovery = """已知意图按下方路由。"""
else:
discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。"""
+11
View File
@@ -119,6 +119,17 @@ content-only)
fi
exit 1
fi
if ! awk -F ' ' '
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { next }
$1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next }
$1 == "A" && $2 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next }
$1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && $3 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 3 { next }
{ invalid = 1 }
END { exit invalid }
' "$TMP_ROOT/name-status"; then
printf '%s\n' 'error: CHANGELOG.md may accompany only release-fragment archival; ordinary PRs must add .changes/<unique-name>.md without editing CHANGELOG.md' >&2
exit 1
fi
;;
esac
+191
View File
@@ -0,0 +1,191 @@
#!/bin/sh
set -eu
# Fragment names are matched with ASCII ranges, so keep collation deterministic.
LC_ALL=C
export LC_ALL
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
usage() { printf '%s\n' 'usage: check-release-fragments.sh BASE HEAD' >&2; }
[ "$#" -eq 2 ] || { usage; exit 2; }
base="$(git -C "$ROOT" rev-parse --verify --quiet "$1^{commit}")" || { usage; exit 2; }
head="$(git -C "$ROOT" rev-parse --verify --quiet "$2^{commit}")" || { usage; exit 2; }
merge_base="$(git -C "$ROOT" merge-base "$base" "$head")"
tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-fragment-policy.XXXXXX")"
cleanup() { rm -rf "$tmp_root"; }
trap cleanup EXIT HUP INT TERM
git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status"
archive_changed=false
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then
archive_changed=true
fi
if [ "$archive_changed" = true ]; then
release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2
exit 1
}
# The archive directory is matched as a literal prefix, never as a regex:
# interpolating the version into one would make `.` match any character, so
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
# the documented audit trail. Only the fragment basename, whose character class
# is fixed, stays a pattern.
if ! awk -F '\t' -v prefix=".changes/released/$release_version/" '
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { changelog = 1; next }
$1 == "R100" && NF == 3 && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && index($3, prefix) == 1 {
target = substr($3, length(prefix) + 1)
if (target !~ /^[a-z0-9][a-z0-9._-]*\.md$/) { invalid = 1; next }
source = $2; sub(/^.*\//, "", source)
if (source != target) invalid = 1
moved++; next
}
{ invalid = 1 }
END { exit !(changelog && moved > 0 && !invalid) }
' "$tmp_root/status"; then
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal PR' >&2
exit 1
fi
source_changes="$tmp_root/source-changes"
mkdir -p "$source_changes"
git -C "$ROOT" ls-tree -r --name-only "$merge_base" -- .changes |
while IFS= read -r path; do
case "$path" in
.changes/*.md)
name="${path#.changes/}"
mkdir -p "$(dirname "$source_changes/$name")"
git -C "$ROOT" show "$merge_base:$path" >"$source_changes/$name"
;;
esac
done
"$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes"
git -C "$ROOT" show "$head:CHANGELOG.md" |
awk -v heading="## [$release_version] - " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' >"$tmp_root/actual-notes"
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
{ started = 1; lines[++count] = $0 }
END {
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
}
' "$1"
}
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then
printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2
diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true
exit 1
fi
else
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) invalid = 1 } END { exit !invalid }' "$tmp_root/status"; then
printf '%s\n' 'error: archived release fragments are immutable outside their release-seal PR' >&2
exit 1
fi
if awk -F '\t' '
$1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ { invalid = 1 }
$1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ { invalid = 1 }
END { exit !invalid }
' "$tmp_root/status"; then
printf '%s\n' 'error: active release fragments may be deleted or renamed only by the matching release-seal archival move' >&2
exit 1
fi
fi
# `.changes/released/**` carries its own immutability and release-seal checks
# above, so every other `.changes` change must revalidate the top-level tree.
# This trigger must not be narrowed to single-level paths or to the legal
# fragment name pattern: git records no diff entry for a directory itself, so
# adding `.changes/foo/bar.md` only ever shows the nested path, and an illegally
# named or non-regular entry only ever shows its own path. Either one would skip
# validation here and then break the next unrelated PR that adds a legal
# fragment.
git -C "$ROOT" diff --no-ext-diff --name-only "$merge_base" "$head" -- .changes >"$tmp_root/changes-paths"
changes_tree_changed=false
if awk '
$0 ~ /^\.changes\/released\// { next }
{ found = 1 }
END { exit !found }
' "$tmp_root/changes-paths"; then
changes_tree_changed=true
fi
# Re-rendering is driven by fragment changes only. `.changes/README.md` is the
# contributor contract rather than release content, and it may be edited while no
# fragment is pending, which the renderer would reject as an empty fragment set.
fragment_changed=false
if awk '
$0 ~ /^\.changes\/released\// { next }
$0 == ".changes/README.md" { next }
{ found = 1 }
END { exit !found }
' "$tmp_root/changes-paths"; then
fragment_changed=true
fi
if [ "$changes_tree_changed" = true ]; then
# `.changes` itself must stay a directory: replacing it with a blob or a
# symlink leaves the child listing below empty, which would report no invalid
# entries while silently discarding every fragment.
changes_root_type="$(git -C "$ROOT" ls-tree "$head" -- .changes | awk 'NR == 1 { print $2 }')"
if [ "$changes_root_type" != tree ]; then
printf '%s\n' 'error: .changes must remain a directory holding README.md, released/, and release fragments' >&2
exit 1
fi
git -C "$ROOT" ls-tree "$head" -- .changes/ >"$tmp_root/head-entries"
awk '
{
mode = $1
type = $2
path = $0
sub(/^[^\t]*\t/, "", path)
name = path
sub(/^.*\//, "", name)
if (path == ".changes/README.md") {
if (mode == "100644" && type == "blob") next
print path
next
}
if (path == ".changes/released") {
if (type == "tree") next
print path
next
}
if (mode != "100644" || type != "blob") { print path; next }
if (name !~ /^[a-z0-9][a-z0-9._-]*\.md$/) { print path; next }
}
' "$tmp_root/head-entries" >"$tmp_root/invalid-entries"
if [ -s "$tmp_root/invalid-entries" ]; then
printf '%s\n' 'error: .changes/ accepts only README.md, released/, and release fragments named <name>.md matching ^[a-z0-9][a-z0-9._-]*\.md$ stored as regular 100644 files' >&2
sed 's/^/ /' "$tmp_root/invalid-entries" >&2
exit 1
fi
# Only an ordinary fragment change is re-rendered here: a release-seal branch
# is already held to the stricter rendered-notes comparison above and its
# fragments have moved into the archive, and a README-only edit carries no
# release content to render.
if [ "$fragment_changed" = true ] && [ "$archive_changed" = false ]; then
head_changes="$tmp_root/head-changes"
mkdir -p "$head_changes"
awk '{ path = $0; sub(/^[^\t]*\t/, "", path); print path }' "$tmp_root/head-entries" |
while IFS= read -r path; do
case "$path" in
.changes/released) continue ;;
esac
git -C "$ROOT" show "$head:$path" >"$head_changes/${path#.changes/}"
done
"$ROOT/scripts/release/render-release-fragments.sh" "$head_changes" >/dev/null
fi
fi
+19 -8
View File
@@ -10,6 +10,7 @@ VERSION="${2:-}"
FROM_BETA=""
FROM_REF=""
CHANGELOG="$ROOT/CHANGELOG.md"
CHANGES_DIR="$ROOT/.changes"
usage() {
cat >&2 <<'EOF'
@@ -19,6 +20,7 @@ Options:
--from-beta <tag> Required for stable release notes
--from-ref <ref> Commit-list baseline for prerelease notes
--changelog <path> Override CHANGELOG.md path
--changes-dir <path> Override release fragment directory
EOF
}
@@ -29,6 +31,7 @@ while [ "$#" -gt 0 ]; do
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
--from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;;
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
--changes-dir) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGES_DIR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
@@ -60,21 +63,19 @@ fi
release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
cleanup() { rm -f "$section" "$output"; }
fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")"
cleanup() { rm -f "$section" "$output" "$fragments"; }
trap cleanup EXIT HUP INT TERM
{
printf '## [%s] - %s\n\n' "$semver" "$release_date"
if [ "$CHANNEL" = "stable" ]; then
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
else
printf '<!-- Summarize what this beta validates. Remove every TODO before publishing. -->\n\n'
fi
printf '### Changed\n\n'
if [ "$CHANNEL" = "stable" ]; then
printf '### Changed\n\n'
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
else
printf -- '- TODO: summarize this beta candidate and its validation scope.\n'
"$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
cat "$fragments"
fi
} > "$section"
@@ -105,7 +106,17 @@ fi
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
cp "$output" "$CHANGELOG"
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
if [ "$CHANNEL" = "prerelease" ]; then
archive_dir="$CHANGES_DIR/released/$semver"
mkdir -p "$archive_dir"
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \;
fi
if [ "$CHANNEL" = "stable" ]; then
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
else
printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION"
fi
if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then
printf '\nCommits since %s:\n' "$FROM_REF"
git log --oneline "$FROM_REF..HEAD"
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
set -eu
# Fragment names are matched with ASCII ranges, so keep collation deterministic.
LC_ALL=C
export LC_ALL
CHANGES_DIR="${1:-.changes}"
usage() {
printf '%s\n' 'usage: render-release-fragments.sh [changes-dir]' >&2
}
[ "$#" -le 1 ] || { usage; exit 2; }
[ -d "$CHANGES_DIR" ] || { printf 'release fragments directory not found: %s\n' "$CHANGES_DIR" >&2; exit 1; }
tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-fragments.XXXXXX")"
cleanup() { rm -rf "$tmp_root"; }
trap cleanup EXIT HUP INT TERM
# A fragment must be a regular file named ^[a-z0-9][a-z0-9._-]*\.md$. Anything
# else is rejected rather than skipped, so a symlink or an illegally named
# fragment can never be silently dropped from the rendered notes.
validate_fragment_name() {
name="$1"
path="$2"
case "$name" in
*.md) ;;
*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
case "$name" in
[a-z0-9]*) ;;
*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
case "$name" in
*[!a-z0-9._-]*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
}
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$tmp_root/entries"
: >"$tmp_root/files"
while IFS= read -r entry; do
base="${entry##*/}"
if [ "$base" = 'README.md' ]; then
continue
fi
if [ -L "$entry" ]; then
printf 'release fragment must be a regular file, not a symbolic link: %s\n' "$entry" >&2
exit 1
fi
if [ -d "$entry" ]; then
if [ "$base" = 'released' ]; then
continue
fi
printf 'unexpected directory in release fragments directory: %s\n' "$entry" >&2
exit 1
fi
if [ ! -f "$entry" ]; then
printf 'release fragment must be a regular file: %s\n' "$entry" >&2
exit 1
fi
validate_fragment_name "$base" "$entry"
printf '%s\n' "$entry" >>"$tmp_root/files"
done <"$tmp_root/entries"
[ -s "$tmp_root/files" ] || {
printf '%s\n' 'no release fragments found; add .changes/<unique-name>.md before preparing a prerelease changelog' >&2
exit 1
}
validate_fragment() {
fragment="$1"
validate_fragment_name "${fragment##*/}" "$fragment"
[ "$(sed -n '1p' "$fragment")" = '---' ] &&
[ "$(sed -n '3p' "$fragment")" = '---' ] || {
printf 'invalid release fragment header: %s\n' "$fragment" >&2
return 1
}
case "$(sed -n '2p' "$fragment")" in
'category: Added'|'category: Changed'|'category: Deprecated'|'category: Removed'|'category: Fixed'|'category: Security') ;;
*) printf 'invalid release fragment category: %s\n' "$fragment" >&2; return 1 ;;
esac
body="$(sed -n '4,$p' "$fragment")"
printf '%s\n' "$body" | grep -Eq '^- [^[:space:]].*' || {
printf 'release fragment must contain a non-empty Markdown list item: %s\n' "$fragment" >&2
return 1
}
if printf '%s\n' "$body" | grep -Eqi '(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)'; then
printf 'release fragment must not contain TODO/TBD: %s\n' "$fragment" >&2
return 1
fi
}
while IFS= read -r fragment; do
validate_fragment "$fragment"
done <"$tmp_root/files"
for category in Added Changed Deprecated Removed Fixed Security; do
category_files="$tmp_root/$category"
: >"$category_files"
while IFS= read -r fragment; do
if [ "$(sed -n '2p' "$fragment")" = "category: $category" ]; then
printf '%s\n' "$fragment" >>"$category_files"
fi
done <"$tmp_root/files"
[ -s "$category_files" ] || continue
printf '### %s\n\n' "$category"
while IFS= read -r fragment; do
awk 'NR >= 4 { if ($0 ~ /[^[:space:]]/) started = 1; if (started) print }' "$fragment"
printf '\n'
done <"$category_files"
done
+1 -1
View File
@@ -51,7 +51,7 @@ cli_version: ">=1.0.15"
| `devapp` | 19 | `dingtalk-misc` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `drive` | 28 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 27 | `dingtalk-minutes` |
| `oa` | 7 | `dingtalk-misc` |
+32 -36
View File
@@ -18,34 +18,30 @@ metadata:
> 命令参考:[drive.md](references/drive.md)。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
## Shortcut 发现(按需)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "drive +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws drive <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service drive --format json` 批量发现。
`drive` 当前有 28 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图按下方路由。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws drive +copy` | write | 复制文件/文档到指定位置 |
| `dws drive +find-file` | read | 按名称关键词搜索钉盘文件并投影关键字段(只读) |
| `dws drive +info` | read | 获取钉盘文件/文件夹元数据 |
| `dws drive +move` | write | 移动文件/文档到指定位置 |
| `dws drive +recent` | read | 获取最近访问/编辑的文档列表 |
| `dws drive +search` | read | 搜索钉盘文件 |
| `dws drive +search-docs` | read | 搜索文档空间文档 |
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service drive --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
| 用户说 | 命令 |
|--------|------|
| "看钉盘文件 / 文件夹列表" | `dws drive list [--folder <dentryUuid>]` |
| "看钉盘文件 / 文件夹列表" | `dws drive +list [--folder <dentryUuid>]` |
| "钉盘目录树" | `python scripts/drive_tree_list.py --depth 2` |
| "查文件元数据" | `dws drive info --node <dentryUuid>` |
| "搜文件 / 找文件" | `dws drive search --query "<关键词>"` |
| "下载文件" | `dws drive download --node <dentryUuid> --output <path>` |
| "上传文件" | `dws drive upload --file <path> [--folder <id>]` |
| "建钉盘文件夹" | `dws drive mkdir --name "<名称>" [--folder <id>]` |
| "复制/移动/重命名/删除/权限管理" | `dws drive copy/move/rename/delete/permission ...` |
| "回收站 / 还原删除的文件" | `dws drive recycle list` / `dws drive recycle restore --id <recycleItemId>` |
| "查文件元数据/统计/公开状态/封面" | `dws drive +inspect --node <dentryUuid> [--include-stats/--include-publish/--include-cover]` |
| "搜文件 / 找文件" | `dws drive +search --query "<关键词>"` |
| "下载文件" | `dws drive +download --node <dentryUuid> --output <工作目录内相对路径>` |
| "上传文件" | `dws drive +upload --file <工作目录内相对路径> [--folder <id>]` |
| "建钉盘文件夹 / 建快捷方式" | `dws drive +create-folder ...` / `dws drive +create-shortcut ...` |
| "复制/移动/重命名/删除" | `dws drive +copy/+move/+rename/+delete ...` |
| "回收站 / 还原删除的文件" | `dws drive +recycle-list` / `dws drive +recycle-restore --id <recycleItemId>` |
| "收藏 / 取消收藏 / 收藏列表" | `dws drive +star-add/+star-remove/+star-list ...` |
| "查/关互联网公开" | `dws drive +publish-get/+publish-unset ...`;开启公开当前无已验证 eligible 节点,不推荐 Agent 调用 |
| "普通文件历史版本" | `dws drive +version-history/+version-get/+version-download/+version-revert ...` |
| "在线文档评论/导入导出/权限" | 切到 `dws doc +comment-* / +import / +export / +access-*` |
## 标准 SOP(必遵流程)
@@ -55,29 +51,29 @@ metadata:
**触发**:找文件/搜文件/我的文件/最近文件/某文档在哪。
1. **选源(必须)**:最近访问 → `dws drive recent --limit <n> --format json`(翻页用上次返回的 `nextCursor` 传 `--cursor`);按内容/名称全局搜 → `dws drive search --query "<关键词>" --format json`;浏览某目录 → `dws drive list --folder <dentryUuid> --format json`。
1. **选源(必须)**:最近访问 → `dws drive +recent --limit <n> --format json`(翻页用上次返回的 `nextCursor` 传 `--cursor`);按内容/名称全局搜 → `dws drive +search --query "<关键词>" --format json`;浏览某目录 → `dws drive +list --folder <dentryUuid> --format json`。
2. **解析(必须)**:取真实 `dentryUuid`(= `id`/`nodeId`);多候选让用户确认,**禁止**默认取第一个。
3. **下钻(必须)**:根目录没命中时,进入最相关文件夹继续 `drive list --folder`,必要时 `python scripts/drive_tree_list.py --depth 2` 递归,**禁止**只看根目录就放弃。
4. **回读元数据(必须)**:命中后 `dws drive info --node <dentryUuid> --format json`,按 `extension` 确认类型。
3. **下钻(必须)**:根目录没命中时,进入最相关文件夹继续 `drive +list --folder`,必要时 `python scripts/drive_tree_list.py --depth 2` 递归,**禁止**只看根目录就放弃。
4. **回读元数据(必须)**:命中后 `dws drive +inspect --node <dentryUuid> --format json`,按 `extension` 确认类型。
**禁止**:编造 dentryUuid、只看根目录放弃、用 `drive list` 替代 `drive search` 做全局查找。
**禁止**:编造 dentryUuid、只看根目录放弃、用 `drive +list` 替代 `drive +search` 做全局查找。
### SOP-2 上传 / 下载(upload-download)
**触发**:上传文件/下载文件/传到钉盘/用本地文件覆盖已有文件。
1. **上传(必须)**:`dws drive upload --file <本地路径> [--folder <dentryUuid>] --format json`;返回取 `dentryUuid`,用 `drive info --node` 回读确认。
2. **覆盖(必须)**:先 `dws drive info --node <dentryUuid> --format json`,记录真实 `extension` 和原 `name`。`extension=md` 切 `dingtalk-misc` 的 `references/markdown.md`,先 `markdown overwrite --dry-run` 再确认执行;其他普通文件在用户确认后执行 `dws drive upload --node <dentryUuid> --file <本地路径> --file-name "<原name>" --format json`,随后再次 `drive info` 回读。`adoc` / `axls` / `able` 切对应内容 skill/reference,不按普通文件覆盖。
3. **下载(必须)**:先 `dws drive info --node <dentryUuid> --format json` 判断类型——`extension=adoc` 切 `dingtalk-doc` 用 `doc export`;普通文件执行 `dws drive download --node <dentryUuid> --output <本地路径> --format json`。
1. **上传(必须)**:先把文件暂存到工作目录,再执行 `dws drive +upload --file <相对路径> [--folder <dentryUuid>] --format json`;shortcut 内部已提交并回读远端元数据,返回取 `data.nodeId`。
2. **覆盖(必须)**:先 `dws drive +inspect --node <dentryUuid> --format json`,记录真实 `extension` 和原 `name`。`extension=md` 切 `dingtalk-misc` 的 `references/markdown.md`;其他普通文件在用户确认后执行 `dws drive +upload --node <dentryUuid> --file <相对路径> --file-name "<原name>" --format json`。`adoc` / `axls` / `able` 切对应内容 skill/reference,不按普通文件覆盖。
3. **下载(必须)**:先 `dws drive +inspect --node <dentryUuid> --format json` 判断类型——`extension=adoc` 切 `dingtalk-doc` 用 `doc +export`;普通文件执行 `dws drive +download --node <dentryUuid> --output <工作目录内相对路径> --format json`,并校验 `data.sizeBytes > 0` 和本地文件真实存在。
**禁止**:对在线文档用 `drive download`(会失败)、普通文件覆盖时省略 `--file-name` 导致隐式重命名、上传或覆盖后不回读。
**禁止**:对在线文档用 `drive +download`(会失败)、普通文件覆盖时省略 `--file-name` 导致隐式重命名、只看退出码而不检查统一结果。
### SOP-3 文件夹 / 复制 / 移动 / 重命名(folder-ops)
**触发**:建文件夹/复制/移动/重命名。
1. **执行(必须)**:建钉盘文件夹 `dws drive mkdir --name "<名称>" [--folder <id>]`;复制 `drive copy --node <dentryUuid> --folder <目标>`;移动 `drive move --node <dentryUuid> --folder <目标>`;重命名 `drive rename --node <dentryUuid> --name "<新名>"`。全部加 `--format json`。
2. **验证(必须)**:操作后 `drive info --node <新dentryUuid>` 或 `drive list --folder <目标>` 回读。
1. **执行(必须)**:建钉盘文件夹 `dws drive +create-folder --name "<名称>" [--folder <id>]`;复制 `drive +copy --node <dentryUuid> --folder <目标>`;移动 `drive +move --node <dentryUuid> --folder <目标>`;重命名 `drive +rename --node <dentryUuid> --name "<新名>"`。全部加 `--format json`。
2. **验证(必须)**:这些 shortcut 内部已经读回;调用方仍须检查 `ok=true`、`outcome=success` 和 `data` 中的 nodeId/对象,不能只看进程退出码。
**禁止**:未确认就移动/覆盖他人文件、跳过回读。
@@ -85,8 +81,8 @@ metadata:
**触发**:删文件/回收站/还原。
1. **删除(必须)**:`dws drive delete --node <dentryUuid> --format json`(**必须**先与用户确认)。
2. **还原(必须)**:`dws drive recycle list --format json` 取 `recycleItemId` → `dws drive recycle restore --id <recycleItemId> --format json`。
1. **删除(必须)**:`dws drive +delete --node <dentryUuid> --format json`(**必须**先与用户确认,再由执行层添加 `--yes`)。
2. **还原(必须)**:`dws drive +recycle-list --format json` 按 `originalName/originalPath` 确认目标并取 `recycleItemId` → 用户确认后执行 `dws drive +recycle-restore --id <recycleItemId> --format json`;成功时 Shortcut 会用服务返回的 nodeId 读回。
**禁止**:未确认就删除、把 `dentryUuid` 当 `recycleItemId` 传给 restore。
@@ -94,16 +90,16 @@ metadata:
**触发**:互联网公开/取消公开/查公开状态。
1. **执行(必须)**:查状态 `dws drive publish get --node <dentryUuid> --format json`;开启公开 `dws drive publish set --node <dentryUuid> --yes`(**[危险]** 必须用户确认);关闭公开 `dws drive publish unset --node <dentryUuid> --yes`。
1. **执行(必须)**:查状态 `dws drive +publish-get --node <dentryUuid> --format json`;用户确认后关闭公开 `dws drive +publish-unset --node <dentryUuid> --format json`。`+publish-set` 在普通文件和在线文档真实夹具上均返回 `operation.notSupported`,当前不进入 Agent 公开入口;只有未来先找到服务端明确支持的 eligible 节点并通过真实 set→get→unset 闭环后才能启用。
2. **边界(必须)**:对外公开前**必须**与用户确认边界与后果。
**禁止**:未确认就 `publish set`、跳过 `--yes`。
**禁止**:未确认就改变公开状态;把 `operation.notSupported` 当成功;在没有 eligible 节点真实闭环的情况下启用 `+publish-set`。
## 高频硬约束
- 查找文件不要只看根目录后放弃;根目录没命中时,进入最相关的目标文件夹继续 `drive list --folder <dentryUuid>`,必要时用目录树脚本递归到合理深度。
- `drive list` 默认 `--limit 20`,自动化场景里保守使用 `--limit 50` 以内并处理 `nextToken` 翻页;不要因为参数边界报错反复重试。
- 全局找文件优先 `drive search --query`;指定目录浏览用 `drive list`,命中后必须 `drive info --node <dentryUuid> --format json` 回读元数据。
- 查找文件不要只看根目录后放弃;根目录没命中时,进入最相关的目标文件夹继续 `drive +list --folder <dentryUuid>`,必要时用目录树脚本递归到合理深度。
- `drive +list` 默认 `--limit 20`,自动化场景里保守使用 `--limit 50` 以内并处理 `nextCursor` 翻页;不要因为参数边界报错反复重试。
- 全局找文件优先 `drive +search --query`;指定目录浏览用 `drive +list`,命中后必须 `drive +inspect --node <dentryUuid> --format json` 回读元数据。
- 删除、覆盖、移动等破坏性操作必须确认;上传、创建文件夹、下载后要读回或列目录验证。
- 所有 `dws drive` 命令加 `--format json`。
@@ -5,13 +5,13 @@
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "参照这个生成同样的 / 按模板生成 / 复刻 X / 同样的模板 X 月份的" + 已有 alidocs URL | 模板保形生成同形态变体 | `drive copy + drive rename + doc block update` → 见 [best_practices/04-document.md `template-based-generation`](../../dingtalk-doc/references/04-document.md#template-based-generation) | `doc read + doc create`(重写链) | adoc → markdown 是有损投影,read+create 会丢行高/单元格背景色/字号;copy 在 adoc 层保形复制后只在副本上局部修改 |
| "读一下这个 xlsx 的数据" / xlsx 节点链接 | 下载本地表格文件 | `dws drive download --node` | `sheet range read` | xlsx / xls / xlsm / csv 是上传的本地文件(`contentType=DOCUMENT`),sheet 命令只支持在线表格,必须下载后本地解析 |
| "读一下这个 xlsx 的数据" / xlsx 节点链接 | 下载本地表格文件 | `dws drive +download --node --output <相对路径>` | `sheet range read` | xlsx / xls / xlsm / csv 是上传的本地文件(`contentType=DOCUMENT`),sheet 命令只支持在线表格;Shortcut 会验证真实本地字节 |
| "把这个在线表格导出为 xlsx 文件" | 在线表格格式转换 | `dws sheet export` | `dws drive download` | `export` 是 axls → xlsx 的导出转换;`download` 只能下载已有的 xlsx 节点 |
| "帮我把这个文件传到网盘" | 钉盘上传 | `drive upload` | — | 文件上传是存储层操作,归 drive |
| "上传文件到钉盘/我的文件" | 钉盘上传 | `drive upload` | — | 提到"钉盘/网盘/我的文件"→ drive |
| "上传文件"(未指定目标) | 默认钉盘 | `drive upload` | — | 未明确目标时默认上传到钉盘 |
| "帮我把这个文件传到网盘" | 钉盘上传 | `drive +upload` | — | 文件上传是存储层操作,归 drive;Shortcut 负责提交和读回 |
| "上传文件到钉盘/我的文件" | 钉盘上传 | `drive +upload` | — | 提到"钉盘/网盘/我的文件"→ drive |
| "上传文件"(未指定目标) | 默认钉盘 | `drive +upload` | — | 未明确目标时默认上传到钉盘 |
| "帮我看看知识库里的文件" | 知识库节点列表 | `wiki node list --workspace` | `drive list` | 明确"知识库"上下文 → wiki node list |
| "列出钉盘团队空间" | 列出钉盘空间 | `wiki space list --type orgSpace` | `drive list-spaces` | 空间管理归 wiki,drive list-spaces 已 deprecated |
| "在知识库里搜方案" | 空间内搜索 | `wiki node search --workspace` | `drive search` | 指定了空间上下文 → wiki node search |
| "搜一下有没有叫XX的文件" | 全局搜索 | `drive search` | `wiki node search` | 未指定空间 → drive search 全局聚合搜索 |
| "搜一下有没有叫XX的文件" | 全局搜索 | `drive +search` | `wiki node search` | 未指定空间 → Drive Shortcut 严格搜索 |
| "整理一下XX项目的所有讨论" | 跨源主题归档 | #5 generate-topic-report | #4 write-doc | #4 侧重单篇文档创作;按主题跨听记/群消息汇总属于工作汇报 |
@@ -6,13 +6,13 @@
### query-doc
1. 全局搜索:`drive search --query "<关键词>"` → `nodeId`(聚合钉盘+文档空间)
1. 钉盘全局搜索:`drive +search --query "<关键词>"` → `nodeId`;在线文档统一使用 `doc +search`
2. 空间内搜索:`wiki node search --workspace <WS_ID> --query "<关键词>"` → `nodeId`
3. `doc read --node <nodeId>`(按需;大文档只抽章节)
### list-folder-docs
`drive list --workspace <WS_ID>` 或 `wiki node list --workspace <WS_ID>`
`wiki node list --workspace <WS_ID>`;普通钉盘目录使用 `drive +list --folder <ID>`
### import-file
@@ -42,4 +42,3 @@ dws doc import --file ./report.docx --format json
> **禁止先 Read 文件再 `doc create` + `doc update`**。`doc import` 是服务端格式转换,客户端无需解析文件内容。
> 详见 [./doc/doc-import.md](../../dingtalk-doc/references/doc/doc-import.md)。
+409 -14
View File
@@ -9,9 +9,10 @@ import (
)
type changelogGateRepo struct {
root string
gate string
base string
root string
gate string
fragmentPolicy string
base string
}
const changelogGateBase = `# Changelog
@@ -42,6 +43,26 @@ const changelogGateValidRelease = `# Changelog
- Initial release.
`
// A release-seal section whose 1.0.1-beta.1 notes are exactly the rendered form
// of changelogGateValidFragment. A seal PR built from this therefore satisfies
// the rendered-notes comparison, which isolates the archive-path assertions.
const changelogGateSealedRelease = `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`
func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
t.Helper()
@@ -69,8 +90,10 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
}
for _, path := range []string{
"scripts/policy/check-changelog-pr.sh",
"scripts/policy/check-release-fragments.sh",
"scripts/policy/open-source-audit.sh",
"scripts/release/release-lib.sh",
"scripts/release/render-release-fragments.sh",
} {
data, err := os.ReadFile(filepath.Join(sourceRoot, path))
if err != nil {
@@ -91,12 +114,21 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
changelogGateGit(t, root, "commit", "-m", "seed repository")
return &changelogGateRepo{
root: root,
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
root: root,
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
fragmentPolicy: filepath.Join(root, "scripts", "policy", "check-release-fragments.sh"),
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
}
}
func (r *changelogGateRepo) runFragmentPolicy(t *testing.T, base, head string) (string, error) {
t.Helper()
cmd := exec.Command("sh", r.fragmentPolicy, base, head)
cmd.Dir = r.root
output, err := cmd.CombinedOutput()
return string(output), err
}
func changelogGateWrite(t *testing.T, root, path, content string, mode os.FileMode) {
t.Helper()
full := filepath.Join(root, path)
@@ -203,22 +235,386 @@ func TestChangelogPRGateAcceptsTargetedChanges(t *testing.T) {
}
}
func TestChangelogPRContentOnlyAllowsOtherFiles(t *testing.T) {
func TestChangelogPRContentOnlyRejectsOrdinaryChangesAlongsideChangelog(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
repo.commit(t, "change code with release notes")
output, err := repo.runMode(t, "--content-only")
if err != nil {
t.Fatalf("content-only gate error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
t.Fatalf("content-only gate output missing success marker:\n%s", output)
if err == nil || !strings.Contains(output, "may accompany only release-fragment archival") {
t.Fatalf("content-only gate accepted ordinary source change: err=%v\noutput:\n%s", err, output)
}
}
func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T) {
func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
repo.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`, 0o644)
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1"), 0o755); err != nil {
t.Fatalf("MkdirAll archive: %v", err)
}
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1", "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment: %v", err)
}
repo.commit(t, "seal release notes")
output, err := repo.runMode(t, "--content-only")
if err != nil {
t.Fatalf("content-only release seal error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
t.Fatalf("content-only release seal output missing success marker:\n%s", output)
}
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("release fragment policy rejected matching seal: %v\noutput:\n%s", err, output)
}
}
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
t.Run("invalid active fragment", func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-invalid.md", "---\ncategory: Added\n---\n\n- TODO: fill this in.\n", 0o644)
repo.commit(t, "add invalid fragment")
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil || !strings.Contains(output, "must not contain TODO/TBD") {
t.Fatalf("invalid active fragment passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("archive version differs from changelog", func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
repo.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.2-beta.1"), 0o755); err != nil {
t.Fatalf("MkdirAll archive: %v", err)
}
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.2-beta.1", "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment: %v", err)
}
repo.commit(t, "archive under wrong release")
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil || !strings.Contains(output, "unchanged R100 moves") {
t.Fatalf("wrong archive version passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("archive notes differ from rendered fragment", func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
repo.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1"), 0o755); err != nil {
t.Fatalf("MkdirAll archive: %v", err)
}
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1", "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment: %v", err)
}
repo.commit(t, "seal mismatched release notes")
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil || !strings.Contains(output, "does not exactly match") {
t.Fatalf("mismatched release notes passed: err=%v\noutput:\n%s", err, output)
}
})
}
const changelogGateValidFragment = "---\ncategory: Added\n---\n\n- Chat reply mentions.\n"
// sealFragmentInto stages a release-seal PR that archives the single active
// fragment into .changes/released/<archiveDir>/, with a CHANGELOG section whose
// rendered notes already match. Only archiveDir varies, so a rejection can only
// come from the archive-path contract.
func (r *changelogGateRepo) sealFragmentInto(t *testing.T, archiveDir string) string {
t.Helper()
changelogGateWrite(t, r.root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
r.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, r.root, "rev-parse", "HEAD"))
changelogGateWrite(t, r.root, "CHANGELOG.md", changelogGateSealedRelease, 0o644)
target := filepath.Join(r.root, ".changes", "released", archiveDir)
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", target, err)
}
if err := os.Rename(filepath.Join(r.root, ".changes", "1234-chat.md"), filepath.Join(target, "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment error = %v", err)
}
r.commit(t, "seal release into "+archiveDir)
return sealBase
}
// The archive directory used to be matched by interpolating the version into an
// awk regex, where `.` matches any character. Version `1.0.1-beta.1` therefore
// also admitted `1x0x1-betaX1`, letting the archive drift from the CHANGELOG
// version while every other seal assertion still passed.
func TestReleaseFragmentPolicyRejectsArchiveDirectoryMatchedAsRegex(t *testing.T) {
for _, archiveDir := range []string{"1x0x1-betaX1", "1.0.1-betaX1", "1x0.1-beta.1"} {
t.Run(archiveDir, func(t *testing.T) {
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, archiveDir)
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil {
t.Fatalf("archive directory %q passed:\n%s", archiveDir, output)
}
if !strings.Contains(output, "unchanged R100 moves") {
t.Fatalf("gate output missing the archive-move contract:\n%s", output)
}
})
}
}
// Guards the fix against over-blocking: the archive directory that exactly
// equals the CHANGELOG version must still seal cleanly.
func TestReleaseFragmentPolicyAcceptsArchiveDirectoryMatchingChangelogVersion(t *testing.T) {
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("matching archive directory rejected: %v\noutput:\n%s", err, output)
}
}
// A top-level `.changes/` entry that violates the naming rule or is not a
// regular 100644 blob must fail the gate. Selecting the trigger by the legal
// name pattern used to let these entries through untouched, which both bypassed
// validation here and broke the next PR that added a legal fragment.
func TestReleaseFragmentPolicyRejectsIllegalFragmentNamesAndModes(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, root string)
wantPath string
}{
{
name: "uppercase fragment name",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/Chat.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/Chat.md",
},
{
name: "fragment name with a space",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/chat reply.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/chat reply.md",
},
{
name: "fragment name starting with a dash",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/-chat.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/-chat.md",
},
{
name: "non-markdown entry",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/notes.txt", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/notes.txt",
},
{
name: "symlinked fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
if err := os.Symlink("1234-chat.md", filepath.Join(root, ".changes", "5678-alias.md")); err != nil {
t.Fatalf("Symlink release fragment: %v", err)
}
},
wantPath: ".changes/5678-alias.md",
},
{
name: "executable fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o755)
},
wantPath: ".changes/1234-chat.md",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
test.seed(t, repo.root)
repo.commit(t, test.name)
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil {
t.Fatalf("illegal release fragment entry passed:\n%s", output)
}
if !strings.Contains(output, ".changes/ accepts only") {
t.Fatalf("gate output missing the entry contract:\n%s", output)
}
if !strings.Contains(output, test.wantPath) {
t.Fatalf("gate output missing offending entry %q:\n%s", test.wantPath, output)
}
})
}
}
func TestReleaseFragmentPolicyAcceptsLegalFragmentAlongsideReadmeAndArchive(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
changelogGateWrite(t, repo.root, ".changes/released/1.0.0/0001-seed.md", changelogGateValidFragment, 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
repo.commit(t, "add release fragment")
if output, err := repo.runFragmentPolicy(t, base, "HEAD"); err != nil {
t.Fatalf("legal release fragment rejected: %v\noutput:\n%s", err, output)
}
}
// Git records no diff entry for a directory itself, so adding
// `.changes/foo/bar.md` only ever surfaces the nested path. Triggering the
// top-level validation on single-level paths let such a directory reach main
// untouched and then broke every later fragment render with
// `unexpected directory`.
func TestReleaseFragmentPolicyRejectsNestedChangesEntries(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, root string)
wantPath string
}{
{
name: "nested directory only",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/foo/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/foo",
},
{
name: "nested directory alongside a legal fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
changelogGateWrite(t, root, ".changes/foo/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/foo",
},
{
name: "nested directory shadowing the archive name",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/released.d/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/released.d",
},
{
name: "deeply nested directory",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/a/b/c/note.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/a",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
test.seed(t, repo.root)
repo.commit(t, test.name)
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil {
t.Fatalf("nested .changes entry passed:\n%s", output)
}
if !strings.Contains(output, ".changes/ accepts only") {
t.Fatalf("gate output missing the entry contract:\n%s", output)
}
if !strings.Contains(output, test.wantPath) {
t.Fatalf("gate output missing offending entry %q:\n%s", test.wantPath, output)
}
})
}
}
// Replacing `.changes` itself empties the child listing, so scanning entries
// alone would report nothing invalid while the whole fragment directory
// disappears. Seeded without an active fragment so the archival-move guard
// cannot mask the directory contract being asserted here.
func TestReleaseFragmentPolicyRejectsReplacedChangesDirectory(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
if err := os.RemoveAll(filepath.Join(repo.root, ".changes")); err != nil {
t.Fatalf("RemoveAll(.changes) error = %v", err)
}
if err := os.Symlink("docs", filepath.Join(repo.root, ".changes")); err != nil {
t.Fatalf("Symlink(.changes) error = %v", err)
}
repo.commit(t, "replace .changes with a symlink")
output, err := repo.runFragmentPolicy(t, base, "HEAD")
if err == nil {
t.Fatalf("replaced .changes directory passed:\n%s", output)
}
if !strings.Contains(output, ".changes must remain a directory") {
t.Fatalf("gate output missing the directory contract:\n%s", output)
}
}
// `.changes/README.md` is the contributor contract, not release content, so a
// documentation-only edit must pass even though no fragment is pending — the
// renderer rejects an empty fragment set.
func TestReleaseFragmentPolicyAcceptsReadmeOnlyChangeWithoutPendingFragments(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n\nName each fragment `<name>.md`.\n", 0o644)
repo.commit(t, "document the fragment naming rule")
if output, err := repo.runFragmentPolicy(t, base, "HEAD"); err != nil {
t.Fatalf("README-only change rejected: %v\noutput:\n%s", err, output)
}
}
// A README-only edit still revalidates the tree, so pollution that somehow
// reached the branch is reported by the PR that touches `.changes/` rather than
// deferred to whichever unrelated PR next adds a fragment.
func TestReleaseFragmentPolicyReadmeOnlyChangeStillValidatesTree(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
changelogGateWrite(t, repo.root, ".changes/Foo.md", changelogGateValidFragment, 0o644)
repo.commit(t, "seed polluted fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n\nUpdated contract.\n", 0o644)
repo.commit(t, "edit only the fragment readme")
output, err := repo.runFragmentPolicy(t, base, "HEAD")
if err == nil {
t.Fatalf("README-only change skipped tree validation:\n%s", output)
}
if !strings.Contains(output, ".changes/Foo.md") {
t.Fatalf("gate output missing the pre-existing illegal entry:\n%s", output)
}
}
func TestChangelogPRContentOnlyStillValidatesReleaseNotes(t *testing.T) {
tests := []struct {
name string
changelog string
@@ -250,7 +646,6 @@ func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T)
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
repo.commit(t, test.name)
output, err := repo.runMode(t, "--content-only")
+102 -37
View File
@@ -73,6 +73,7 @@ type releaseTestRepo struct {
remote string
contract string
prepare string
render string
releaseCmd string
lib string
verify string
@@ -107,6 +108,7 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
remote: remote,
contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"),
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
@@ -1658,10 +1660,13 @@ func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) {
}
}
func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, ".changes", "1234-chat-reply.md"), []byte("---\ncategory: Added\n---\n\n- **Chat reply mentions** (#1234) — supports selected member mentions.\n"), 0o644)
mustWriteFile(t, filepath.Join(r.root, ".changes", "1235-chat-fix.md"), []byte("---\ncategory: Fixed\n---\n\n- **Chat reply fallback** (#1235) — keeps replies compatible with older clients.\n"), 0o644)
r.commitAndPush(t, "install changelog preparation")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
@@ -1675,55 +1680,115 @@ func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") {
t.Fatalf("prepared changelog missing guarded template:\n%s", changelog)
content := string(changelog)
if !strings.Contains(content, "## [1.0.1-beta.1] - 2026-07-11") ||
!strings.Contains(content, "### Added") ||
!strings.Contains(content, "Chat reply mentions") ||
!strings.Contains(content, "### Fixed") ||
!strings.Contains(content, "Chat reply fallback") ||
strings.Contains(content, "TODO") {
t.Fatalf("prepared changelog did not render release fragments:\n%s", changelog)
}
r.commitAndPush(t, "commit unfinished release notes")
contractOutput, contractErr := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") {
t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput)
for _, name := range []string{"1234-chat-reply.md", "1235-chat-fix.md"} {
if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) {
t.Fatalf("unconsumed release fragment %s still present: %v", name, err)
}
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1-beta.1", name)); err != nil {
t.Fatalf("archived release fragment %s missing: %v", name, err)
}
}
}
func TestReleasePrepareChangelogKeepsUnreleasedContentAboveNewVersion(t *testing.T) {
func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte("# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Keep this unreleased note.\n\n## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"), 0o644)
r.commitAndPush(t, "add unreleased changelog note")
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, ".changes", "invalid.md"), []byte("---\ncategory: Added\n---\n\n- TODO: write this later.\n"), 0o644)
r.commitAndPush(t, "add invalid release fragment")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
cmd.Dir = r.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "must not contain TODO/TBD") {
t.Fatalf("invalid release fragment was accepted: err=%v\noutput:\n%s", err, output)
}
data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
const releaseValidFragment = "---\ncategory: Added\n---\n\n- **Chat reply mentions** (#1234) — supports selected member mentions.\n"
// The renderer used to skip anything `find -type f -name '*.md'` did not match,
// so a symlinked or illegally named fragment was dropped from the notes without
// a word. Every unexpected top-level entry must fail the release instead.
func TestReleasePrepareChangelogRejectsUnsafeReleaseFragmentEntries(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, changesDir string)
wantMsg string
}{
{
name: "symlinked fragment",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "1234-chat.md"), []byte(releaseValidFragment), 0o644)
if err := os.Symlink("1234-chat.md", filepath.Join(changesDir, "5678-alias.md")); err != nil {
t.Fatalf("Symlink release fragment: %v", err)
}
},
wantMsg: "not a symbolic link",
},
{
name: "fragment name with a space",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "chat reply.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
name: "uppercase fragment name",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "Chat.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
name: "non-markdown entry",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "notes.txt"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
// The PR gate is what keeps a nested directory out of `.changes/`;
// this covers the release-side backstop that reports it instead of
// rendering notes from an unexpected tree shape.
name: "nested fragment directory",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "foo", "bar.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "unexpected directory in release fragments directory",
},
}
content := string(data)
positions := []int{
strings.Index(content, "## [Unreleased]"),
strings.Index(content, "- Keep this unreleased note."),
strings.Index(content, "## [1.0.1-beta.1] - 2026-07-11"),
strings.Index(content, "## [1.0.0] - 2026-07-01"),
}
for index, position := range positions {
if position < 0 {
t.Fatalf("prepared changelog is missing expected marker %d:\n%s", index, content)
}
}
for index := 1; index < len(positions); index++ {
if positions[index-1] >= positions[index] {
t.Fatalf("prepared changelog order is invalid: %v\n%s", positions, content)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
test.seed(t, filepath.Join(r.root, ".changes"))
r.commitAndPush(t, "add unsafe release fragment entry")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
cmd.Dir = r.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("unsafe release fragment entry was accepted:\n%s", output)
}
if !strings.Contains(string(output), test.wantMsg) {
t.Fatalf("renderer output missing %q:\n%s", test.wantMsg, output)
}
})
}
}