Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7db84d47 | ||
|
|
a023a2956a | ||
|
|
867bb44586 | ||
|
|
4bbd42cc25 | ||
|
|
2cc24de505 | ||
|
|
e023be1444 | ||
|
|
6575301a3a | ||
|
|
43121f1d8f | ||
|
|
cd02fe71e6 | ||
|
|
93a2071837 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
5de36d783a | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
10bb2ec205 | ||
|
|
ac0125e468 | ||
|
|
7cc8db4eb0 | ||
|
|
f06a24ec2e | ||
|
|
8f135ecde6 | ||
|
|
a2e51f2b86 | ||
|
|
3d6e62fc08 | ||
|
|
c6094e291e | ||
|
|
e026c754e0 | ||
|
|
18030f1018 | ||
|
|
6297b6b0c8 | ||
|
|
e8905a1984 | ||
|
|
a097d57510 | ||
|
|
b566afe3e2 | ||
|
|
7405825294 | ||
|
|
7eb39ad5d6 | ||
|
|
eb73b944a1 | ||
|
|
ecaa375be8 | ||
|
|
dbecf23bc4 | ||
|
|
d73e199d97 | ||
|
|
7c9687094f | ||
|
|
05d8c86177 | ||
|
|
115dad3b82 | ||
|
|
b8ac9810f4 | ||
|
|
c1a90c0194 | ||
|
|
0bb2b6ce98 | ||
|
|
2662f87ad0 | ||
|
|
5a5b567eb0 | ||
|
|
c2db909bd2 | ||
|
|
871542ef0c | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
ea7c66b190 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
3027337a34 | ||
|
|
d0ad33034e | ||
|
|
5f13876e6e | ||
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
5c8fd0a48c | ||
|
|
7490bb95c5 | ||
|
|
b186e59a01 | ||
|
|
a92f54df3d | ||
|
|
307c9e797b | ||
|
|
116117e987 | ||
|
|
07ce090eeb | ||
|
|
201949aec1 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
ba375b40fa | ||
|
|
0a063662a0 | ||
|
|
4148a90bf5 | ||
|
|
262248d08d | ||
|
|
e7955b5891 | ||
|
|
efb172dcd6 | ||
|
|
6572010922 | ||
|
|
0cbb1b8d30 | ||
|
|
6738d0f29e | ||
|
|
db6addfc0e | ||
|
|
384b067ead | ||
|
|
c32d10bd43 | ||
|
|
17f153a070 | ||
|
|
959bc4c1a8 | ||
|
|
9f80006b3b | ||
|
|
e530aea14d | ||
|
|
32515729af | ||
|
|
2a7ab22e85 | ||
|
|
757f45df31 | ||
|
|
ef71673c69 | ||
|
|
a3773c4384 | ||
|
|
4110330575 | ||
|
|
af0086c9a8 | ||
|
|
28f75dec0a | ||
|
|
15d5be6000 | ||
|
|
ebfba82ebc | ||
|
|
ec9897678f | ||
|
|
af6e566abd | ||
|
|
4ea298ec61 | ||
|
|
7aba24b690 | ||
|
|
60e278f32f | ||
|
|
7acbec2615 | ||
|
|
c54a9e1e5e | ||
|
|
a15fb22671 | ||
|
|
d65ad9aa2e | ||
|
|
5d25a10223 | ||
|
|
2bd6b297b0 | ||
|
|
7688f95d2b | ||
|
|
368c879f45 | ||
|
|
0d5c6d92e3 | ||
|
|
49a17b4375 | ||
|
|
b9e7ff8c55 | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
24cdb85d71 | ||
|
|
d75b744a35 | ||
|
|
7134f33e1d | ||
|
|
88f7ea5679 | ||
|
|
c0b562cc07 | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
70d58648c8 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
fae21ec9f2 | ||
|
|
779fd82a88 | ||
|
|
3e60b83881 | ||
|
|
66a676b6aa | ||
|
|
23bbbccb7e | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
d800060e06 | ||
|
|
8d8206f791 | ||
|
|
15c2bd50b8 | ||
|
|
711d557b93 | ||
|
|
6102e9fc68 | ||
|
|
fd61868393 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef | ||
|
|
f1e95d763d | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
c508968814 | ||
|
|
10943629d6 | ||
|
|
9fd9ae7a95 | ||
|
|
5b0198b2ec | ||
|
|
dc854acb9b | ||
|
|
d054e3dc01 | ||
|
|
95536c3e97 | ||
|
|
1bfedbd4d2 | ||
|
|
cfaf161fc7 |
@@ -0,0 +1,285 @@
|
||||
'use strict';
|
||||
|
||||
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
|
||||
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
|
||||
|
||||
const PRODUCT_GROUPS = [
|
||||
{
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
|
||||
},
|
||||
{
|
||||
primary: 'typefield',
|
||||
backup: 'wxianfeng',
|
||||
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
|
||||
},
|
||||
{
|
||||
primary: 'haofeng0705',
|
||||
backup: 'typefield',
|
||||
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
|
||||
},
|
||||
];
|
||||
|
||||
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
id: 'security',
|
||||
label: '登录、认证、权限、安全',
|
||||
primary: 'hlzjsong',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'internal/auth/',
|
||||
'internal/keychain/',
|
||||
'internal/audit/',
|
||||
'internal/pat/',
|
||||
'internal/security/',
|
||||
'internal/safety/',
|
||||
'pkg/edition/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'delivery',
|
||||
label: 'CI、测试、发布、安装',
|
||||
primary: 'haofeng0705',
|
||||
backup: 'wxianfeng',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
path.startsWith('.github/') ||
|
||||
path.startsWith('scripts/release/') ||
|
||||
path.startsWith('scripts/policy/') ||
|
||||
path.startsWith('scripts/dev/') ||
|
||||
path.startsWith('scripts/install') ||
|
||||
path.startsWith('Formula/') ||
|
||||
path.startsWith('build/') ||
|
||||
path.startsWith('internal/upgrade/') ||
|
||||
path.startsWith('internal/app/upgrade') ||
|
||||
path.startsWith('test/') ||
|
||||
path.startsWith('verify/') ||
|
||||
path.startsWith('.workflow/') ||
|
||||
path === 'coverage.txt' ||
|
||||
path === 'coverage-base.txt' ||
|
||||
path === '.goreleaser.yaml' ||
|
||||
path === 'package.json' ||
|
||||
path === 'package-lock.json' ||
|
||||
path === 'docs/releasing.md',
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
label: 'DWS 架构、公共内核',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'cmd/',
|
||||
'internal/apiclient/',
|
||||
'internal/app/',
|
||||
'internal/cli/',
|
||||
'internal/cobracmd/',
|
||||
'internal/corecmd/',
|
||||
'internal/errors/',
|
||||
'internal/executor/',
|
||||
'internal/generator/',
|
||||
'internal/i18n/',
|
||||
'internal/interfacesnapshot/',
|
||||
'internal/jsonutil/',
|
||||
'internal/localio/',
|
||||
'internal/logging/',
|
||||
'internal/output/',
|
||||
'internal/pipeline/',
|
||||
'internal/plugin/',
|
||||
'internal/profilectx/',
|
||||
'internal/registry/',
|
||||
'internal/syncdata/',
|
||||
'internal/testseam/',
|
||||
'internal/transport/',
|
||||
'pkg/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'compatibility',
|
||||
label: '兼容性',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
|
||||
path.includes('schema_compat'),
|
||||
},
|
||||
];
|
||||
|
||||
function productMatches(path, product) {
|
||||
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
|
||||
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
|
||||
}
|
||||
|
||||
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
|
||||
group.products.map((product) => ({
|
||||
id: `product:${product}`,
|
||||
label: `产品:${product}`,
|
||||
primary: group.primary,
|
||||
backup: group.backup,
|
||||
requiresSecondary: false,
|
||||
matches: (path) => productMatches(path, product),
|
||||
})),
|
||||
);
|
||||
|
||||
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
|
||||
|
||||
function normalizedPaths(file) {
|
||||
return [file?.filename, file?.previous_filename]
|
||||
.filter((path) => typeof path === 'string' && path !== '')
|
||||
.map((path) => path.toLowerCase());
|
||||
}
|
||||
|
||||
function compareStats(left, right) {
|
||||
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
|
||||
}
|
||||
|
||||
function classifyFiles(files) {
|
||||
const counts = new Map();
|
||||
for (const file of files || []) {
|
||||
const matchingModules = new Set();
|
||||
for (const path of normalizedPaths(file)) {
|
||||
const matches = ALL_MODULES.filter((module) => module.matches(path));
|
||||
const securityOrDelivery = matches.filter(
|
||||
(module) => module.id === 'security' || module.id === 'delivery',
|
||||
);
|
||||
const effectiveMatches = securityOrDelivery.length > 0
|
||||
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
|
||||
: matches;
|
||||
for (const match of effectiveMatches) {
|
||||
matchingModules.add(match.id);
|
||||
}
|
||||
if (
|
||||
effectiveMatches.length === 0 &&
|
||||
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
|
||||
) {
|
||||
matchingModules.add('architecture');
|
||||
}
|
||||
}
|
||||
for (const moduleID of matchingModules) {
|
||||
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([id, files]) => {
|
||||
const index = ALL_MODULES.findIndex((module) => module.id === id);
|
||||
return {module: {...ALL_MODULES[index], order: index}, files};
|
||||
})
|
||||
.sort(compareStats);
|
||||
}
|
||||
|
||||
function chooseModuleReviewer(module, unavailable) {
|
||||
return [module.primary, module.backup].find(
|
||||
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
|
||||
);
|
||||
}
|
||||
|
||||
function addReviewer(reviewers, reviewer) {
|
||||
if (reviewer && !reviewers.includes(reviewer)) {
|
||||
reviewers.push(reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
|
||||
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
|
||||
const candidates = [];
|
||||
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
|
||||
if (
|
||||
eligible.has(reviewer.toLowerCase()) &&
|
||||
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
|
||||
) {
|
||||
candidates.push(reviewer);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers,
|
||||
satisfiedReviewers = [],
|
||||
requestReviewer,
|
||||
onFailure = () => {},
|
||||
}) {
|
||||
const alreadySatisfied = new Set(
|
||||
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
|
||||
);
|
||||
const satisfied = new Set();
|
||||
const requested = [];
|
||||
|
||||
for (const reviewer of candidates) {
|
||||
if (satisfied.size >= requiredReviewers) {
|
||||
break;
|
||||
}
|
||||
const normalizedReviewer = reviewer.toLowerCase();
|
||||
if (alreadySatisfied.has(normalizedReviewer)) {
|
||||
satisfied.add(normalizedReviewer);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldContinue = await requestReviewer(reviewer);
|
||||
if (shouldContinue === false) {
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
|
||||
}
|
||||
requested.push(reviewer);
|
||||
satisfied.add(normalizedReviewer);
|
||||
} catch (error) {
|
||||
onFailure(reviewer, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
|
||||
}
|
||||
|
||||
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
|
||||
const modules = classifyFiles(files);
|
||||
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
|
||||
const reviewers = [];
|
||||
const primaryModule = modules[0];
|
||||
|
||||
if (!primaryModule) {
|
||||
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
|
||||
}
|
||||
|
||||
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
|
||||
|
||||
const requiresSecondary =
|
||||
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
|
||||
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
|
||||
if (requiresSecondary) {
|
||||
addReviewer(
|
||||
reviewers,
|
||||
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
|
||||
);
|
||||
}
|
||||
|
||||
for (const reviewer of fallbackReviewers) {
|
||||
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
|
||||
break;
|
||||
}
|
||||
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
|
||||
addReviewer(reviewers, reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
|
||||
reviewers,
|
||||
requiredReviewers: requiresSecondary ? 2 : 1,
|
||||
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REVIEWER_POOL,
|
||||
classifyFiles,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./reviewer-routing');
|
||||
|
||||
function route(files, author = 'author', latestPusher = author) {
|
||||
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 1);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['.github/workflows/ci.yml']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.equal(result.reason, 'cross_or_sensitive');
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/auth/login.go'], 'hlzjsong');
|
||||
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/upgrade/downloader.go']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['pkg/edition/edition.go']);
|
||||
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/leaf_dispatch.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['docs/unknown-area.md']);
|
||||
assert.deepEqual(result.reviewers, []);
|
||||
assert.equal(result.reason, 'unknown_paths');
|
||||
}
|
||||
|
||||
async function testSingleReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['wxianfeng'],
|
||||
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 1,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('cannot request primary'), {status: 422});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 1);
|
||||
}
|
||||
|
||||
async function testTwoReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['haofeng0705', 'wxianfeng'],
|
||||
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 2,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('temporary failure'), {status: 503});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 2);
|
||||
}
|
||||
|
||||
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates: ['wxianfeng', 'typefield'],
|
||||
requiredReviewers: 1,
|
||||
satisfiedReviewers: ['typefield'],
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng']);
|
||||
assert.deepEqual(result.requested, ['wxianfeng']);
|
||||
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
testSingleReviewerFallback(),
|
||||
testTwoReviewerFallback(),
|
||||
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
|
||||
])
|
||||
.then(() => console.log('reviewer routing policy tests passed'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -111,6 +111,7 @@ jobs:
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('internal/cli/') ||
|
||||
filename.startsWith('internal/cobracmd/') ||
|
||||
filename.startsWith('internal/corecmd/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/i18n/') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
@@ -432,6 +433,10 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
- name: Test reviewer routing policy
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
needs: lint
|
||||
@@ -1385,6 +1390,13 @@ jobs:
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete CLI command compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$COMPATIBILITY_BASE_REF" \
|
||||
--stable-ref "$COMPATIBILITY_STABLE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
@@ -21,6 +21,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted routing policy
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
@@ -29,12 +34,13 @@ jobs:
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const reviewerPool = [
|
||||
'sczheng189',
|
||||
'shangguanxuan633-lab',
|
||||
'audanye-sudo',
|
||||
'wxianfeng',
|
||||
];
|
||||
const {
|
||||
REVIEWER_POOL,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./.github/reviewer-routing.js');
|
||||
const reviewerPool = REVIEWER_POOL;
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
@@ -67,6 +73,21 @@ jobs:
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
let changedFiles;
|
||||
try {
|
||||
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
changedFiles = [];
|
||||
}
|
||||
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
@@ -77,13 +98,9 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRequested =
|
||||
(pullRequest.requested_reviewers || []).length > 0 ||
|
||||
(pullRequest.requested_teams || []).length > 0;
|
||||
if (alreadyRequested) {
|
||||
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
const existingRequestedReviewers = new Set(
|
||||
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
|
||||
);
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
@@ -116,22 +133,18 @@ jobs:
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() ===
|
||||
review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
const currentHeadReviewers = new Set(
|
||||
[...latestDecisionByLogin.values()]
|
||||
.filter(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
)
|
||||
.map(review => review.user.login.toLowerCase()),
|
||||
);
|
||||
if (currentHeadDecision) {
|
||||
core.info(
|
||||
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
@@ -178,20 +191,42 @@ jobs:
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
for (const reviewer of ranked) {
|
||||
try {
|
||||
const routing = resolveReviewRouting({
|
||||
files: changedFiles,
|
||||
author,
|
||||
latestPusher,
|
||||
fallbackReviewers: ranked,
|
||||
});
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: routing.reviewers,
|
||||
fallbackReviewers: ranked,
|
||||
eligibleReviewers: eligible,
|
||||
});
|
||||
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
|
||||
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(
|
||||
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
|
||||
);
|
||||
|
||||
const satisfiedReviewers = new Set([
|
||||
...currentHeadReviewers,
|
||||
...[...existingRequestedReviewers].filter((reviewer) =>
|
||||
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
|
||||
),
|
||||
]);
|
||||
const requestResult = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: routing.requiredReviewers,
|
||||
satisfiedReviewers: [...satisfiedReviewers],
|
||||
requestReviewer: async (reviewer) => {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(currentPull.requested_reviewers || []).length > 0 ||
|
||||
(currentPull.requested_teams || []).length > 0
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
@@ -202,15 +237,23 @@ jobs:
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
return true;
|
||||
},
|
||||
onFailure: (reviewer, error) => {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (requestResult.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
|
||||
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
|
||||
core.warning(
|
||||
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
|
||||
@@ -6,6 +6,96 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed devapp list pagination** (#917) — the paginated `dws devapp`
|
||||
list shortcuts (`+list`, `+permission-list`, `+event-list`, and
|
||||
`+version-list`) now preserve `hasMore` and `nextCursor` with each single-page
|
||||
result, forward opaque cursors byte-for-byte (including surrounding or
|
||||
whitespace-only values), normalize deployed providers' terminal missing,
|
||||
null, empty, or last-observed cursor to `nextCursor: ""`, and reject malformed
|
||||
or ambiguous pagination pages and unprojectable app items instead of emitting
|
||||
terminal-looking partial results.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
|
||||
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
|
||||
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
|
||||
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
|
||||
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
|
||||
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
|
||||
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
|
||||
so callers can reuse returned task, message, and conversation IDs instead
|
||||
of searching message history by content.
|
||||
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
|
||||
reference with the progressive routing layout, aligns all 20 Sheet topic
|
||||
references across the mono and multi bundles, and adds a content-policy guard
|
||||
that prevents the paired topic trees from drifting again.
|
||||
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
|
||||
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
|
||||
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
|
||||
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
|
||||
now supports image URLs and local-file uploads through explicit message
|
||||
types, while retaining Markdown as the default and preserving its existing
|
||||
title and text requirements.
|
||||
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
|
||||
commands to list, add, hide, sort, and manage custom conversation shortcuts,
|
||||
with validation and confirmation for destructive removal.
|
||||
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
|
||||
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
|
||||
Base, table, record, attachment, view, dashboard, and workflow operations
|
||||
with explicit confirmation and result-verification semantics for writes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Doc import upload fallback** — `dws doc import` no longer fails on file
|
||||
formats outside the conversion whitelist (html, pdf, zip, extensionless,
|
||||
and any future format): it now hands the file to the document-space upload
|
||||
chain (the same primitive as `dws drive upload --workspace`), stores the
|
||||
original file at the requested `--folder`/`--workspace` target, and prints
|
||||
an explicit stderr notice with the supported-format list and the
|
||||
convert-to-md alternative. The fallback shares the import file checks
|
||||
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
|
||||
a single JSON document, and marks the machine-readable result with
|
||||
`fallback: "upload"` and `converted: false` so agents never mistake the
|
||||
stored file for a converted online document. The fallback fails closed
|
||||
unless the commit response parses as JSON and carries a file identity
|
||||
(exposed as `dentry_id`); empty or unverifiable responses surface as
|
||||
errors instead of fabricated success. Importable formats and
|
||||
`dws sheet import` validation are unchanged.
|
||||
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
|
||||
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
|
||||
- **Release-equivalent PR compatibility gate** (#889) — pull-request
|
||||
admission now runs command-surface compatibility checks against the current
|
||||
release baseline before code reaches `main`.
|
||||
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
|
||||
used for new ready PRs while retaining the existing current-head review and
|
||||
required-check gates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
|
||||
the registered remote tool name `verify_formula`; the previous
|
||||
`formula_verify` name failed at gateway dispatch.
|
||||
- **CLI and parameter recovery boundaries** (#864) — command and parameter
|
||||
recovery now fail closed when an Agent-provided path or flag cannot be
|
||||
reconciled with the executable CLI surface, reducing unsafe hallucinated
|
||||
retries.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
### Added
|
||||
@@ -126,6 +216,7 @@ and compatibility and CI reliability fixes.
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -138,6 +229,7 @@ and compatibility and CI reliability fixes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57-beta.4"
|
||||
version "1.0.58-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.56"
|
||||
version "1.0.57"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@python3 scripts/run_chat_shortcut_live_audit_test.py
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -151,9 +152,13 @@ generate-schema:
|
||||
@set -e; \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
|
||||
command_fallbacks_guard=$$(mktemp); \
|
||||
command_fallbacks_schema_guard=$$(mktemp); \
|
||||
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
@@ -169,6 +174,14 @@ generate-schema:
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
|
||||
@@ -70,16 +70,17 @@
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(5)
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
|
||||
@@ -1,209 +1,926 @@
|
||||
{
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"generated_at": "2026-08-06T21:08:26.697858",
|
||||
"count": 357,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+advperm-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "关闭高级权限会使自定义角色失效,显式发布高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+advperm-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "开启 Base 高级权限总开关的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-put",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-remove",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-bootstrap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-get-primary-doc-id",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-schema-snapshot",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新 Base 名称和备注的一对一写入,复用统一确认门。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "删除图表及布局项,显式发布不可逆高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取指定图表完整配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-share-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取图表分享配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启、关闭和设置图表分享类型。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新图表配置或布局,并要求显式 config 输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-widgets-example",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "返回所有图表类型的 widget config 示例,供构造合法图表配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-arrange",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "触发服务端智能布局重排并保留统一写入确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-config-example",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "级联删除仪表盘及图表,显式发布不可逆高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "读取仪表盘详情及 charts summary,作为图表操作导航入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-share-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取仪表盘分享配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启、关闭和设置仪表盘分享类型。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+export-data",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除字段的一对一入口,显式发布数据丢失风险。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "批量获取字段完整类型配置,避免仅返回名称的损失投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一字段名称、类型配置和 AI 配置的增量更新输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+find-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除表单视图的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-hide",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用隐藏/显示布尔语义切换表单字段可见性。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "列出表单当前字段顺序、可见性、必填和描述配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新表单字段必填状态与描述。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "从表的视图中稳定筛出表单视图并返回表单目录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-share-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取当前视图分享表单配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启或关闭指定视图的分享表单。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新表单标题和描述。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+import-data",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一将已上传文件导入新表或追加到已有表的目标语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+import-upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+list-tables",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-bulk-patch",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-history-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "按 recordId 查询变更历史的一对一只读入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-primary-doc-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以幂等意图为 primaryDoc 字段创建关联文档。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-primary-doc-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取记录主键文档 nodeId 的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-query",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-query-empty",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-share-links",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-share-url",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将最多 20 个 recordId 统一映射为可分享链接列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-upsert",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-upsert-by-key",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+resolve-base",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+resolve-table",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一角色名称、成员与权限配置的创建输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除自定义角色的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取单个角色完整权限配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出 Base 全部自定义角色的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 PATCH 语义增量更新角色配置,未提供字段保持不变。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在 Base 目录树创建用于组织表和仪表盘的文件夹。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除目录文件夹的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-list-empty",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "扫描目录树并只返回没有子节点的空文件夹。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-list-nodes",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-move-node",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-rename",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "重命名目录文件夹的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-reorder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除数据表的一对一入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一表名、备注和行命名规则的 PATCH 风格更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+url-resolve",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除视图的一对一入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-duplicate",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "复制现有视图配置并生成新视图的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "读取视图完整列顺序、筛选、排序和分组配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-frozen-cols",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取 Grid 视图冻结列数的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-lock",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取视图锁定状态的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-row-height",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取视图行高像素值的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-lock",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用默认锁定与 --off 解锁统一表达视图锁状态切换。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-preset-apply",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-fill-color-rule",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以全量覆盖语义设置条件填色规则,并用空数组明确清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-frozen-cols",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "设置冻结列数并以 0 明确表达取消冻结。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-row-height",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把行高限制为服务支持的 32/56/88/128 像素档位后写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一视图名称、描述、可见列、筛选、排序和分组配置更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-deploy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "禁用工作流会影响业务自动化,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "启用指定自动化工作流的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取单个自动化工作流详情的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "分页列出 Base 自动化工作流的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -1964,7 +2681,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -101,5 +101,5 @@ skills/mono/
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
|
||||
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
@@ -37,6 +38,16 @@ orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
|
||||
paired_files:
|
||||
- mono: references/products/sheet.md
|
||||
multi: dingtalk-misc/references/sheet.md
|
||||
mode: link-normalized
|
||||
link_substitutions:
|
||||
- mono: "../url-patterns.md"
|
||||
multi: "../../dingtalk-shared/references/url-patterns.md"
|
||||
- mono: "../intent-guide.md"
|
||||
multi: "sheet-intent-guide.md"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
|
||||
var shortcutHallucinationBadcases20260728JSON []byte
|
||||
|
||||
type shortcutHallucinationReplayCorpus struct {
|
||||
SourceReport string `json:"source_report"`
|
||||
SourceFile string `json:"source_file"`
|
||||
SourceDWSCommit string `json:"source_dws_commit"`
|
||||
Model string `json:"model"`
|
||||
ExpectedCount int `json:"expected_count"`
|
||||
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
|
||||
}
|
||||
|
||||
type shortcutHallucinationReplayBadcase struct {
|
||||
ID string `json:"id"`
|
||||
CaseID string `json:"case_id"`
|
||||
Run int `json:"run"`
|
||||
CommandIndex int `json:"command_index"`
|
||||
Turn int `json:"turn"`
|
||||
SourcePath string `json:"source_path"`
|
||||
ExpectedOutcome string `json:"expected_outcome"`
|
||||
Raw string `json:"raw"`
|
||||
OriginalExitCode int `json:"original_exit_code"`
|
||||
IsHelp bool `json:"is_help"`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
|
||||
var corpus shortcutHallucinationReplayCorpus
|
||||
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
|
||||
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
|
||||
}
|
||||
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
|
||||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
|
||||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
|
||||
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
|
||||
}
|
||||
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
|
||||
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
|
||||
}
|
||||
|
||||
wantOutcomeCounts := map[string]int{
|
||||
"rewrite": 8,
|
||||
"ambiguous": 44,
|
||||
}
|
||||
wantSourceCounts := map[string]int{
|
||||
"chat +group-member-list": 1,
|
||||
"chat +group-send-text": 1,
|
||||
"chat +list-group-bots": 1,
|
||||
"chat +list-robot": 1,
|
||||
"chat +list-robots": 1,
|
||||
"chat +members": 3,
|
||||
"chat +message-list": 1,
|
||||
"chat +read-single": 6,
|
||||
"chat +rename-group": 1,
|
||||
"chat +send": 4,
|
||||
"chat +send-by-bot": 3,
|
||||
"chat +send-dm": 2,
|
||||
"chat +send-file": 15,
|
||||
"chat +send-image": 3,
|
||||
"chat +send-media": 2,
|
||||
"chat +send-message": 3,
|
||||
"chat +send-single": 1,
|
||||
"chat +send-text": 2,
|
||||
"chat +send-to": 1,
|
||||
}
|
||||
rewriteTargets := map[string]string{
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool, len(corpus.Badcases))
|
||||
gotOutcomeCounts := make(map[string]int)
|
||||
gotSourceCounts := make(map[string]int)
|
||||
for _, badcase := range corpus.Badcases {
|
||||
badcase := badcase
|
||||
t.Run(badcase.ID, func(t *testing.T) {
|
||||
if badcase.ID == "" || seenIDs[badcase.ID] {
|
||||
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
|
||||
}
|
||||
seenIDs[badcase.ID] = true
|
||||
gotOutcomeCounts[badcase.ExpectedOutcome]++
|
||||
gotSourceCounts[badcase.SourcePath]++
|
||||
|
||||
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
|
||||
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
|
||||
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
|
||||
}
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
switch badcase.ExpectedOutcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
|
||||
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
|
||||
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
|
||||
}
|
||||
case "unknown_shortcut":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
|
||||
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
|
||||
}
|
||||
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
|
||||
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
raw string
|
||||
occurrences int
|
||||
outcome string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
id: "dws_im_v2_0013-search-group",
|
||||
raw: `dws chat +search-group --name "dws测试群02" --format json`,
|
||||
occurrences: 1,
|
||||
outcome: "official_alias",
|
||||
target: "chat +chat-search",
|
||||
},
|
||||
{
|
||||
id: "dws_oa_0017-list-processes",
|
||||
raw: `dws oa +list-processes --format json --limit 50`,
|
||||
occurrences: 2,
|
||||
outcome: "ambiguous",
|
||||
},
|
||||
}
|
||||
totalOccurrences := 0
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.id, func(t *testing.T) {
|
||||
totalOccurrences += test.occurrences
|
||||
args := historicalShortcutBadcaseArgv(t, test.raw)
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
badcase := shortcutHallucinationReplayBadcase{
|
||||
ID: test.id,
|
||||
SourcePath: strings.Join(args[:2], " "),
|
||||
ExpectedOutcome: test.outcome,
|
||||
Raw: test.raw,
|
||||
}
|
||||
switch test.outcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
|
||||
case "official_alias":
|
||||
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
default:
|
||||
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if totalOccurrences != 3 {
|
||||
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalOfficialAlias(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
|
||||
}
|
||||
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
|
||||
t.Helper()
|
||||
command := strings.TrimSpace(raw)
|
||||
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
|
||||
command = strings.TrimSuffix(command, suffix)
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(command)
|
||||
if err != nil {
|
||||
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
|
||||
}
|
||||
if len(argv) < 3 || argv[0] != "dws" {
|
||||
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
|
||||
}
|
||||
return append([]string(nil), argv[1:]...)
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutRewrite(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if wantTarget == "" {
|
||||
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
|
||||
}
|
||||
found := false
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
|
||||
}
|
||||
wantPrefix := strings.Fields(wantTarget)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
|
||||
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutReason(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
wantReason string,
|
||||
) {
|
||||
t.Helper()
|
||||
if ctx == nil {
|
||||
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
|
||||
}
|
||||
if got := structuredShortcutReplayReason(err); got != wantReason {
|
||||
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
|
||||
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func structuredShortcutReplayReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) {
|
||||
return structured.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
|
||||
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
|
||||
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
|
||||
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(context.Background(), engine)
|
||||
runtimeCommand := exactAppCommand(root, "chat +members")
|
||||
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
|
||||
var userDefined []string
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined {
|
||||
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
|
||||
}
|
||||
}
|
||||
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
|
||||
}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +members" {
|
||||
t.Fatalf("runtime shortcut context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("runtime shortcut execute: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
shortcutDir := filepath.Join(configDir, "shortcuts")
|
||||
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := `version: 1
|
||||
service: chat
|
||||
command: "+members"
|
||||
product: chat
|
||||
description: runtime members fixture
|
||||
execute:
|
||||
tool: fixture_user_members
|
||||
bind:
|
||||
sentinel: "${sentinel}"
|
||||
flags:
|
||||
- name: sentinel
|
||||
type: string
|
||||
required: true
|
||||
desc: fixture sentinel
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
|
||||
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "group search query",
|
||||
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("fallback execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("fallback correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--keyword", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalKeywordAttempts) != 0 {
|
||||
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
|
||||
}
|
||||
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(nativeCanonicalAttempts) != 0 {
|
||||
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "search group shortcut alias",
|
||||
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalKeyword,
|
||||
},
|
||||
{
|
||||
name: "chat group search shortcut alias",
|
||||
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
{
|
||||
name: "hidden native compatibility leaf",
|
||||
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat group search",
|
||||
wantPreview: nativeCanonical,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("official alias execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
name: "members",
|
||||
args: []string{"chat", "+members", "--group", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "group member list",
|
||||
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "list group bots",
|
||||
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robot",
|
||||
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robots",
|
||||
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "bot list",
|
||||
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "conversation detail",
|
||||
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
|
||||
target: "chat +conversation-info",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("read fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := strings.Fields(test.target)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
|
||||
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
|
||||
t.Fatalf("official group-members alias context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("rename fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := []string{"chat", "+chat-update"}
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
|
||||
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
|
||||
for _, source := range sources {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("history-save fallback context = %#v", ctx)
|
||||
}
|
||||
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
if !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("history-save correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
|
||||
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
|
||||
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if ctx == nil {
|
||||
t.Fatal("+export-pdf returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
|
||||
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
candidates []string
|
||||
}{
|
||||
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
|
||||
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
|
||||
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
|
||||
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
|
||||
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
|
||||
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
|
||||
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
args := append(strings.Fields(test.path), "--format", "json")
|
||||
ctx, err := executeParamAliasE2E(t, caller, args...)
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous command fallback returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
|
||||
t.Fatalf("ambiguous error = %T %#v", err, err)
|
||||
}
|
||||
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
|
||||
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
|
||||
}
|
||||
for _, candidate := range test.candidates {
|
||||
if !strings.Contains(structured.Hint, "dws "+candidate) {
|
||||
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" {
|
||||
t.Fatalf("canonical context = %#v", ctx)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing required query",
|
||||
args: []string{"chat", "+group-search", "--dry-run"},
|
||||
want: "请至少指定 --query、--keyword 之一",
|
||||
},
|
||||
{
|
||||
name: "unknown canonical flag",
|
||||
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
|
||||
want: "unknown flag",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
|
||||
ctx.Corrections[0].Handler != "command-path-fallback" {
|
||||
t.Fatalf("rewritten context = %#v", ctx)
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+bot-list": true,
|
||||
"+conversation-detail": true,
|
||||
"+conversation-category-list": true,
|
||||
"+conversation-group-list": true,
|
||||
"+list-my-groups": true,
|
||||
"+group-search": true,
|
||||
"+members": true,
|
||||
"+group-member-list": true,
|
||||
"+list-group-bots": true,
|
||||
"+list-robot": true,
|
||||
"+list-robots": true,
|
||||
"+message-list": true,
|
||||
"+read-single": true,
|
||||
"+rename-group": true,
|
||||
"+send": true,
|
||||
"+send-by-bot": true,
|
||||
"+send-dm": true,
|
||||
"+send-message": true,
|
||||
"+send-single": true,
|
||||
"+send-text": true,
|
||||
"+send-to": true,
|
||||
"+send-file": true,
|
||||
"+send-image": true,
|
||||
"+send-media": true,
|
||||
"+group-send-text": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
chat := exactAppCommand(root, "chat")
|
||||
if chat == nil {
|
||||
t.Fatal("chat command missing")
|
||||
}
|
||||
for _, child := range chat.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("fallback name %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("fallback name %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("chat " + path); ok {
|
||||
t.Fatalf("fallback path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
|
||||
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "oa" && declared.Command == "+list-processes" {
|
||||
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
|
||||
}
|
||||
}
|
||||
|
||||
group := exactAppCommand(root, "chat group")
|
||||
searchCompatibility := exactAppCommand(root, "chat group search")
|
||||
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
|
||||
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
|
||||
}
|
||||
var help bytes.Buffer
|
||||
group.SetOut(&help)
|
||||
if err := group.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(help.String(), "\n search ") {
|
||||
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+create-version": true,
|
||||
"+save-version": true,
|
||||
"+snapshot": true,
|
||||
"+version-create": true,
|
||||
"+export-pdf": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
doc := exactAppCommand(root, "doc")
|
||||
if doc == nil {
|
||||
t.Fatal("doc command missing")
|
||||
}
|
||||
for _, child := range doc.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("invalid shortcut %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("doc " + path); ok {
|
||||
t.Fatalf("invalid path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
|
||||
t.Helper()
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
current := root
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) > 0 && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
for _, part := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == part {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func executeDevAppListRoot(t *testing.T, args ...string) (string, string, error) {
|
||||
return executeDevAppRoot(t, "+list", args...)
|
||||
}
|
||||
|
||||
func executeDevAppRoot(t *testing.T, command string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
root := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs(append([]string{"devapp", command}, args...))
|
||||
err := root.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppPaginatedShortcutsMock(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
command string
|
||||
collection string
|
||||
args []string
|
||||
}{
|
||||
{name: "apps", command: "+list", collection: "apps"},
|
||||
{name: "permissions", command: "+permission-list", collection: "permissions", args: []string{"--unified-app-id", "X"}},
|
||||
{name: "events", command: "+event-list", collection: "events", args: []string{"--unified-app-id", "X"}},
|
||||
{name: "versions", command: "+version-list", collection: "versions", args: []string{"--unified-app-id", "X"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := append(append([]string(nil), test.args...), "--mock", "--format", "json")
|
||||
stdout, stderr, err := executeDevAppRoot(t, test.command, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("mock %s error = %v, stderr=%q", test.command, err, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("mock %s stderr = %q", test.command, stderr)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("decode mock %s output: %v, output=%q", test.command, err, stdout)
|
||||
}
|
||||
items, ok := payload[test.collection].([]any)
|
||||
if len(payload) != 4 || !ok || len(items) != 0 || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("mock %s payload = %#v", test.command, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListMockPaginationFormats(t *testing.T) {
|
||||
for _, format := range []string{"json", "raw", "ndjson", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
if format == "pretty" {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
}
|
||||
stdout, stderr, err := executeDevAppListRoot(t, "--mock", "--format", format)
|
||||
if err != nil {
|
||||
t.Fatalf("mock list error = %v, stderr=%q", err, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("mock list stderr = %q", stderr)
|
||||
}
|
||||
if format == "pretty" {
|
||||
values := map[string]string{}
|
||||
lines := strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("pretty mock lines = %d, want four: %q", len(lines), stdout)
|
||||
}
|
||||
for _, line := range lines {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("pretty mock line is not key/value: %q", line)
|
||||
}
|
||||
values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(values) != 4 || values["apps"] != "[]" || values["count"] != "0" ||
|
||||
values["hasMore"] != "false" || values["nextCursor"] != "" {
|
||||
t.Fatalf("pretty mock values = %#v, output=%q", values, stdout)
|
||||
}
|
||||
return
|
||||
}
|
||||
if format == "ndjson" && len(strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")) != 1 {
|
||||
t.Fatalf("ndjson mock output expanded envelope: %q", stdout)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("decode mock output: %v, output=%q", err, stdout)
|
||||
}
|
||||
apps, ok := payload["apps"].([]any)
|
||||
if len(payload) != 4 || !ok || len(apps) != 0 || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != false || payload["nextCursor"] != "" {
|
||||
t.Fatalf("mock pagination payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type devAppListFixtureRunner struct {
|
||||
response string
|
||||
}
|
||||
|
||||
func (r devAppListFixtureRunner) Run(
|
||||
_ context.Context,
|
||||
invocation executor.Invocation,
|
||||
) (executor.Result, error) {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": r.response}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListInvalidContractJSONError(t *testing.T) {
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(*GlobalFlags) executor.Runner {
|
||||
return devAppListFixtureRunner{response: `{
|
||||
"apps":[{"unifiedAppId":"would-be-partial"},"payload-do-not-leak"],
|
||||
"hasMore":false,
|
||||
"nextCursor":""
|
||||
}`}
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var cobraStderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&cobraStderr)
|
||||
root.SetArgs([]string{"devapp", "+list", "--cursor", "cursor-do-not-leak", "--format", "json"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("invalid pagination contract unexpectedly succeeded")
|
||||
}
|
||||
if stdout.Len() != 0 || cobraStderr.Len() != 0 {
|
||||
t.Fatalf("invalid contract leaked command output: stdout=%q stderr=%q", stdout.String(), cobraStderr.String())
|
||||
}
|
||||
if code := apperrors.ExitCode(err); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1", code)
|
||||
}
|
||||
|
||||
var machineStderr bytes.Buffer
|
||||
if printErr := printExecutionError(root, &stdout, &machineStderr, err); printErr != nil {
|
||||
t.Fatalf("print JSON error: %v", printErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("JSON error wrote stdout: %q", stdout.String())
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(machineStderr.String()))
|
||||
var payload map[string]any
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
t.Fatalf("decode stderr JSON: %v, stderr=%q", err, machineStderr.String())
|
||||
}
|
||||
if err := decoder.Decode(&map[string]any{}); err != io.EOF {
|
||||
t.Fatalf("stderr contained more than one JSON value: %v, stderr=%q", err, machineStderr.String())
|
||||
}
|
||||
errorPayload, ok := payload["error"].(map[string]any)
|
||||
message, messageOK := errorPayload["message"].(string)
|
||||
if len(payload) != 1 || !ok || len(errorPayload) != 4 ||
|
||||
errorPayload["category"] != "api" || errorPayload["code"] != float64(1) ||
|
||||
errorPayload["reason"] != "devapp_pagination_contract_invalid" ||
|
||||
!messageOK || strings.TrimSpace(message) == "" {
|
||||
t.Fatalf("JSON error contract = %#v", payload)
|
||||
}
|
||||
if strings.Contains(machineStderr.String(), "cursor-do-not-leak") ||
|
||||
strings.Contains(machineStderr.String(), "payload-do-not-leak") ||
|
||||
strings.Contains(machineStderr.String(), "would-be-partial") {
|
||||
t.Fatalf("JSON error leaked request or response data: %q", machineStderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListMockIsolation(t *testing.T) {
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
runner := newCommandRunnerWithFlags(&GlobalFlags{Mock: true})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
product string
|
||||
tool string
|
||||
}{
|
||||
{name: "other devapp tool", product: "devapp", tool: "get_dev_app"},
|
||||
{name: "other product", product: "contact", tool: "list_contacts"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
"mock.isolation", test.product, test.tool, map[string]any{},
|
||||
)
|
||||
result, err := runner.Run(context.Background(), invocation)
|
||||
if err != nil {
|
||||
t.Fatalf("mock invocation error = %v", err)
|
||||
}
|
||||
content, ok := result.Response["content"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("mock content = %#v", result.Response["content"])
|
||||
}
|
||||
mockResult, ok := content["result"].([]any)
|
||||
if !ok || len(mockResult) != 0 {
|
||||
t.Fatalf("non-target mock result changed = %#v", content["result"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,14 @@ func paramAliasResponseForTool(tool string) string {
|
||||
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
|
||||
case "search_groups":
|
||||
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
|
||||
case "search_contact_by_key_word":
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "get_document_content":
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
}
|
||||
@@ -165,6 +173,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
|
||||
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("flag-list dry-run error = %v", err)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
|
||||
t.Fatalf("flag-list dry-run preview = %#v", preview)
|
||||
}
|
||||
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
|
||||
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
originalArgs := os.Args
|
||||
@@ -191,7 +217,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
|
||||
return ctx, root.Execute()
|
||||
}
|
||||
|
||||
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation []string
|
||||
@@ -244,7 +270,7 @@ func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
start := "2026-03-10T14:00:00+08:00"
|
||||
end := "2026-03-10T18:00:00+08:00"
|
||||
@@ -279,7 +305,7 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
@@ -311,7 +337,7 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
@@ -390,7 +416,7 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
entries, err := cli.ReduceParamAliases(root)
|
||||
@@ -478,7 +504,7 @@ func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
|
||||
func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
want string
|
||||
@@ -531,7 +557,7 @@ func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
const command = "chat message list-direct"
|
||||
if _, ok := cli.LookupParamAlias(command); ok {
|
||||
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
|
||||
@@ -556,7 +582,7 @@ func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
@@ -653,7 +679,7 @@ func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
for _, args := range [][]string{
|
||||
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
|
||||
@@ -678,7 +704,7 @@ func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -770,7 +796,7 @@ func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
emitted string
|
||||
@@ -817,7 +843,7 @@ func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
|
||||
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
|
||||
if err != want {
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
// - expect=<realFlag> : emitted must reduce to that canonical flag.
|
||||
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
|
||||
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
|
||||
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -113,13 +113,19 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
|
||||
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
if len(flagArgs) == 0 {
|
||||
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
got := flagArgs[0]
|
||||
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
|
||||
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
|
||||
switch {
|
||||
case got == "--"+c.Expect:
|
||||
case hasInlineValue && inlineValue != fixtureValue:
|
||||
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
|
||||
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
switch {
|
||||
case gotBare == c.Expect:
|
||||
// (1) rewritten; the embedded table must agree.
|
||||
if !hasEntry {
|
||||
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
|
||||
|
||||
@@ -30,13 +30,34 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
|
||||
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
|
||||
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
|
||||
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
|
||||
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
|
||||
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
@@ -81,9 +102,36 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
|
||||
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
@@ -101,6 +149,13 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
@@ -111,6 +166,9 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
},
|
||||
}
|
||||
|
||||
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
|
||||
@@ -149,6 +207,38 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "title", canonical: "name"},
|
||||
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
|
||||
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
|
||||
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
|
||||
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
|
||||
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
|
||||
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
|
||||
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
|
||||
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
|
||||
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
|
||||
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
|
||||
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
|
||||
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
@@ -163,14 +253,33 @@ var paramAliasNewIMCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
|
||||
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
@@ -273,8 +382,9 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
@@ -290,13 +400,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
activeAliases++
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
@@ -308,6 +421,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
// --time. Alias equivalence concerns the resolved target and transport shape;
|
||||
// a suite crossing a second boundary must not make that default appear
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -55,7 +55,7 @@ func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
"chat", "message", "send",
|
||||
@@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
t.Fatalf("--debug details missing from early error:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantReason string
|
||||
wantCommand string
|
||||
}{
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_shortcut",
|
||||
wantCommand: "dws chat",
|
||||
},
|
||||
{
|
||||
name: "unknown dev app subcommand",
|
||||
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws dev app",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
err = newPreParseValidationError(err)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
|
||||
t.Fatalf("command error leaked flag classification: %#v", structured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
|
||||
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
|
||||
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
|
||||
t.Fatalf("resolved shortcut flag error = %#v", structured)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ func Execute() (exitCode int) {
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
if structured, ok := err.(*apperrors.Error); ok {
|
||||
return structured
|
||||
}
|
||||
userErr := err
|
||||
var handlerErr *pipeline.HandlerError
|
||||
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
|
||||
@@ -1324,6 +1327,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// - PostResponse: after transport returns, before stdout (canonical RunE)
|
||||
func newPipelineEngine() *pipeline.Engine {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
|
||||
entry, ok := cli.LookupCommandPathFallback(path)
|
||||
if !ok {
|
||||
return pipeline.CommandPathFallback{}, false
|
||||
}
|
||||
return pipeline.CommandPathFallback{
|
||||
From: entry.From,
|
||||
Mode: string(entry.Mode),
|
||||
To: entry.To,
|
||||
Candidates: append([]string(nil), entry.Candidates...),
|
||||
}, true
|
||||
})
|
||||
engine.RegisterAll(
|
||||
// Register handler runs during command tree building.
|
||||
handlers.RegisterHandler{},
|
||||
|
||||
+17
-1
@@ -589,13 +589,29 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
// Mock mode: return predefined mock response without network call.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
invocation.Implemented = true
|
||||
result := any([]any{})
|
||||
if invocation.CanonicalProduct == "devapp" {
|
||||
collectionKey := map[string]string{
|
||||
"list_dev_app": "apps",
|
||||
"list_dev_app_permissions": "items",
|
||||
"list_dev_app_events": "events",
|
||||
"list_dev_app_versions": "items",
|
||||
}[invocation.Tool]
|
||||
if collectionKey != "" {
|
||||
result = map[string]any{
|
||||
collectionKey: []any{},
|
||||
"hasMore": false,
|
||||
"nextCursor": "",
|
||||
}
|
||||
}
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": map[string]any{
|
||||
"success": true,
|
||||
"result": []any{},
|
||||
"result": result,
|
||||
"_mock": true,
|
||||
"_tool": invocation.Tool,
|
||||
},
|
||||
|
||||
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.create_with_data",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 294
|
||||
publicShortcutCount = 357
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 295
|
||||
schemaPublishedShortcutCount = 358
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
publiclyDeliveredShortcutCount = 357
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 180; got != want {
|
||||
if got, want := int(product["count"].(float64)), 187; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
{
|
||||
"source_report": "param_hallucination_20260728_120943",
|
||||
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
|
||||
"source_dws_commit": "a8e83e5",
|
||||
"model": "dashscope/qwen3.7-max",
|
||||
"expected_count": 52,
|
||||
"badcases": [
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command0",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command1",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command2",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members \"dws测试群01\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run8-command4",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 8,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command0",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command3",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command4",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command1",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command2",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
|
||||
"original_exit_code": 0,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command8",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run4-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --help",
|
||||
"original_exit_code": 64,
|
||||
"is_help": true
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command4",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command5",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command6",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0016-run1-command7",
|
||||
"case_id": "dws_im_v3_0016",
|
||||
"run": 1,
|
||||
"command_index": 7,
|
||||
"turn": 3,
|
||||
"source_path": "chat +rename-group",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command9",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 9,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run5-command0",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 5,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +group-member-list",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command8",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 8,
|
||||
"turn": 2,
|
||||
"source_path": "chat +group-send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0018-run10-command5",
|
||||
"case_id": "dws_im_v3_0018",
|
||||
"run": 10,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command1",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run6-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 6,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command6",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command9",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-to",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run9-command0",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 9,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command3",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --user-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command4",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --uid 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command5",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --staff-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command6",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command7",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 7,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --target 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command8",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single 550582 --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run5-command1",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 5,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +message-list",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +message-list --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run4-command5",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robot",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run5-command15",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 5,
|
||||
"command_index": 15,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-group-bots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run9-command9",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 9,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
|
||||
|
||||
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
|
||||
// observed in evaluation. It is deliberately not a command identity source:
|
||||
// Help, Skill, Schema, and normal navigation continue to publish canonical
|
||||
// paths from the CommandRegistry and the real Cobra tree only.
|
||||
|
||||
//go:embed command_path_fallbacks.json
|
||||
var embeddedCommandPathFallbacksJSON []byte
|
||||
|
||||
//go:embed command_path_fallbacks.schema.json
|
||||
var embeddedCommandPathFallbacksSchemaJSON []byte
|
||||
|
||||
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
|
||||
|
||||
// CommandPathFallbackMode determines whether an invalid reviewed path can be
|
||||
// normalized exactly or must stop and surface reviewed candidates.
|
||||
type CommandPathFallbackMode string
|
||||
|
||||
const (
|
||||
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
|
||||
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
|
||||
)
|
||||
|
||||
// CommandPathFallback is one validated, generated runtime recovery record.
|
||||
// From is never advertised as a stable alias. To and Candidates are canonical
|
||||
// real Cobra paths validated at generation time.
|
||||
type CommandPathFallback struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
type commandPathFallbackSnapshot struct {
|
||||
Schema string `json:"$schema"`
|
||||
Version int `json:"version"`
|
||||
Entries []commandPathFallbackEntrySpec `json:"entries"`
|
||||
}
|
||||
|
||||
type commandPathFallbackEntrySpec struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedCommandPathFallbacksOnce sync.Once
|
||||
embeddedCommandPathFallbacksData []CommandPathFallback
|
||||
embeddedCommandPathFallbacksErr error
|
||||
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
|
||||
)
|
||||
|
||||
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
|
||||
// Callers receive a clone so generation and tests cannot mutate shared data.
|
||||
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
return loadReviewedCommandPathFallbacks()
|
||||
}
|
||||
|
||||
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
embeddedCommandPathFallbacksOnce.Do(func() {
|
||||
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
|
||||
})
|
||||
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
|
||||
}
|
||||
|
||||
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
|
||||
var snapshot commandPathFallbackSnapshot
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
|
||||
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
|
||||
}
|
||||
if len(snapshot.Entries) == 0 {
|
||||
return nil, fmt.Errorf("command path fallbacks declares no entries")
|
||||
}
|
||||
|
||||
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
|
||||
seenFrom := make(map[string]bool, len(snapshot.Entries))
|
||||
for index, spec := range snapshot.Entries {
|
||||
from, err := validateAuthoredCommandFallbackPath(spec.From)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
|
||||
}
|
||||
if seenFrom[from] {
|
||||
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
|
||||
}
|
||||
seenFrom[from] = true
|
||||
reason := strings.TrimSpace(spec.ReviewReason)
|
||||
if !spec.Reviewed || reason == "" {
|
||||
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
|
||||
}
|
||||
|
||||
entry := CommandPathFallback{
|
||||
From: from,
|
||||
Mode: spec.Mode,
|
||||
Reviewed: true,
|
||||
ReviewReason: reason,
|
||||
}
|
||||
switch spec.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if len(spec.Candidates) > 0 {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
|
||||
}
|
||||
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
|
||||
}
|
||||
entry.To = to
|
||||
case CommandPathFallbackAmbiguous:
|
||||
if strings.TrimSpace(spec.To) != "" {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
|
||||
}
|
||||
if len(spec.Candidates) < 2 {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
|
||||
}
|
||||
seenCandidates := make(map[string]bool, len(spec.Candidates))
|
||||
for _, rawCandidate := range spec.Candidates {
|
||||
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
|
||||
}
|
||||
if seenCandidates[candidate] {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
|
||||
}
|
||||
seenCandidates[candidate] = true
|
||||
entry.Candidates = append(entry.Candidates, candidate)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
|
||||
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
|
||||
// live distribution-owned Cobra tree. It refuses to turn an existing command
|
||||
// or stable alias into a hidden rewrite. The sole exception is an explicitly
|
||||
// annotated hint-only compatibility node, which contains no business action.
|
||||
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
|
||||
if root == nil {
|
||||
return nil, fmt.Errorf("command path fallback source root is nil")
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byFrom := make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
byFrom[entry.From] = entry
|
||||
}
|
||||
var problems []string
|
||||
for _, entry := range entries {
|
||||
match, resolveErr := resolveExactCobraPath(root, entry.From)
|
||||
if resolveErr != nil {
|
||||
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
|
||||
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
|
||||
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if _, chained := byFrom[entry.To]; chained {
|
||||
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
|
||||
}
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
|
||||
case CommandPathFallbackAmbiguous:
|
||||
for _, candidate := range entry.Candidates {
|
||||
if _, chained := byFrom[candidate]; chained {
|
||||
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
|
||||
}
|
||||
// Ambiguous recovery never dispatches. It may therefore point the
|
||||
// caller at both canonical shortcuts and native leaves, while exact
|
||||
// rewrites keep the stricter +shortcut identity boundary.
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
|
||||
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
|
||||
var problems []string
|
||||
if commandFallbackService(from) != commandFallbackService(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
|
||||
}
|
||||
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
|
||||
}
|
||||
match, err := resolveExactCobraPath(root, target)
|
||||
if err != nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
|
||||
}
|
||||
if match.Command == nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
|
||||
}
|
||||
if match.UsedAlias {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
|
||||
}
|
||||
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func commandFallbackService(path string) string {
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func commandFallbackHasShortcut(path string) bool {
|
||||
for _, part := range strings.Fields(path) {
|
||||
if strings.HasPrefix(part, "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
|
||||
cloned := make([]CommandPathFallback, len(entries))
|
||||
for index, entry := range entries {
|
||||
cloned[index] = entry
|
||||
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$schema": "./command_path_fallbacks.schema.json",
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"from": "chat +group-search",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
|
||||
},
|
||||
{
|
||||
"from": "chat +members",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-member-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-group-bots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robot",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +message-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +chat-messages",
|
||||
"chat +messages-list-direct",
|
||||
"chat +search-msg",
|
||||
"chat +unread-chats"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +read-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-list-direct",
|
||||
"chat +chat-messages"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
|
||||
},
|
||||
{
|
||||
"from": "chat +rename-group",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-update",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
|
||||
},
|
||||
{
|
||||
"from": "chat +send",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-message",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-to",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-dm",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-by-bot",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send-by-bot"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +send-to-group",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-file",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-image",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-media",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +conversation-info",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
|
||||
},
|
||||
{
|
||||
"from": "chat +bot-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-category-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list",
|
||||
"chat +category-list-conversations"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-group-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list-conversations",
|
||||
"chat +conversation-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-my-groups",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +my-groups",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
|
||||
},
|
||||
{
|
||||
"from": "doc +list-templates",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-list",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
|
||||
},
|
||||
{
|
||||
"from": "doc +search-template",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
|
||||
},
|
||||
{
|
||||
"from": "doc +template",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +create-from-template"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
|
||||
},
|
||||
{
|
||||
"from": "doc +version",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +history-list",
|
||||
"doc +history-save",
|
||||
"doc +history-revert"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
|
||||
},
|
||||
{
|
||||
"from": "doc +create-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
|
||||
},
|
||||
{
|
||||
"from": "doc +save-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
|
||||
},
|
||||
{
|
||||
"from": "doc +snapshot",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
|
||||
},
|
||||
{
|
||||
"from": "doc +version-create",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
|
||||
"title": "DWS Reviewed Command Path Fallbacks",
|
||||
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"$schema",
|
||||
"version",
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "./command_path_fallbacks.schema.json"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"commandPath": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
|
||||
"description": "Exact CLI path without a leading dws or flags."
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"mode",
|
||||
"reviewed",
|
||||
"review_reason"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"rewrite",
|
||||
"ambiguous"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"uniqueItems": true,
|
||||
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
}
|
||||
},
|
||||
"reviewed": {
|
||||
"const": true
|
||||
},
|
||||
"review_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "rewrite"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"to"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"candidates"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "ambiguous"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"candidates"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"to"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
|
||||
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
|
||||
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
|
||||
|
||||
package cli
|
||||
|
||||
var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "chat +bot-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-category-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-detail",
|
||||
Mode: "rewrite",
|
||||
To: "chat +conversation-info",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-group-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-member-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-search",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-group-bots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-my-groups",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robot",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +members",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
|
||||
},
|
||||
{
|
||||
From: "chat +message-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +read-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
|
||||
},
|
||||
{
|
||||
From: "chat +rename-group",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-update",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
|
||||
},
|
||||
{
|
||||
From: "chat +send",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-by-bot",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-dm",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-file",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-image",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-media",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-message",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-to",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
|
||||
},
|
||||
{
|
||||
From: "doc +create-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
|
||||
},
|
||||
{
|
||||
From: "doc +list-templates",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-list",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
|
||||
},
|
||||
{
|
||||
From: "doc +save-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
|
||||
},
|
||||
{
|
||||
From: "doc +search-template",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
|
||||
},
|
||||
{
|
||||
From: "doc +snapshot",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
|
||||
},
|
||||
{
|
||||
From: "doc +template",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
|
||||
},
|
||||
{
|
||||
From: "doc +version",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
|
||||
},
|
||||
{
|
||||
From: "doc +version-create",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
|
||||
},
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
},
|
||||
}
|
||||
|
||||
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
|
||||
return generatedCommandPathFallbacks
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
commandPathFallbackIndexOnce sync.Once
|
||||
commandPathFallbackIndex map[string]CommandPathFallback
|
||||
)
|
||||
|
||||
func buildCommandPathFallbackIndex() {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
commandPathFallbackIndex[entry.From] = entry
|
||||
}
|
||||
}
|
||||
|
||||
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
|
||||
// recovery-only path. Normalization only removes a leading dws and folds
|
||||
// whitespace; it does not apply prefix, typo, or semantic matching.
|
||||
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
|
||||
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
|
||||
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
|
||||
if !ok {
|
||||
return CommandPathFallback{}, false
|
||||
}
|
||||
entry.Candidates = append([]string(nil), entry.Candidates...)
|
||||
return entry, true
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func commandFallbackJSON(entries string) []byte {
|
||||
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
|
||||
}
|
||||
|
||||
func validCommandFallbackEntry(from, to string) string {
|
||||
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
|
||||
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
|
||||
entries, err := decodeCommandPathFallbacks(valid)
|
||||
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
|
||||
t.Fatalf("decode valid = %#v, %v", entries, err)
|
||||
}
|
||||
entries[0].Candidates = append(entries[0].Candidates, "mutated")
|
||||
cloned := cloneCommandPathFallbacks(entries)
|
||||
cloned[0].Candidates[0] = "clone"
|
||||
if entries[0].Candidates[0] != "mutated" {
|
||||
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
"unknown field": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
|
||||
want: "unknown field",
|
||||
},
|
||||
"wrong schema": {
|
||||
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "$schema",
|
||||
},
|
||||
"wrong version": {
|
||||
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "unsupported",
|
||||
},
|
||||
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
|
||||
"unnormalized path": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
|
||||
want: "not a normalized",
|
||||
},
|
||||
"unreviewed": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"empty reason": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"invalid mode": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "invalid mode",
|
||||
},
|
||||
"rewrite candidates": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare candidates",
|
||||
},
|
||||
"empty rewrite target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"ambiguous target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare to",
|
||||
},
|
||||
"ambiguous too small": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "at least two",
|
||||
},
|
||||
"empty ambiguous candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"duplicate candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "repeats candidate",
|
||||
},
|
||||
"duplicate from": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
|
||||
want: "duplicate from",
|
||||
},
|
||||
"multiple documents": {
|
||||
data: append(valid, []byte(` {}`)...),
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("decode error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
|
||||
t.Fatalf("decode embedded schema: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
|
||||
t.Fatalf("schema header = %#v", schema)
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil || len(entries) != 34 {
|
||||
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
|
||||
}
|
||||
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
|
||||
t.Fatalf("generated lookup = %#v, %v", got, ok)
|
||||
}
|
||||
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
|
||||
t.Fatal("generated lookup accepted unknown path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
rewrites := map[string]string{
|
||||
"chat +bot-list": "chat +chat-bots",
|
||||
"chat +conversation-detail": "chat +conversation-info",
|
||||
"chat +group-search": "chat +chat-search",
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
"doc +create-version": "doc +history-save",
|
||||
"doc +list-templates": "doc +template-list",
|
||||
"doc +save-version": "doc +history-save",
|
||||
"doc +search-template": "doc +template-search",
|
||||
"doc +snapshot": "doc +history-save",
|
||||
"doc +version-create": "doc +history-save",
|
||||
}
|
||||
for from, to := range rewrites {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
|
||||
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
|
||||
}
|
||||
}
|
||||
|
||||
ambiguous := map[string][]string{
|
||||
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
|
||||
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
|
||||
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
|
||||
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
|
||||
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
|
||||
"chat +send-dm": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-single": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
for from, candidates := range ambiguous {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
|
||||
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
|
||||
}
|
||||
}
|
||||
|
||||
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
|
||||
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
|
||||
root := commandFallbackTestRoot()
|
||||
entries := []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
}
|
||||
withCommandFallbackSource(t, entries)
|
||||
got, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || len(got) != len(entries) {
|
||||
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
|
||||
}
|
||||
got[2].Candidates[0] = "mutated"
|
||||
again, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || again[2].Candidates[0] == "mutated" {
|
||||
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
|
||||
baseRoot := commandFallbackTestRoot
|
||||
tests := map[string]struct {
|
||||
entries []CommandPathFallback
|
||||
mutate func(*cobra.Command)
|
||||
want string
|
||||
}{
|
||||
"missing target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"real source collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cobra alias collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cross service": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
|
||||
want: "crosses service boundary",
|
||||
},
|
||||
"shortcut mismatch": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
|
||||
want: "disagree on +shortcut identity",
|
||||
},
|
||||
"chained rewrite": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
|
||||
},
|
||||
want: "chained fallbacks are forbidden",
|
||||
},
|
||||
"chained ambiguous candidate": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
|
||||
},
|
||||
want: "is another fallback source",
|
||||
},
|
||||
"missing ambiguous candidate": {
|
||||
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"hidden target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
exactSchemaCommand(root, "chat +good").Hidden = true
|
||||
},
|
||||
want: "not a public runnable",
|
||||
},
|
||||
"target is alias": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
|
||||
want: "must use canonical Cobra names",
|
||||
},
|
||||
"ambiguous source command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "cannot be resolved safely",
|
||||
},
|
||||
"ambiguous target command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "target \"chat +duplicate\" cannot be resolved safely",
|
||||
},
|
||||
"unsupported reduced mode": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
|
||||
want: "unsupported mode",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := baseRoot()
|
||||
if test.mutate != nil {
|
||||
test.mutate(root)
|
||||
}
|
||||
for index := range test.entries {
|
||||
test.entries[index].Reviewed = true
|
||||
test.entries[index].ReviewReason = "fixture"
|
||||
}
|
||||
withCommandFallbackSource(t, test.entries)
|
||||
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
withCommandFallbackLoadError(t, errors.New("fixture load"))
|
||||
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
|
||||
t.Fatalf("load error = %v", err)
|
||||
}
|
||||
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
|
||||
t.Fatalf("nil root error = %v", err)
|
||||
}
|
||||
if got := commandFallbackService(""); got != "" {
|
||||
t.Fatalf("commandFallbackService(empty) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
|
||||
chat.AddCommand(
|
||||
good,
|
||||
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
|
||||
chat.AddCommand(group)
|
||||
mail := &cobra.Command{Use: "mail"}
|
||||
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat, mail)
|
||||
return root
|
||||
}
|
||||
|
||||
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func withCommandFallbackLoadError(t *testing.T, want error) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("generated command path fallback table is empty")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
|
||||
t.Fatalf("invalid generated entry: %#v", entry)
|
||||
}
|
||||
seen[entry.From] = true
|
||||
}
|
||||
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
|
||||
t.Fatal("generated table clone changed values")
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -27,7 +27,9 @@
|
||||
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
|
||||
// (active bindings retired; ParamDecl.Property owns delivery)
|
||||
// 4. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// 5. command_path_fallbacks.json + schema
|
||||
// reviewed recovery-only invalid paths
|
||||
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
|
||||
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
|
||||
@@ -35,7 +37,9 @@
|
||||
//
|
||||
// Remaining generated output from this file:
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
// - command_path_fallbacks_generated.go invalid-path recovery normalization
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read internal/cli: %v", err)
|
||||
@@ -50,6 +50,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
"cmd_command_path_fallbacks",
|
||||
} {
|
||||
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,32 +10,37 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
|
||||
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
|
||||
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
@@ -91,10 +96,24 @@
|
||||
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
|
||||
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -111,7 +130,48 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
|
||||
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
|
||||
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
|
||||
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
|
||||
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
|
||||
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
|
||||
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
|
||||
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
|
||||
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
|
||||
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
|
||||
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
|
||||
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
|
||||
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
|
||||
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
|
||||
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
|
||||
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
|
||||
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
|
||||
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
|
||||
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -258,8 +318,117 @@
|
||||
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
|
||||
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
|
||||
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
|
||||
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
|
||||
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
|
||||
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
|
||||
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
|
||||
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
|
||||
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
|
||||
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
|
||||
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
|
||||
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
|
||||
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
|
||||
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
|
||||
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
|
||||
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
|
||||
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
|
||||
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
|
||||
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
|
||||
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
|
||||
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
|
||||
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
|
||||
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,10 +592,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.",
|
||||
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Command cmd_command_path_fallbacks validates the reviewed recovery-only
|
||||
// command path table against the live distribution Cobra tree and writes the
|
||||
// deterministic runtime lookup source. It does not add paths to Cobra, Help,
|
||||
// Skill, Schema, or the stable CommandRegistry identity surface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
|
||||
)
|
||||
|
||||
const defaultOutput = "internal/cli/command_path_fallbacks_generated.go"
|
||||
|
||||
var (
|
||||
newCommandFallbackRoot = app.NewSchemaSourceRootCommand
|
||||
reduceCommandFallbackEntries = cli.ReduceCommandPathFallbacks
|
||||
formatCommandFallbackSource = format.Source
|
||||
writeCommandFallbackFile = os.WriteFile
|
||||
exitCommandFallbackProcess = os.Exit
|
||||
)
|
||||
|
||||
func main() {
|
||||
var rootPath string
|
||||
var outputPath string
|
||||
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
|
||||
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated command-path fallback table")
|
||||
flag.Parse()
|
||||
|
||||
if err := generateCommandPathFallbacks(rootPath, outputPath); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func generateCommandPathFallbacks(rootPath, outputPath string) error {
|
||||
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := reduceCommandFallbackEntries(newCommandFallbackRoot())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := renderCommandPathFallbacks(entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCommandFallbackFile(outputPath, source, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated command path fallbacks: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stderr, "generated command path fallbacks: output=%s entries=%d\n", outputPath, len(entries))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutputIsolation(rootPath, outputPath string) error {
|
||||
inputs := []outputguard.Input{
|
||||
{Name: "reviewed command path fallbacks", Path: "internal/cli/command_path_fallbacks.json"},
|
||||
{Name: "reviewed command path fallback schema", Path: "internal/cli/command_path_fallbacks.schema.json"},
|
||||
}
|
||||
target := outputguard.Target{Name: "--output", Path: outputPath}
|
||||
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
|
||||
return err
|
||||
}
|
||||
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
|
||||
}
|
||||
|
||||
func renderCommandPathFallbacks(entries []cli.CommandPathFallback) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("// Copyright 2026 Alibaba Group\n")
|
||||
b.WriteString("// Licensed under the Apache License, Version 2.0\n\n")
|
||||
b.WriteString("// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.\n")
|
||||
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
|
||||
b.WriteString("package cli\n\n")
|
||||
b.WriteString("var generatedCommandPathFallbacks = []CommandPathFallback{\n")
|
||||
for _, entry := range entries {
|
||||
b.WriteString("\t{\n")
|
||||
fmt.Fprintf(&b, "\t\tFrom: %q,\n", entry.From)
|
||||
fmt.Fprintf(&b, "\t\tMode: %q,\n", entry.Mode)
|
||||
if entry.To != "" {
|
||||
fmt.Fprintf(&b, "\t\tTo: %q,\n", entry.To)
|
||||
}
|
||||
if len(entry.Candidates) > 0 {
|
||||
fmt.Fprintf(&b, "\t\tCandidates: %s,\n", renderStringSlice(entry.Candidates))
|
||||
}
|
||||
b.WriteString("\t\tReviewed: true,\n")
|
||||
fmt.Fprintf(&b, "\t\tReviewReason: %q,\n", entry.ReviewReason)
|
||||
b.WriteString("\t},\n")
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
b.WriteString("func loadGeneratedCommandPathFallbacks() []CommandPathFallback {\n")
|
||||
b.WriteString("\treturn generatedCommandPathFallbacks\n")
|
||||
b.WriteString("}\n")
|
||||
|
||||
formatted, err := formatCommandFallbackSource(b.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format generated command path fallbacks: %w", err)
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
func renderStringSlice(values []string) string {
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = fmt.Sprintf("%q", value)
|
||||
}
|
||||
return "[]string{" + strings.Join(quoted, ", ") + "}"
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
exitCommandFallbackProcess(1)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
|
||||
got := reflect.ValueOf(newCommandFallbackRoot).Pointer()
|
||||
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
|
||||
if got != want {
|
||||
t.Fatal("command fallback generator must use the distribution-owned Schema source command tree")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveCommandFallbackGeneratorGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
oldArgs, oldFlags := os.Args, flag.CommandLine
|
||||
oldRoot := newCommandFallbackRoot
|
||||
oldReduce := reduceCommandFallbackEntries
|
||||
oldFormat := formatCommandFallbackSource
|
||||
oldWrite := writeCommandFallbackFile
|
||||
oldExit := exitCommandFallbackProcess
|
||||
t.Cleanup(func() {
|
||||
os.Args, flag.CommandLine = oldArgs, oldFlags
|
||||
newCommandFallbackRoot = oldRoot
|
||||
reduceCommandFallbackEntries = oldReduce
|
||||
formatCommandFallbackSource = oldFormat
|
||||
writeCommandFallbackFile = oldWrite
|
||||
exitCommandFallbackProcess = oldExit
|
||||
})
|
||||
}
|
||||
|
||||
func commandFallbackRepositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackMainWritesGeneratedFile(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
|
||||
flag.CommandLine = flag.NewFlagSet("command-fallback-success", flag.ContinueOnError)
|
||||
os.Args = []string{"cmd_command_path_fallbacks", "-root", commandFallbackRepositoryRoot(t), "-output", output}
|
||||
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
|
||||
return []cli.CommandPathFallback{{
|
||||
From: "demo +bad",
|
||||
Mode: cli.CommandPathFallbackRewrite,
|
||||
To: "demo +good",
|
||||
Reviewed: true,
|
||||
ReviewReason: "fixture",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
main()
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
"func loadGeneratedCommandPathFallbacks() []CommandPathFallback",
|
||||
`From: "demo +bad"`,
|
||||
`To: "demo +good"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("generated source missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGenerateCommandPathFallbackFailurePaths(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
root := commandFallbackRepositoryRoot(t)
|
||||
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
|
||||
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
|
||||
if err := generateCommandPathFallbacks(root, filepath.Join(root, "internal", "cli", "command_path_fallbacks.json")); err == nil {
|
||||
t.Fatal("generator accepted output overlapping reviewed input")
|
||||
}
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
|
||||
return nil, errors.New("reduce")
|
||||
}
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
|
||||
t.Fatalf("reduction error = %v", err)
|
||||
}
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { return nil, nil }
|
||||
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
|
||||
t.Fatalf("format error = %v", err)
|
||||
}
|
||||
formatCommandFallbackSource = format.Source
|
||||
writeCommandFallbackFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
|
||||
t.Fatalf("write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCommandFallbackOutputIsolation(t *testing.T) {
|
||||
root := commandFallbackRepositoryRoot(t)
|
||||
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "fallbacks.go")); err != nil {
|
||||
t.Fatalf("temporary output rejected: %v", err)
|
||||
}
|
||||
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
|
||||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
|
||||
t.Fatalf("non-canonical repository output error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRenderCommandPathFallbacksShape(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
entries := []cli.CommandPathFallback{
|
||||
{
|
||||
From: "demo +bad",
|
||||
Mode: cli.CommandPathFallbackRewrite,
|
||||
To: "demo +good",
|
||||
Reviewed: true,
|
||||
ReviewReason: "rewrite fixture",
|
||||
},
|
||||
{
|
||||
From: "demo +choose",
|
||||
Mode: cli.CommandPathFallbackAmbiguous,
|
||||
Candidates: []string{"demo +one", "demo +two"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "ambiguous fixture",
|
||||
},
|
||||
}
|
||||
data, err := renderCommandPathFallbacks(entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
`Mode: "rewrite"`,
|
||||
`Candidates: []string{"demo +one", "demo +two"}`,
|
||||
`ReviewReason: "ambiguous fixture"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("render missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if got := renderStringSlice(nil); got != "[]string{}" {
|
||||
t.Fatalf("renderStringSlice(nil) = %q", got)
|
||||
}
|
||||
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
|
||||
if _, err := renderCommandPathFallbacks(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
|
||||
t.Fatalf("formatter error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackMainReportsFailure(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
flag.CommandLine = flag.NewFlagSet("command-fallback-failure", flag.ContinueOnError)
|
||||
os.Args = []string{
|
||||
"cmd_command_path_fallbacks",
|
||||
"-root", commandFallbackRepositoryRoot(t),
|
||||
"-output", filepath.Join(commandFallbackRepositoryRoot(t), "internal", "cli", "command_path_fallbacks.json"),
|
||||
}
|
||||
exitCommandFallbackProcess = func(code int) { panic(code) }
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != 1 {
|
||||
t.Fatalf("main exit = %#v, want 1", recovered)
|
||||
}
|
||||
}()
|
||||
main()
|
||||
}
|
||||
+179
-94
@@ -5,12 +5,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
@@ -123,113 +126,195 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
}
|
||||
|
||||
// recordQueryFetchAll implements --all auto-pagination for record query.
|
||||
// - pageLimit controls max pages (default 50, 0 = unlimited)
|
||||
// - Mid-loop errors break gracefully, outputting already-fetched data
|
||||
// - Merged result preserves last page's cursor so caller can resume
|
||||
// It prints only a complete result. A page limit, empty/invalid response,
|
||||
// transport failure, or cursor cycle returns a non-zero structured error whose
|
||||
// details retain the incomplete records and retry cursor.
|
||||
func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
|
||||
const pageDelayMs = 200
|
||||
const serverID = "aitable"
|
||||
|
||||
ctx := context.Background()
|
||||
var allRecords []any
|
||||
page := 0
|
||||
lastCursor := ""
|
||||
|
||||
for {
|
||||
page++
|
||||
|
||||
if page > 1 {
|
||||
helperSleep(time.Duration(pageDelayMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[page %d] fetching...\n", page)
|
||||
|
||||
result, err := deps.Caller.CallTool(ctx, serverID, "query_records", toolArgs)
|
||||
if err != nil {
|
||||
if page == 1 {
|
||||
return WrapError(err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[page %d] error, stopping pagination: %v\n", page, err)
|
||||
break
|
||||
}
|
||||
|
||||
// Extract text content from tool result
|
||||
var text string
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" && c.Text != "" {
|
||||
text = c.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
break
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
if page == 1 {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Extract records from response
|
||||
var pageRecords []any
|
||||
if data, ok := resp["data"].(map[string]any); ok {
|
||||
if recs, ok := data["records"].([]any); ok {
|
||||
pageRecords = recs
|
||||
}
|
||||
} else if recs, ok := resp["records"].([]any); ok {
|
||||
pageRecords = recs
|
||||
}
|
||||
allRecords = append(allRecords, pageRecords...)
|
||||
|
||||
// Extract cursor for next page (MCP returns "nextCursor" or "cursor")
|
||||
cursor := ""
|
||||
if data, ok := resp["data"].(map[string]any); ok {
|
||||
if c, ok := data["nextCursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
} else if c, ok := data["cursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
}
|
||||
} else if c, ok := resp["nextCursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
} else if c, ok := resp["cursor"].(string); ok && c != "" {
|
||||
cursor = c
|
||||
}
|
||||
requestArgs := make(map[string]any, len(toolArgs))
|
||||
for key, value := range toolArgs {
|
||||
requestArgs[key] = value
|
||||
}
|
||||
initialCursor, _ := requestArgs["cursor"].(string)
|
||||
|
||||
effectivePageLimit := pageLimit
|
||||
if pageLimit == 0 {
|
||||
effectivePageLimit = paging.UnlimitedPageLimit
|
||||
}
|
||||
result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) {
|
||||
if cursor == "" {
|
||||
lastCursor = ""
|
||||
break
|
||||
delete(requestArgs, "cursor")
|
||||
} else {
|
||||
requestArgs["cursor"] = cursor
|
||||
}
|
||||
|
||||
// Check page limit (0 = unlimited)
|
||||
if pageLimit > 0 && page >= pageLimit {
|
||||
lastCursor = cursor
|
||||
fmt.Fprintf(os.Stderr, "[pagination] reached page limit (%d), stopping. Use --page-limit 0 to fetch all.\n", pageLimit)
|
||||
break
|
||||
toolResult, err := deps.Caller.CallTool(ctx, serverID, "query_records", requestArgs)
|
||||
if err != nil {
|
||||
return paging.Page{}, err
|
||||
}
|
||||
if toolResult == nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned a nil result")
|
||||
}
|
||||
for _, content := range toolResult.Content {
|
||||
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
|
||||
return parseRecordQueryPage(content.Text)
|
||||
}
|
||||
}
|
||||
return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content")
|
||||
}, paging.Options{
|
||||
PageLimit: effectivePageLimit,
|
||||
InterPageDelay: paging.DefaultInterPageDelay,
|
||||
InitialCursor: initialCursor,
|
||||
})
|
||||
|
||||
lastCursor = ""
|
||||
toolArgs["cursor"] = cursor
|
||||
if !result.Complete {
|
||||
return recordQueryIncompleteError(result, pageLimit)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[pagination] done: %d pages, %d total records\n", page, len(allRecords))
|
||||
|
||||
// Build merged output — preserve cursor & has_more for resume capability
|
||||
fmt.Fprintf(os.Stderr, "[pagination] complete: %d pages, %d fetched records\n", result.Pages, len(result.Records))
|
||||
mergedData := map[string]any{
|
||||
"records": allRecords,
|
||||
"totalCount": len(allRecords),
|
||||
"records": result.Records,
|
||||
"fetchedCount": len(result.Records),
|
||||
"hasMore": false,
|
||||
"complete": true,
|
||||
"pages": result.Pages,
|
||||
}
|
||||
if lastCursor != "" {
|
||||
mergedData["cursor"] = lastCursor
|
||||
mergedData["hasMore"] = true
|
||||
} else {
|
||||
mergedData["hasMore"] = false
|
||||
if result.TotalCount != nil {
|
||||
mergedData["totalCount"] = *result.TotalCount
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
|
||||
}
|
||||
|
||||
func parseRecordQueryPage(text string) (paging.Page, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(text))
|
||||
decoder.UseNumber()
|
||||
var response map[string]any
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned invalid JSON: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned more than one JSON value")
|
||||
}
|
||||
return paging.Page{}, fmt.Errorf("query_records returned trailing invalid JSON: %w", err)
|
||||
}
|
||||
if response == nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records returned null instead of an object")
|
||||
}
|
||||
|
||||
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
|
||||
payload := response
|
||||
if rawData, exists := response["data"]; exists {
|
||||
data, ok := rawData.(map[string]any)
|
||||
if !ok {
|
||||
return paging.Page{}, fmt.Errorf("query_records data must be an object, got %T", rawData)
|
||||
}
|
||||
payload = data
|
||||
}
|
||||
|
||||
rawRecords, exists := payload["records"]
|
||||
if !exists {
|
||||
return paging.Page{}, fmt.Errorf("query_records response is missing records")
|
||||
}
|
||||
records, ok := rawRecords.([]any)
|
||||
if !ok {
|
||||
return paging.Page{}, fmt.Errorf("query_records records must be an array, got %T", rawRecords)
|
||||
}
|
||||
for index, record := range records {
|
||||
if _, ok := record.(map[string]any); !ok {
|
||||
return paging.Page{}, fmt.Errorf("query_records records[%d] must be an object, got %T", index, record)
|
||||
}
|
||||
}
|
||||
|
||||
nextCursor := firstNonEmptyString(payload, "nextCursor", "cursor")
|
||||
if hasMore, ok := payload["hasMore"].(bool); ok && hasMore && nextCursor == "" {
|
||||
return paging.Page{}, fmt.Errorf("query_records reported hasMore=true without a next cursor")
|
||||
}
|
||||
totalCount, err := parseOptionalNonNegativeInt(payload["totalCount"])
|
||||
if err != nil {
|
||||
return paging.Page{}, fmt.Errorf("query_records totalCount: %w", err)
|
||||
}
|
||||
return paging.Page{Records: records, NextCursor: nextCursor, TotalCount: totalCount}, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOptionalNonNegativeInt(value any) (*int, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var parsed int64
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
integer, err := typed.Int64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("must be an integer, got %q", typed)
|
||||
}
|
||||
parsed = integer
|
||||
case float64:
|
||||
if math.Trunc(typed) != typed {
|
||||
return nil, fmt.Errorf("must be an integer, got %v", typed)
|
||||
}
|
||||
parsed = int64(typed)
|
||||
case int:
|
||||
parsed = int64(typed)
|
||||
case int64:
|
||||
parsed = typed
|
||||
default:
|
||||
return nil, fmt.Errorf("must be a non-negative integer, got %T", value)
|
||||
}
|
||||
if parsed < 0 || int64(int(parsed)) != parsed {
|
||||
return nil, fmt.Errorf("must fit a non-negative int, got %d", parsed)
|
||||
}
|
||||
result := int(parsed)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func recordQueryIncompleteError(result paging.Result, pageLimit int) error {
|
||||
incomplete := map[string]any{
|
||||
"records": result.Records,
|
||||
"fetchedCount": len(result.Records),
|
||||
"pages": result.Pages,
|
||||
"attempts": result.Attempts,
|
||||
"complete": false,
|
||||
"hasMore": result.HasMore,
|
||||
"stopReason": result.StopReason,
|
||||
}
|
||||
if result.LastCursor != "" {
|
||||
incomplete["cursor"] = result.LastCursor
|
||||
}
|
||||
if result.TotalCount != nil {
|
||||
incomplete["totalCount"] = *result.TotalCount
|
||||
}
|
||||
|
||||
hint := "retry the command; the structured error details preserve fetched records and the retry cursor"
|
||||
if result.StopReason == paging.StopPageLimit {
|
||||
hint = "rerun with --page-limit 0 to require a complete result, or resume from details.cursor"
|
||||
}
|
||||
message := fmt.Sprintf("record pagination incomplete after %d successful page(s) and %d fetched record(s)", result.Pages, len(result.Records))
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation("aitable.query_records.all"),
|
||||
apperrors.WithServerKey("aitable"),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithReason("pagination_"+string(result.StopReason)),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"page_limit": pageLimit,
|
||||
"incomplete_result": incomplete,
|
||||
}),
|
||||
apperrors.WithCause(result.Err),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── filters 格式校验 ──────────────────────────────────────────────────────
|
||||
@@ -6966,8 +7051,8 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordQueryCmd.Flags().Int("page-size", 0, "--limit 的别名(兼容 LLM 常见误用)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("page-size")
|
||||
recordQueryCmd.Flags().String("cursor", "", "分页游标,首次查询不传;cursor 为空表示已取完全部记录")
|
||||
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取全部记录。传入时自动循环直到无更多数据或达到 --page-limit 上限")
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(5000 条),设为 0 表示无限制")
|
||||
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取完整记录集;达到 --page-limit 且仍有更多页时返回非零结构化错误,不把不完整结果作为成功输出")
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
|
||||
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("view-id")
|
||||
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
|
||||
|
||||
@@ -292,15 +292,15 @@ func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing
|
||||
t.Fatalf("update view top-level: %v", err)
|
||||
}
|
||||
|
||||
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"nextCursor":"next"}}`}}
|
||||
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"totalCount":17}}`}}
|
||||
out = installAitableDeps(t, caller)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "totalCount") {
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), `"totalCount": 17`) || !strings.Contains(out.String(), `"fetchedCount": 1`) {
|
||||
t.Fatalf("paginated records = %q, %v", out.String(), err)
|
||||
}
|
||||
caller = &aitableTestCaller{responses: []string{"not-json"}}
|
||||
out = installAitableDeps(t, caller)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "not-json") {
|
||||
t.Fatalf("raw first page = %q, %v", out.String(), err)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil || out.Len() != 0 {
|
||||
t.Fatalf("invalid first page must fail without success output = %q, %v", out.String(), err)
|
||||
}
|
||||
caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}}
|
||||
installAitableDeps(t, caller)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAITablePageParsingRemainingEdges(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"trailing invalid JSON": `{"records":[]} {`,
|
||||
"data not object": `{"data":[]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseRecordQueryPage(raw); err == nil {
|
||||
t.Fatalf("parseRecordQueryPage(%q) succeeded", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableOptionalCountNumericTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value any
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bad number", value: json.Number("1.5"), wantErr: true},
|
||||
{name: "fractional float", value: float64(1.5), wantErr: true},
|
||||
{name: "whole float", value: float64(2), want: 2},
|
||||
{name: "int", value: int(3), want: 3},
|
||||
{name: "int64", value: int64(4), want: 4},
|
||||
{name: "wrong type", value: "5", wantErr: true},
|
||||
{name: "negative", value: int64(-1), wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseOptionalNonNegativeInt(tc.value)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("value %#v succeeded: %#v", tc.value, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got == nil || *got != tc.want {
|
||||
t.Fatalf("value %#v = %#v, %v", tc.value, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) {
|
||||
total := 9
|
||||
err := recordQueryIncompleteError(paging.Result{
|
||||
Records: []any{map[string]any{"id": "r"}}, Pages: 1, Attempts: 1,
|
||||
HasMore: true, LastCursor: "next", StopReason: paging.StopPageLimit,
|
||||
TotalCount: &total,
|
||||
}, 1)
|
||||
if err == nil || !strings.Contains(err.Error(), "pagination incomplete") {
|
||||
t.Fatalf("incomplete result error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableExplicitUnlimitedPaginationE2E(t *testing.T) {
|
||||
responses := make([]string, paging.DefaultPageLimit+1)
|
||||
for index := range responses {
|
||||
next := ""
|
||||
if index < len(responses)-1 {
|
||||
next = fmt.Sprintf(`,"nextCursor":"cursor-%d"`, index+1)
|
||||
}
|
||||
responses[index] = fmt.Sprintf(`{"records":[{"recordId":"record-%d"}]%s}`, index, next)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
out := installAitableDeps(t, caller)
|
||||
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
|
||||
t.Fatalf("explicit unlimited pagination failed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != len(responses) {
|
||||
t.Fatalf("explicit unlimited pagination calls = %d, want %d", len(caller.calls), len(responses))
|
||||
}
|
||||
for _, want := range []string{`"pages": 51`, `"fetchedCount": 51`, `"complete": true`} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("explicit unlimited output missing %s: %s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type recordQueryE2EStep struct {
|
||||
result *edition.ToolResult
|
||||
err error
|
||||
}
|
||||
|
||||
type recordQueryE2ECaller struct {
|
||||
steps []recordQueryE2EStep
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *recordQueryE2ECaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
cloned := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
cloned[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: cloned})
|
||||
index := len(c.calls) - 1
|
||||
if index >= len(c.steps) {
|
||||
return nil, fmt.Errorf("unexpected tool call %d", index+1)
|
||||
}
|
||||
return c.steps[index].result, c.steps[index].err
|
||||
}
|
||||
|
||||
func (*recordQueryE2ECaller) Format() string { return "json" }
|
||||
func (*recordQueryE2ECaller) DryRun() bool { return false }
|
||||
func (*recordQueryE2ECaller) Fields() string { return "" }
|
||||
func (*recordQueryE2ECaller) JQ() string { return "" }
|
||||
|
||||
func runRecordQueryCLI(t *testing.T, caller *recordQueryE2ECaller, extraArgs ...string) (string, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
InitDeps(caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = out
|
||||
args := []string{"record", "query", "--base-id", "base-e2e", "--table-id", "table-e2e", "--all"}
|
||||
args = append(args, extraArgs...)
|
||||
os.Args = append([]string{"dws", "aitable"}, args...)
|
||||
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(args)
|
||||
err := command.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func recordQueryTextStep(text string) recordQueryE2EStep {
|
||||
return recordQueryE2EStep{result: textToolResult(text)}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLICompleteE2E(t *testing.T) {
|
||||
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"data":{"records":[{"id":"r1"},{"id":"r2"}],"nextCursor":"cursor-2","totalCount":3}}`),
|
||||
recordQueryTextStep(`{"data":{"records":[{"id":"r3"}],"nextCursor":"","totalCount":3}}`),
|
||||
}}
|
||||
out, err := runRecordQueryCLI(t, caller, "--page-limit", "0")
|
||||
if err != nil {
|
||||
t.Fatalf("record query CLI returned error: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"complete": true`, `"fetchedCount": 3`, `"totalCount": 3`, `"id": "r3"`} {
|
||||
if !bytes.Contains([]byte(out), []byte(want)) {
|
||||
t.Fatalf("record query CLI output missing %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("tool calls = %d, want 2", len(caller.calls))
|
||||
}
|
||||
if got := caller.calls[0].args["cursor"]; got != nil {
|
||||
t.Fatalf("first-page cursor = %#v, want absent", got)
|
||||
}
|
||||
if got := caller.calls[1].args["cursor"]; got != "cursor-2" {
|
||||
t.Fatalf("second-page cursor = %#v, want cursor-2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLIFailsClosedE2E(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []recordQueryE2EStep
|
||||
args []string
|
||||
}{
|
||||
{name: "nil result", steps: []recordQueryE2EStep{{result: nil}}},
|
||||
{name: "empty content", steps: []recordQueryE2EStep{{result: &edition.ToolResult{}}}},
|
||||
{name: "empty text", steps: []recordQueryE2EStep{recordQueryTextStep("")}},
|
||||
{name: "invalid json", steps: []recordQueryE2EStep{recordQueryTextStep("{")}},
|
||||
{name: "null payload", steps: []recordQueryE2EStep{recordQueryTextStep("null")}},
|
||||
{name: "missing records", steps: []recordQueryE2EStep{recordQueryTextStep(`{"data":{"nextCursor":"c"}}`)}},
|
||||
{name: "records wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":{}}`)}},
|
||||
{name: "records null", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":null}`)}},
|
||||
{name: "record item wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":["bad"]}`)}},
|
||||
{name: "trailing json", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[]} {}`)}},
|
||||
{name: "has more without cursor", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"hasMore":true}`)}},
|
||||
{name: "invalid total", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"totalCount":"many"}`)}},
|
||||
{name: "first transport error", steps: []recordQueryE2EStep{{err: errors.New("offline")}}},
|
||||
{
|
||||
name: "second page error",
|
||||
steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"retry-me"}`),
|
||||
{err: errors.New("upstream reset")},
|
||||
},
|
||||
args: []string{"--page-limit", "0"},
|
||||
},
|
||||
{
|
||||
name: "page limit",
|
||||
steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"resume-me"}`)},
|
||||
args: []string{"--page-limit", "1"},
|
||||
},
|
||||
{
|
||||
name: "cursor cycle",
|
||||
steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"records":[{"id":"r1"}],"nextCursor":"same"}`),
|
||||
recordQueryTextStep(`{"records":[{"id":"r2"}],"nextCursor":"same"}`),
|
||||
},
|
||||
args: []string{"--page-limit", "0"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &recordQueryE2ECaller{steps: test.steps}
|
||||
out, err := runRecordQueryCLI(t, caller, test.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("CLI treated %s as success; output=%s", test.name, out)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("CLI emitted success output for %s: %q", test.name, out)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason == "" || typed.Details["incomplete_result"] == nil {
|
||||
t.Fatalf("error lacks recovery metadata: %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) {
|
||||
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
|
||||
recordQueryTextStep(`{"records":[]}`),
|
||||
}}
|
||||
if out, err := runRecordQueryCLI(t, caller, "--cursor", "resume-from-here", "--page-limit", "0"); err != nil || out == "" {
|
||||
t.Fatalf("resume query = %q, %v", out, err)
|
||||
}
|
||||
want := map[string]any{"baseId": "base-e2e", "tableId": "table-e2e", "cursor": "resume-from-here"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordQueryCLIPageLimitHelpE2E(t *testing.T) {
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
out := &bytes.Buffer{}
|
||||
command.SetOut(out)
|
||||
command.SetErr(out)
|
||||
command.SetArgs([]string{"record", "query", "--help"})
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatalf("record query help failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{"返回非零结构化错误", "不完整结果", "错误详情保留已取记录和续传 cursor"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("record query help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
+1182
-53
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type LeafContract = corecmd.ContractDecl
|
||||
|
||||
type LeafSpec struct {
|
||||
Safety contract.SafetySpec
|
||||
Contract LeafContract
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
GroupRunE func(*cobra.Command, []string) error
|
||||
CallMCPToolOnServer func(string, string, map[string]any) error
|
||||
DeclareLeafMetadata func(*cobra.Command, LeafSpec) *cobra.Command
|
||||
ValidateRequiredFlag func(*cobra.Command, ...string) error
|
||||
}
|
||||
|
||||
var deps = Deps{
|
||||
GroupRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
CallMCPToolOnServer: func(serverID, toolName string, args map[string]any) error {
|
||||
return fmt.Errorf("im helpers dependency CallMCPToolOnServer is not initialized")
|
||||
},
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return cmd
|
||||
},
|
||||
ValidateRequiredFlag: validateRequiredFlagsLocal,
|
||||
}
|
||||
|
||||
func SetDeps(next Deps) {
|
||||
if next.GroupRunE != nil {
|
||||
deps.GroupRunE = next.GroupRunE
|
||||
}
|
||||
if next.CallMCPToolOnServer != nil {
|
||||
deps.CallMCPToolOnServer = next.CallMCPToolOnServer
|
||||
}
|
||||
if next.DeclareLeafMetadata != nil {
|
||||
deps.DeclareLeafMetadata = next.DeclareLeafMetadata
|
||||
}
|
||||
if next.ValidateRequiredFlag != nil {
|
||||
deps.ValidateRequiredFlag = next.ValidateRequiredFlag
|
||||
}
|
||||
}
|
||||
|
||||
func NewChatToolbarCommand() *cobra.Command {
|
||||
return newChatToolbarCommand()
|
||||
}
|
||||
|
||||
func groupRunE(cmd *cobra.Command, args []string) error {
|
||||
return deps.GroupRunE(cmd, args)
|
||||
}
|
||||
|
||||
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
|
||||
return deps.CallMCPToolOnServer(serverID, toolName, args)
|
||||
}
|
||||
|
||||
func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return deps.DeclareLeafMetadata(cmd, spec)
|
||||
}
|
||||
|
||||
func validateRequiredFlags(cmd *cobra.Command, names ...string) error {
|
||||
return deps.ValidateRequiredFlag(cmd, names...)
|
||||
}
|
||||
|
||||
func mustGetFlag(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
if val == "" {
|
||||
val, _ = cmd.InheritedFlags().GetString(name)
|
||||
}
|
||||
return strings.TrimSpace(val)
|
||||
}
|
||||
|
||||
func parseCSVInt64(raw string) ([]int64, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]int64, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer %q", v)
|
||||
}
|
||||
result = append(result, n)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("at least one ID is required")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateRequiredFlagsLocal(cmd *cobra.Command, names ...string) error {
|
||||
for _, name := range names {
|
||||
if strings.TrimSpace(mustGetFlag(cmd, name)) == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("flag --%s is required", name),
|
||||
apperrors.WithReason("missing_required_flag"),
|
||||
apperrors.WithHint(fmt.Sprintf("使用 --%s 指定必填参数", name)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandBoolFlag(cmd *cobra.Command, name string) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
value, _ := cmd.Flags().GetBool(name)
|
||||
if !value {
|
||||
value, _ = cmd.InheritedFlags().GetBool(name)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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 chat
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
toolbarCmd := &cobra.Command{
|
||||
Use: "toolbar",
|
||||
Short: "快捷栏管理",
|
||||
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
toolbarCmd.DisableAutoGenTag = true
|
||||
|
||||
toolbarCmd.AddCommand(
|
||||
newToolbarListCommand(),
|
||||
newToolbarAddCommand(),
|
||||
newToolbarHideCommand(),
|
||||
newToolbarSortCommand(),
|
||||
newToolbarCreateCustomCommand(),
|
||||
newToolbarRemoveCustomCommand(),
|
||||
newToolbarUpdateCustomCommand(),
|
||||
)
|
||||
|
||||
return toolbarCmd
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarAddCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "将入口添加到快捷栏可见区",
|
||||
Long: "将一个或多个快捷入口从隐藏区移至可见区。入口 ID 使用逗号分隔。",
|
||||
Example: ` dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--shortcut-ids: %w", err)
|
||||
}
|
||||
err = callMCPToolOnServer("im", "add_shortcut_to_bar", map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutIds": ids,
|
||||
})
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "add_shortcut_to_bar",
|
||||
CanonicalPath: "chat.add_shortcut_to_bar",
|
||||
CLIPath: "chat toolbar add",
|
||||
PrimaryCLIPath: "chat toolbar add",
|
||||
},
|
||||
Description: "将快捷入口添加到会话快捷栏可见区",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_shortcut_to_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将快捷入口添加到会话快捷栏可见区",
|
||||
UseWhen: []string{"需要将隐藏的快捷入口重新显示在快捷栏中"},
|
||||
AvoidWhen: []string{"创建新的自定义入口应使用 chat toolbar create-custom"},
|
||||
Examples: []string{"dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarCreateCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-custom",
|
||||
Short: "创建自定义快捷栏入口",
|
||||
Long: `创建一个新的自定义快捷栏入口。
|
||||
|
||||
必填参数:
|
||||
--conversation-id 会话 openConversationId
|
||||
--title 入口标题
|
||||
--url 入口跳转链接
|
||||
--icon-url 入口图标 URL
|
||||
--pc-url PC 端跳转链接(可与 --url 相同)
|
||||
|
||||
可选参数:
|
||||
--extension 扩展信息,格式 key=value,可重复使用
|
||||
--desc 入口描述(为空时使用 --title)
|
||||
--tag 入口标签
|
||||
--sort-index 排序权重`,
|
||||
Example: ` dws chat toolbar create-custom --conversation-id <cid> --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
dws chat toolbar create-custom --conversation-id <cid> --title "工具" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com" --extension color=blue --desc "常用工具"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
|
||||
"url": mustGetFlag(cmd, "url"),
|
||||
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
|
||||
"pcUrl": mustGetFlag(cmd, "pc-url"),
|
||||
}
|
||||
|
||||
descValue := mustGetFlag(cmd, "desc")
|
||||
if descValue == "" {
|
||||
descValue = mustGetFlag(cmd, "title")
|
||||
}
|
||||
toolArgs["desc"] = toolbarI18nJSONString(descValue)
|
||||
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
|
||||
toolArgs["tag"] = tag
|
||||
}
|
||||
if cmd.Flags().Changed("sort-index") {
|
||||
sortIndex, _ := cmd.Flags().GetInt("sort-index")
|
||||
toolArgs["sortIndex"] = sortIndex
|
||||
}
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ext != nil {
|
||||
toolArgs["extension"] = ext
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "create_custom_shortcut", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("title", "", "入口标题")
|
||||
cmd.Flags().String("url", "", "入口跳转链接")
|
||||
cmd.Flags().String("icon-url", "", "入口图标 URL")
|
||||
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
|
||||
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
|
||||
cmd.Flags().String("desc", "", "入口描述(为空时使用 --title)")
|
||||
cmd.Flags().String("tag", "", "入口标签")
|
||||
cmd.Flags().Int("sort-index", 0, "排序权重")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
_ = cmd.MarkFlagRequired("icon-url")
|
||||
_ = cmd.MarkFlagRequired("pc-url")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "create_custom_shortcut",
|
||||
CanonicalPath: "chat.create_custom_shortcut",
|
||||
CLIPath: "chat toolbar create-custom",
|
||||
PrimaryCLIPath: "chat toolbar create-custom",
|
||||
},
|
||||
Description: "创建自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建自定义快捷栏入口",
|
||||
UseWhen: []string{"需要在快捷栏中新增一个自定义入口(含标题、链接、图标等)"},
|
||||
AvoidWhen: []string{"移动已有入口使用 chat toolbar add / hide;更新已有自定义入口使用 chat toolbar update-custom"},
|
||||
Examples: []string{"dws chat toolbar create-custom --conversation-id <cid> --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "title", Property: "name", Required: boolPtr(true)},
|
||||
{Name: "url", Property: "url", Required: boolPtr(true)},
|
||||
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
|
||||
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
|
||||
{Name: "extension", Property: "extension", Required: boolPtr(false)},
|
||||
{Name: "desc", Property: "desc", Required: boolPtr(false)},
|
||||
{Name: "tag", Property: "tag", Required: boolPtr(false)},
|
||||
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 chat
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func toolbarI18nJSONString(value string) string {
|
||||
payload, _ := json.Marshal(map[string]string{"zh_CN": value})
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func toolbarIconsJSONString(iconMobile string) string {
|
||||
payload, _ := json.Marshal(map[string]string{"iconMobile": iconMobile})
|
||||
return string(payload)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func hasIntersection(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(a))
|
||||
for _, v := range a {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
for _, v := range b {
|
||||
if _, ok := seen[v]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSystemBusy(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "SYSTEM_BUSY")
|
||||
}
|
||||
|
||||
func parseExtension(cmd *cobra.Command) (map[string]string, error) {
|
||||
raw, _ := cmd.Flags().GetStringArray("extension")
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for _, pair := range raw {
|
||||
idx := strings.Index(pair, "=")
|
||||
if idx <= 0 {
|
||||
return nil, fmt.Errorf("--extension 格式错误,期望 key=value,实际 %q", pair)
|
||||
}
|
||||
key := strings.TrimSpace(pair[:idx])
|
||||
value := strings.TrimSpace(pair[idx+1:])
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("--extension key 不能为空: %q", pair)
|
||||
}
|
||||
if _, exists := result[key]; exists {
|
||||
return nil, fmt.Errorf("--extension 重复 key %q,每个 key 只能指定一次", key)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toolbarConversationID(cmd *cobra.Command) (string, error) {
|
||||
cid, _ := cmd.Flags().GetString("conversation-id")
|
||||
cid = strings.TrimSpace(cid)
|
||||
if cid == "" {
|
||||
return "", apperrors.NewValidation(
|
||||
"flag --conversation-id is required",
|
||||
apperrors.WithReason("missing_required_flag"),
|
||||
apperrors.WithHint("使用 --conversation-id 指定会话 openConversationId"),
|
||||
apperrors.WithActions("通过 dws chat search 或 dws chat group list 获取会话 ID"),
|
||||
)
|
||||
}
|
||||
return cid, nil
|
||||
}
|
||||
|
||||
func toolbarNewSystemBusyError() error {
|
||||
return apperrors.NewValidation(
|
||||
"服务端繁忙(SYSTEM_BUSY),请稍后重试",
|
||||
apperrors.WithReason("system_busy"),
|
||||
apperrors.WithHint("服务端当前负载较高,建议等待几秒后重试"),
|
||||
apperrors.WithActions("等待后使用相同参数重试"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type toolbarCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type toolbarTestCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
err error
|
||||
calls []toolbarCall
|
||||
}
|
||||
|
||||
func (c *toolbarTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, toolbarCall{product: productID, tool: toolName, args: args})
|
||||
c.product = productID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{}"}}}, nil
|
||||
}
|
||||
|
||||
func (c *toolbarTestCaller) Format() string { return "json" }
|
||||
func (c *toolbarTestCaller) DryRun() bool { return false }
|
||||
func (c *toolbarTestCaller) Fields() string { return "" }
|
||||
func (c *toolbarTestCaller) JQ() string { return "" }
|
||||
|
||||
func executeToolbarCommand(t *testing.T, cmd *cobra.Command, caller *toolbarTestCaller, args ...string) error {
|
||||
t.Helper()
|
||||
initToolbarDepsForTest(t, caller)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func initToolbarDepsForTest(t *testing.T, caller *toolbarTestCaller) {
|
||||
t.Helper()
|
||||
previous := deps
|
||||
SetDeps(Deps{
|
||||
GroupRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
CallMCPToolOnServer: func(productID, toolName string, args map[string]any) error {
|
||||
_, err := caller.CallTool(context.Background(), productID, toolName, args)
|
||||
return err
|
||||
},
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
return cmd
|
||||
},
|
||||
ValidateRequiredFlag: validateRequiredFlagsLocal,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
deps = previous
|
||||
})
|
||||
}
|
||||
|
||||
func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "获得用户确认后加 --yes 执行") {
|
||||
t.Fatalf("expected confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireToolbarCall(
|
||||
t *testing.T,
|
||||
caller *toolbarTestCaller,
|
||||
wantProduct, wantTool string,
|
||||
wantArgs map[string]any,
|
||||
) {
|
||||
t.Helper()
|
||||
if caller.product != wantProduct || caller.tool != wantTool {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", caller.product, caller.tool, wantProduct, wantTool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func toolbarCustomArgs(extra ...string) []string {
|
||||
args := []string{"--conversation-id", "cid", "--title", "Title", "--url", "https://example.com/mobile", "--icon-url", "https://example.com/icon.png", "--pc-url", "https://example.com/pc"}
|
||||
return append(args, extra...)
|
||||
}
|
||||
|
||||
func TestHasIntersection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b []int64
|
||||
want bool
|
||||
}{
|
||||
{"both empty", nil, nil, false},
|
||||
{"a empty", nil, []int64{1}, false},
|
||||
{"b empty", []int64{1}, nil, false},
|
||||
{"no overlap", []int64{1, 2, 3}, []int64{4, 5, 6}, false},
|
||||
{"has overlap", []int64{1, 2, 3}, []int64{3, 4, 5}, true},
|
||||
{"single match", []int64{1}, []int64{1}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasIntersection(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("hasIntersection(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSystemBusy(t *testing.T) {
|
||||
if isSystemBusy(nil) {
|
||||
t.Error("isSystemBusy(nil) should be false")
|
||||
}
|
||||
if !isSystemBusy(errors.New("error code: SYSTEM_BUSY")) {
|
||||
t.Error("isSystemBusy should detect SYSTEM_BUSY in error message")
|
||||
}
|
||||
if isSystemBusy(errors.New("some other error")) {
|
||||
t.Error("isSystemBusy should return false for non-SYSTEM_BUSY errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtension(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().StringArray("extension", nil, "")
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("empty extension should not error: %v", err)
|
||||
}
|
||||
if ext != nil {
|
||||
t.Fatalf("empty extension should return nil, got %v", ext)
|
||||
}
|
||||
|
||||
_ = cmd.Flags().Set("extension", "key1=val1")
|
||||
_ = cmd.Flags().Set("extension", "key2=val2")
|
||||
ext, err = parseExtension(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("valid extension should not error: %v", err)
|
||||
}
|
||||
if ext["key1"] != "val1" || ext["key2"] != "val2" {
|
||||
t.Fatalf("unexpected extension map: %v", ext)
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "test2"}
|
||||
cmd2.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd2.Flags().Set("extension", "badformat")
|
||||
_, err = parseExtension(cmd2)
|
||||
if err == nil {
|
||||
t.Fatal("invalid extension format should error")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "test3"}
|
||||
cmd3.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd3.Flags().Set("extension", "color=blue")
|
||||
_ = cmd3.Flags().Set("extension", "color=red")
|
||||
_, err = parseExtension(cmd3)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate extension keys should error")
|
||||
}
|
||||
|
||||
cmd4 := &cobra.Command{Use: "test4"}
|
||||
cmd4.Flags().StringArray("extension", nil, "")
|
||||
_ = cmd4.Flags().Set("extension", " =value")
|
||||
_, err = parseExtension(cmd4)
|
||||
if err == nil {
|
||||
t.Fatal("blank extension key should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarConversationID(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("conversation-id", "", "")
|
||||
|
||||
_, err := toolbarConversationID(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("missing conversation-id should error")
|
||||
}
|
||||
|
||||
_ = cmd.Flags().Set("conversation-id", "cid123")
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("valid conversation-id should not error: %v", err)
|
||||
}
|
||||
if cid != "cid123" {
|
||||
t.Fatalf("expected cid123, got %s", cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarDepsDefaultWrappersAndValidationEdges(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "toolbar"}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := groupRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("default groupRunE returned error: %v", err)
|
||||
}
|
||||
if err := callMCPToolOnServer("im", "tool", nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "CallMCPToolOnServer is not initialized") {
|
||||
t.Fatalf("default callMCPToolOnServer error = %v", err)
|
||||
}
|
||||
if got := DeclareLeafMetadata(cmd, LeafSpec{}); got != cmd {
|
||||
t.Fatal("default DeclareLeafMetadata should return the original command")
|
||||
}
|
||||
|
||||
toolbar := NewChatToolbarCommand()
|
||||
if toolbar.Name() != "toolbar" || len(toolbar.Commands()) != 7 {
|
||||
t.Fatalf("toolbar command = %q with %d children, want toolbar with 7 children", toolbar.Name(), len(toolbar.Commands()))
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "parent"}
|
||||
parent.PersistentFlags().String("conversation-id", "", "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
parent.AddCommand(child)
|
||||
_ = parent.PersistentFlags().Set("conversation-id", " inherited ")
|
||||
if got := mustGetFlag(child, "conversation-id"); got != "inherited" {
|
||||
t.Fatalf("mustGetFlag inherited value = %q, want inherited", got)
|
||||
}
|
||||
|
||||
if _, err := parseCSVInt64(" , "); err == nil || !strings.Contains(err.Error(), "at least one ID") {
|
||||
t.Fatalf("empty csv error = %v", err)
|
||||
}
|
||||
if _, err := parseCSVInt64("1,bad"); err == nil || !strings.Contains(err.Error(), `invalid integer "bad"`) {
|
||||
t.Fatalf("invalid csv error = %v", err)
|
||||
}
|
||||
required := &cobra.Command{Use: "required"}
|
||||
required.Flags().String("title", "", "")
|
||||
if err := validateRequiredFlagsLocal(required, "title"); err == nil ||
|
||||
!strings.Contains(err.Error(), "flag --title is required") {
|
||||
t.Fatalf("validateRequiredFlagsLocal error = %v", err)
|
||||
}
|
||||
if commandBoolFlag(nil, "yes") {
|
||||
t.Fatal("commandBoolFlag(nil) should be false")
|
||||
}
|
||||
boolParent := &cobra.Command{Use: "parent"}
|
||||
boolParent.PersistentFlags().Bool("yes", false, "")
|
||||
boolChild := &cobra.Command{Use: "child"}
|
||||
boolParent.AddCommand(boolChild)
|
||||
_ = boolParent.PersistentFlags().Set("yes", "true")
|
||||
if !commandBoolFlag(boolChild, "yes") {
|
||||
t.Fatal("commandBoolFlag should read inherited true value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCommandsValidationEdges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "add blank conversation id",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "add blank shortcut ids",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
|
||||
wantError: "flag --shortcut-ids is required",
|
||||
},
|
||||
{
|
||||
name: "add invalid shortcut ids",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
|
||||
wantError: "--shortcut-ids",
|
||||
},
|
||||
{
|
||||
name: "hide blank conversation id",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "hide blank shortcut ids",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
|
||||
wantError: "flag --shortcut-ids is required",
|
||||
},
|
||||
{
|
||||
name: "hide invalid shortcut ids",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
|
||||
wantError: "--shortcut-ids",
|
||||
},
|
||||
{
|
||||
name: "list blank conversation id",
|
||||
cmd: newToolbarListCommand,
|
||||
args: []string{"--conversation-id", " "},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "sort blank conversation id",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", " ", "--sorted-ids", "1"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "sort blank sorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", " "},
|
||||
wantError: "flag --sorted-ids is required",
|
||||
},
|
||||
{
|
||||
name: "sort invalid sorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,bad"},
|
||||
wantError: "--sorted-ids",
|
||||
},
|
||||
{
|
||||
name: "sort invalid unsorted ids",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1", "--unsorted-ids", "bad"},
|
||||
wantError: "--unsorted-ids",
|
||||
},
|
||||
{
|
||||
name: "create blank conversation id",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--conversation-id", " "),
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "create blank title",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--title", " "),
|
||||
wantError: "flag --title is required",
|
||||
},
|
||||
{
|
||||
name: "create invalid extension",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs("--extension", "bad"),
|
||||
wantError: "--extension",
|
||||
},
|
||||
{
|
||||
name: "remove blank conversation id",
|
||||
cmd: newToolbarRemoveCustomCommand,
|
||||
args: []string{"--conversation-id", " ", "--shortcut-id", "42", "--yes"},
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "update blank conversation id",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--conversation-id", " "),
|
||||
wantError: "flag --conversation-id is required",
|
||||
},
|
||||
{
|
||||
name: "update blank title",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--title", " "),
|
||||
wantError: "flag --title is required",
|
||||
},
|
||||
{
|
||||
name: "update invalid extension",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "42", "--extension", "bad"),
|
||||
wantError: "--extension",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCommandsConvertSystemBusy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
cmd: newToolbarSortCommand,
|
||||
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,2"},
|
||||
},
|
||||
{
|
||||
name: "remove-custom",
|
||||
cmd: newToolbarRemoveCustomCommand,
|
||||
args: []string{"--conversation-id", "cid", "--shortcut-id", "1", "--yes"},
|
||||
},
|
||||
{
|
||||
name: "create-custom",
|
||||
cmd: newToolbarCreateCustomCommand,
|
||||
args: toolbarCustomArgs(),
|
||||
},
|
||||
{
|
||||
name: "update-custom",
|
||||
cmd: newToolbarUpdateCustomCommand,
|
||||
args: toolbarCustomArgs("--shortcut-id", "99"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{
|
||||
err: errors.New("remote SYSTEM_BUSY"),
|
||||
}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "SYSTEM_BUSY") {
|
||||
t.Fatalf("expected system_busy validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarCreateAndUpdateCustomIncludeOptionalFields(t *testing.T) {
|
||||
createCaller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), createCaller, toolbarCustomArgs(
|
||||
"--desc", "Description",
|
||||
"--tag", "Tag",
|
||||
"--sort-index", "7",
|
||||
"--extension", "color=blue",
|
||||
)...)
|
||||
if err != nil {
|
||||
t.Fatalf("create-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, createCaller, "im", "create_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Description"}`,
|
||||
"tag": "Tag",
|
||||
"sortIndex": 7,
|
||||
"extension": map[string]string{"color": "blue"},
|
||||
})
|
||||
|
||||
updateCaller := &toolbarTestCaller{}
|
||||
err = executeToolbarCommand(t, newToolbarUpdateCustomCommand(), updateCaller, toolbarCustomArgs(
|
||||
"--shortcut-id", "99",
|
||||
"--desc", "Description",
|
||||
"--tag", "Tag",
|
||||
"--sort-index", "7",
|
||||
"--extension", "color=blue",
|
||||
)...)
|
||||
if err != nil {
|
||||
t.Fatalf("update-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, updateCaller, "im", "update_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"shortcutId": int64(99),
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Description"}`,
|
||||
"tag": "Tag",
|
||||
"sortIndex": 7,
|
||||
"extension": map[string]string{"color": "blue"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolbarCreateCustomDefaultsDescToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing desc", args: toolbarCustomArgs()},
|
||||
{name: "blank desc", args: toolbarCustomArgs("--desc", " ")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("create-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "create_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Title"}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarUpdateCustomDefaultsDescToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing desc", args: toolbarCustomArgs("--shortcut-id", "99")},
|
||||
{name: "blank desc", args: toolbarCustomArgs("--shortcut-id", "99", "--desc", " ")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, newToolbarUpdateCustomCommand(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("update-custom returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "update_custom_shortcut", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"shortcutId": int64(99),
|
||||
"name": `{"zh_CN":"Title"}`,
|
||||
"url": "https://example.com/mobile",
|
||||
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
|
||||
"pcUrl": "https://example.com/pc",
|
||||
"desc": `{"zh_CN":"Title"}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarSortValidatesOptionalUnsortedIDs(t *testing.T) {
|
||||
intersectionErr := executeToolbarCommand(t, newToolbarSortCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cid",
|
||||
"--sorted-ids", "1,2",
|
||||
"--unsorted-ids", "2,3",
|
||||
)
|
||||
if intersectionErr == nil || !strings.Contains(intersectionErr.Error(), "不能有交集") {
|
||||
t.Fatalf("expected id_intersection error, got %v", intersectionErr)
|
||||
}
|
||||
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarSortCommand(), caller,
|
||||
"--conversation-id", "cid",
|
||||
"--sorted-ids", "1,2",
|
||||
"--unsorted-ids", "3,4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sort returned error: %v", err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", "sort_shortcut_bar", map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"sortedShortcutIds": []int64{1, 2},
|
||||
"unsortedShortcutIds": []int64{3, 4},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarAddHideAndListCallMCPWithExactArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args []string
|
||||
wantTool string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
cmd: newToolbarAddCommand,
|
||||
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "101,102"},
|
||||
wantTool: "add_shortcut_to_bar",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
"shortcutIds": []int64{101, 102},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
cmd: newToolbarHideCommand,
|
||||
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "201,202"},
|
||||
wantTool: "hide_shortcut_from_bar",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
"shortcutIds": []int64{201, 202},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
cmd: newToolbarListCommand,
|
||||
args: []string{"--conversation-id", "cidX"},
|
||||
wantTool: "list_chat_toolbar_shortcuts",
|
||||
wantArgs: map[string]any{
|
||||
"openConversationId": "cidX",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
if err := executeToolbarCommand(t, tt.cmd(), caller, tt.args...); err != nil {
|
||||
t.Fatalf("%s returned error: %v", tt.name, err)
|
||||
}
|
||||
requireToolbarCall(t, caller, "im", tt.wantTool, tt.wantArgs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42")
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
if caller.product != "" || caller.tool != "" || caller.args != nil {
|
||||
t.Fatalf("expected no MCP call recorded, got product=%q tool=%q args=%#v", caller.product, caller.tool, caller.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes(t *testing.T) {
|
||||
caller := &toolbarTestCaller{}
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.product != "im" {
|
||||
t.Fatalf("product = %q, want im", call.product)
|
||||
}
|
||||
if call.tool != "remove_custom_shortcut" {
|
||||
t.Fatalf("tool = %q, want remove_custom_shortcut", call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"openConversationId": "cidX", "shortcutId": int64(42)}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
type removeCustomSeamCall struct {
|
||||
openConversationId string
|
||||
shortcutId int64
|
||||
}
|
||||
|
||||
type removeCustomSeamStub struct {
|
||||
calls []removeCustomSeamCall
|
||||
err error
|
||||
}
|
||||
|
||||
// invoke records the seam call and forwards to deps.Caller.CallTool so the
|
||||
// command path still records the same remote invocation shape as production.
|
||||
func (s *removeCustomSeamStub) invoke(openConversationId string, shortcutId int64) error {
|
||||
s.calls = append(s.calls, removeCustomSeamCall{openConversationId: openConversationId, shortcutId: shortcutId})
|
||||
if err := callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
|
||||
"openConversationId": openConversationId,
|
||||
"shortcutId": shortcutId,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomSeamRejectsWithoutYes(t *testing.T) {
|
||||
stub := &removeCustomSeamStub{}
|
||||
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
|
||||
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42")
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(stub.calls) != 0 {
|
||||
t.Fatalf("expected 0 seam calls before --yes, got %d: %+v", len(stub.calls), stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageToolbarRemoveCustomSeamCallsWithExactArgsWhenYes(t *testing.T) {
|
||||
stub := &removeCustomSeamStub{}
|
||||
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
|
||||
|
||||
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
|
||||
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(stub.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 seam call, got %d: %+v", len(stub.calls), stub.calls)
|
||||
}
|
||||
call := stub.calls[0]
|
||||
if call.openConversationId != "cidX" {
|
||||
t.Fatalf("seam openConversationId = %q, want cidX", call.openConversationId)
|
||||
}
|
||||
if call.shortcutId != int64(42) {
|
||||
t.Fatalf("seam shortcutId = %d, want 42", call.shortcutId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolbarRemoveCustomDeclaresDestructiveHighRiskConfirmation(t *testing.T) {
|
||||
var got LeafSpec
|
||||
next := deps
|
||||
next.DeclareLeafMetadata = func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
|
||||
got = spec
|
||||
return cmd
|
||||
}
|
||||
testseam.Swap(t, &deps, next)
|
||||
|
||||
_ = newToolbarRemoveCustomCommand()
|
||||
if got.Safety.Effect != "destructive" || got.Safety.Risk != "high" ||
|
||||
got.Safety.Confirmation != "user_required" {
|
||||
t.Fatalf("remove-custom safety = %#v, want destructive/high/user_required", got.Safety)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarHideCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "hide",
|
||||
Short: "将入口从快捷栏可见区隐藏",
|
||||
Long: "将一个或多个快捷入口从可见区移至隐藏区。入口 ID 使用逗号分隔。",
|
||||
Example: ` dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--shortcut-ids: %w", err)
|
||||
}
|
||||
err = callMCPToolOnServer("im", "hide_shortcut_from_bar", map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutIds": ids,
|
||||
})
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "hide_shortcut_from_bar",
|
||||
CanonicalPath: "chat.hide_shortcut_from_bar",
|
||||
CLIPath: "chat toolbar hide",
|
||||
PrimaryCLIPath: "chat toolbar hide",
|
||||
},
|
||||
Description: "将快捷入口从会话快捷栏可见区移至隐藏区",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "hide_shortcut_from_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将快捷入口从会话快捷栏可见区隐藏",
|
||||
UseWhen: []string{"需要将快捷栏中的入口隐藏到隐藏区"},
|
||||
AvoidWhen: []string{"永久删除自定义入口应使用 chat toolbar remove-custom"},
|
||||
Examples: []string{"dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询会话快捷栏入口列表",
|
||||
Long: "查询指定会话的快捷栏入口列表,包括可见区和隐藏区的所有入口。",
|
||||
Example: ` dws chat toolbar list --conversation-id <cid>
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_chat_toolbar_shortcuts", map[string]any{
|
||||
"openConversationId": cid,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "list_chat_toolbar_shortcuts",
|
||||
CanonicalPath: "chat.list_chat_toolbar_shortcuts",
|
||||
CLIPath: "chat toolbar list",
|
||||
PrimaryCLIPath: "chat toolbar list",
|
||||
},
|
||||
Description: "查询会话快捷栏入口列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_chat_toolbar_shortcuts"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询会话快捷栏入口列表",
|
||||
UseWhen: []string{"需要查看会话快捷栏中有哪些入口、获取入口 ID 时"},
|
||||
AvoidWhen: []string{"修改快捷栏排序使用 chat toolbar sort;创建自定义入口使用 chat toolbar create-custom"},
|
||||
Examples: []string{"dws chat toolbar list --conversation-id <cid>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// removeChatToolbarCustomShortcutFn is the package-level injection seam for
|
||||
// the remove-custom command's remote call. The default implementation routes
|
||||
// through callMCPToolOnServer against the im server, so production behavior is
|
||||
// unchanged. Tests swap it with testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub)
|
||||
// to capture the call without hitting a real remote. Like every package-var
|
||||
// seam in this repo it is not safe for t.Parallel tests.
|
||||
var removeChatToolbarCustomShortcutFn = func(openConversationId string, shortcutId int64) error {
|
||||
return callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
|
||||
"openConversationId": openConversationId,
|
||||
"shortcutId": shortcutId,
|
||||
})
|
||||
}
|
||||
|
||||
func newToolbarRemoveCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove-custom",
|
||||
Short: "删除自定义快捷栏入口",
|
||||
Long: "删除一个自定义快捷栏入口。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
|
||||
Example: ` dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"删除自定义快捷入口不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标入口及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标自定义入口", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
err = removeChatToolbarCustomShortcutFn(cid, shortcutId)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
|
||||
cmd.Flags().Bool("yes", false, "确认执行删除操作")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-id")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "remove_custom_shortcut",
|
||||
CanonicalPath: "chat.remove_custom_shortcut",
|
||||
CLIPath: "chat toolbar remove-custom",
|
||||
PrimaryCLIPath: "chat toolbar remove-custom",
|
||||
},
|
||||
Description: "删除自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "删除自定义快捷栏入口",
|
||||
UseWhen: []string{"需要永久删除某个自定义快捷栏入口"},
|
||||
AvoidWhen: []string{"仅隐藏入口使用 chat toolbar hide;更新入口使用 chat toolbar update-custom"},
|
||||
Examples: []string{"dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func newToolbarSortCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "sort",
|
||||
Short: "排序快捷栏入口",
|
||||
Long: "对会话快捷栏入口进行排序。sorted-ids 指定排序后的可见区入口 ID 列表,unsorted-ids 指定不参与排序放在末尾的入口 ID 列表。两个列表不能有交集。",
|
||||
Example: ` dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103
|
||||
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102 --unsorted-ids 103,104
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "sorted-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
sortedIds, err := parseCSVInt64(mustGetFlag(cmd, "sorted-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--sorted-ids: %w", err)
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"sortedShortcutIds": sortedIds,
|
||||
}
|
||||
|
||||
if unsortedRaw, _ := cmd.Flags().GetString("unsorted-ids"); unsortedRaw != "" {
|
||||
unsortedIds, err := parseCSVInt64(unsortedRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--unsorted-ids: %w", err)
|
||||
}
|
||||
if hasIntersection(sortedIds, unsortedIds) {
|
||||
return apperrors.NewValidation(
|
||||
"--sorted-ids 与 --unsorted-ids 不能有交集",
|
||||
apperrors.WithReason("id_intersection"),
|
||||
apperrors.WithHint("检查两个 ID 列表,确保同一个 ID 不同时出现在两个列表中"),
|
||||
apperrors.WithActions("修正 ID 列表后重试"),
|
||||
)
|
||||
}
|
||||
toolArgs["unsortedShortcutIds"] = unsortedIds
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "sort_shortcut_bar", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().String("sorted-ids", "", "排序后的入口 ID 列表(逗号分隔)")
|
||||
cmd.Flags().String("unsorted-ids", "", "不参与排序放在末尾的入口 ID 列表(逗号分隔)")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("sorted-ids")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "sort_shortcut_bar",
|
||||
CanonicalPath: "chat.sort_shortcut_bar",
|
||||
CLIPath: "chat toolbar sort",
|
||||
PrimaryCLIPath: "chat toolbar sort",
|
||||
},
|
||||
Description: "对会话快捷栏入口进行排序",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "sort_shortcut_bar"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对会话快捷栏入口进行排序",
|
||||
UseWhen: []string{"需要调整快捷栏入口的显示顺序"},
|
||||
AvoidWhen: []string{"仅添加或隐藏入口使用 chat toolbar add / hide"},
|
||||
Examples: []string{"dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "sorted-ids", Property: "sortedShortcutIds", Required: boolPtr(true)},
|
||||
{Name: "unsorted-ids", Property: "unsortedShortcutIds", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func newToolbarUpdateCustomCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update-custom",
|
||||
Short: "更新自定义快捷栏入口",
|
||||
Long: `更新一个已有的自定义快捷栏入口。
|
||||
|
||||
必填参数:
|
||||
--conversation-id 会话 openConversationId
|
||||
--shortcut-id 自定义入口 ID
|
||||
--title 入口标题
|
||||
--url 入口跳转链接
|
||||
--icon-url 入口图标 URL
|
||||
--pc-url PC 端跳转链接
|
||||
|
||||
可选参数:
|
||||
--extension 扩展信息,格式 key=value,可重复使用
|
||||
--desc 入口描述
|
||||
--tag 入口标签
|
||||
--sort-index 排序权重`,
|
||||
Example: ` dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
|
||||
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cid, err := toolbarConversationID(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
|
||||
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": cid,
|
||||
"shortcutId": shortcutId,
|
||||
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
|
||||
"url": mustGetFlag(cmd, "url"),
|
||||
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
|
||||
"pcUrl": mustGetFlag(cmd, "pc-url"),
|
||||
}
|
||||
|
||||
descValue := mustGetFlag(cmd, "desc")
|
||||
if descValue == "" {
|
||||
descValue = mustGetFlag(cmd, "title")
|
||||
}
|
||||
toolArgs["desc"] = toolbarI18nJSONString(descValue)
|
||||
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
|
||||
toolArgs["tag"] = tag
|
||||
}
|
||||
if cmd.Flags().Changed("sort-index") {
|
||||
sortIndex, _ := cmd.Flags().GetInt("sort-index")
|
||||
toolArgs["sortIndex"] = sortIndex
|
||||
}
|
||||
|
||||
ext, err := parseExtension(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ext != nil {
|
||||
toolArgs["extension"] = ext
|
||||
}
|
||||
|
||||
err = callMCPToolOnServer("im", "update_custom_shortcut", toolArgs)
|
||||
if isSystemBusy(err) {
|
||||
return toolbarNewSystemBusyError()
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
|
||||
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
|
||||
cmd.Flags().String("title", "", "入口标题")
|
||||
cmd.Flags().String("url", "", "入口跳转链接")
|
||||
cmd.Flags().String("icon-url", "", "入口图标 URL")
|
||||
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
|
||||
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
|
||||
cmd.Flags().String("desc", "", "入口描述")
|
||||
cmd.Flags().String("tag", "", "入口标签")
|
||||
cmd.Flags().Int("sort-index", 0, "排序权重")
|
||||
_ = cmd.MarkFlagRequired("conversation-id")
|
||||
_ = cmd.MarkFlagRequired("shortcut-id")
|
||||
_ = cmd.MarkFlagRequired("title")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
_ = cmd.MarkFlagRequired("icon-url")
|
||||
_ = cmd.MarkFlagRequired("pc-url")
|
||||
cmd.DisableAutoGenTag = true
|
||||
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat",
|
||||
Name: "update_custom_shortcut",
|
||||
CanonicalPath: "chat.update_custom_shortcut",
|
||||
CLIPath: "chat toolbar update-custom",
|
||||
PrimaryCLIPath: "chat toolbar update-custom",
|
||||
},
|
||||
Description: "更新自定义快捷栏入口",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_custom_shortcut"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新自定义快捷栏入口",
|
||||
UseWhen: []string{"需要修改已有自定义快捷栏入口的标题、链接、图标等属性"},
|
||||
AvoidWhen: []string{"创建新入口使用 chat toolbar create-custom;删除入口使用 chat toolbar remove-custom"},
|
||||
Examples: []string{"dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
|
||||
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
|
||||
{Name: "title", Property: "name", Required: boolPtr(true)},
|
||||
{Name: "url", Property: "url", Required: boolPtr(true)},
|
||||
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
|
||||
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
|
||||
{Name: "extension", Property: "extension", Required: boolPtr(false)},
|
||||
{Name: "desc", Property: "desc", Required: boolPtr(false)},
|
||||
{Name: "tag", Property: "tag", Required: boolPtr(false)},
|
||||
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -370,6 +370,131 @@ func TestCrossPlatformCoverageUploadConversationLocalFileCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, response := range []string{`{`, `{"result":{}}`} {
|
||||
if _, err := parseConversationFileDownloadURL(response); err == nil {
|
||||
t.Fatalf("parseConversationFileDownloadURL(%q) returned nil", response)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "markdown requires title", args: []string{"--text", "message"}},
|
||||
{name: "markdown requires text", args: []string{"--title", "title"}},
|
||||
{name: "image requires URL", args: []string{"--msg-type", "image"}},
|
||||
{name: "file requires path", args: []string{"--msg-type", "file"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--group", "group"}
|
||||
if err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...); err == nil {
|
||||
t.Fatal("missing rich-media input returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("group and direct targets are mutually exclusive", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot",
|
||||
"--group", "group", "--users", "user", "--title", "title", "--text", "message",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("mutually exclusive target error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct markdown defaults to DX message type", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--title", "title", "--text", "message",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
|
||||
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct image", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--msg-type", "image", "--image-url", "https://example.test/image.png", "--at-all",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleImageMsg" || caller.args["isAtAll"] != "true" {
|
||||
t.Fatalf("direct image call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target []string
|
||||
}{
|
||||
{name: "user file", target: []string{"--users", "user"}},
|
||||
{name: "open DingTalk ID file", target: []string{"--open-dingtalk-ids", "D-user"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example/file","uploadKey":"key"}`},
|
||||
{text: `{"result":{"downloadUrl":"https://download.example/report.pdf"}}`},
|
||||
{text: `{"success":true}`},
|
||||
}}
|
||||
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--msg-type", "file", "--file-path", filePath}
|
||||
if err := runChatCoverageCommand(t, caller, append(args, tc.target...)...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 || caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleDingtalkDriveFile" || caller.args["fileUrl"] != "https://download.example/report.pdf" {
|
||||
t.Fatalf("direct file call = %d %s %#v", caller.calls, caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("file dry run", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
); err != nil || caller.calls != 0 {
|
||||
t.Fatalf("dry run error = %v, calls = %d", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file rejects multiple recipients", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "one,two",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires exactly one") {
|
||||
t.Fatalf("multiple-recipient error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file upload failure", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("upload failed")}}}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "upload failed") {
|
||||
t.Fatalf("upload error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGuardGroupOwnerRemovalCoverage(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
@@ -190,23 +190,23 @@ func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "30")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
want := map[string]any{"cursor": int64(42), "size": "30"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
for _, size := range []string{"-1", "0", "31"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 30") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
contains []string
|
||||
notContain string
|
||||
}{
|
||||
{
|
||||
name: "send returns task ID",
|
||||
command: "send",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"query-send-status --open-task-id <openTaskId>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query returns message and conversation IDs",
|
||||
command: "query-send-status",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"openMessageId",
|
||||
"openConversationId",
|
||||
"chat message edit",
|
||||
"chat message recall",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edit includes post-send workflow",
|
||||
command: "edit",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
{
|
||||
name: "recall includes post-send workflow",
|
||||
command: "recall",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", test.command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, want := range test.contains {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help)
|
||||
}
|
||||
}
|
||||
if test.notContain != "" && strings.Contains(help, test.notContain) {
|
||||
t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatCatalogSafetyMetadataIsExplicit(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(newChatCommand())
|
||||
|
||||
checked := 0
|
||||
walkChatCatalogLeaves(root, func(cmd *cobra.Command) {
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok || final.Identity == nil || final.Safety == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(final.Identity.ProductID) != "chat" {
|
||||
return
|
||||
}
|
||||
checked++
|
||||
canonical := strings.TrimSpace(final.Identity.CanonicalPath)
|
||||
safety := final.Safety
|
||||
if safety.Effect == "" || safety.Risk == "" ||
|
||||
safety.Confirmation == "" || safety.Idempotency == "" {
|
||||
t.Fatalf("chat Catalog leaf %s has incomplete Safety: %+v", canonical, *safety)
|
||||
}
|
||||
if safety.Effect == "read" {
|
||||
if safety.Risk != "low" ||
|
||||
safety.Confirmation != "not_required" ||
|
||||
safety.Idempotency != "idempotent" {
|
||||
t.Fatalf("chat read Catalog leaf %s Safety = %+v, want read/low/not_required/idempotent",
|
||||
canonical, *safety)
|
||||
}
|
||||
}
|
||||
})
|
||||
if checked == 0 {
|
||||
t.Fatal("no chat Catalog leaves checked")
|
||||
}
|
||||
}
|
||||
|
||||
func walkChatCatalogLeaves(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Runnable() && !cmd.HasSubCommands() {
|
||||
fn(cmd)
|
||||
return
|
||||
}
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
walkChatCatalogLeaves(child, fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
chathelpers "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/chat"
|
||||
)
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
chathelpers.SetDeps(chathelpers.Deps{
|
||||
GroupRunE: GroupRunE,
|
||||
CallMCPToolOnServer: CallMCPToolOnServer,
|
||||
DeclareLeafMetadata: func(cmd *cobra.Command, spec chathelpers.LeafSpec) *cobra.Command {
|
||||
return DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: spec.Safety,
|
||||
Contract: spec.Contract,
|
||||
})
|
||||
},
|
||||
})
|
||||
return chathelpers.NewChatToolbarCommand()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatToolbarParentCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *chatToolbarParentCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.product = productID
|
||||
c.tool = toolName
|
||||
c.args = args
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*chatToolbarParentCaller) Format() string { return "json" }
|
||||
func (*chatToolbarParentCaller) DryRun() bool { return false }
|
||||
func (*chatToolbarParentCaller) Fields() string { return "" }
|
||||
func (*chatToolbarParentCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageChatToolbarMountedThroughParentDeps(t *testing.T) {
|
||||
caller := &chatToolbarParentCaller{}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
|
||||
root := newChatCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if command, _, err := root.Find([]string{"toolbar", "list"}); err != nil || command == nil || command.Name() != "list" {
|
||||
t.Fatalf("find chat toolbar list = command %v, err %v", command, err)
|
||||
}
|
||||
|
||||
root.SetArgs([]string{"toolbar", "list", "--conversation-id", "cidX"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("chat toolbar list returned error: %v", err)
|
||||
}
|
||||
if caller.product != "im" || caller.tool != "list_chat_toolbar_shortcuts" {
|
||||
t.Fatalf("tool call = %s/%s, want im/list_chat_toolbar_shortcuts", caller.product, caller.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"openConversationId": "cidX"}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
_ = isEmptyDataValidation(value)
|
||||
_ = isEmptyHyperlink(value)
|
||||
}
|
||||
_ = fillIntMatrix(2, 3, 4)
|
||||
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
|
||||
_ = maxColLen2D([][]int{{1}, {2, 3}})
|
||||
_ = checkMatrixShape(1, 2, 1, 2, "x")
|
||||
@@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{styleSpec{}, 1, 1},
|
||||
}
|
||||
for _, style := range styles {
|
||||
_ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{"", `[[""]]`, hAlignEnum},
|
||||
{"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
}
|
||||
}
|
||||
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
|
||||
_, _ = findFilterViewByID(views, "one")
|
||||
|
||||
@@ -81,6 +81,53 @@ func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChmodRequiresConfirmationBeforeRemoteCall(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"chmod", "chat.message:send",
|
||||
"--agentCode", "wukong",
|
||||
"--grant-type", "timed",
|
||||
"--ttl", "24h",
|
||||
"--conversation-id", "cidX",
|
||||
)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChmodCallsRemoteWithExactArgsWhenConfirmed(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"chmod", "chat.message:send",
|
||||
"--agentCode", "wukong",
|
||||
"--grant-type", "timed",
|
||||
"--ttl", "24h",
|
||||
"--conversation-id", "cidX",
|
||||
"--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat chmod with --yes returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "chat_permission_grant" {
|
||||
t.Fatalf("tool call = %s/%s, want im/chat_permission_grant", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"agentCode": "wukong",
|
||||
"scope": "chat.message:send",
|
||||
"grantType": "timed",
|
||||
"ttl": "24h",
|
||||
"grantParams": `{"conversationId":"cidX","openCid":"cidX","openConversationId":"cidX"}`,
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
type guardedStepCaller struct {
|
||||
calls []guardedMutationCall
|
||||
steps []string
|
||||
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
|
||||
doc := &cobra.Command{
|
||||
Use: "doc",
|
||||
@@ -81,6 +83,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(doc)
|
||||
doc.AddCommand(newDevDocSearchCommand())
|
||||
|
||||
root.AddCommand(
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"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/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -108,6 +109,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
|
||||
webapp := &cobra.Command{
|
||||
Use: "webapp",
|
||||
@@ -119,6 +121,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(webapp)
|
||||
webapp.AddCommand(
|
||||
newDevAppWebappGetCommand(runner),
|
||||
newDevAppWebappConfigCommand(runner),
|
||||
@@ -134,6 +137,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(permission)
|
||||
permission.AddCommand(
|
||||
newDevAppPermissionListCommand(runner),
|
||||
newDevAppPermissionAddCommand(runner),
|
||||
@@ -150,6 +154,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(credentials)
|
||||
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
|
||||
|
||||
member := &cobra.Command{
|
||||
@@ -162,6 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(member)
|
||||
member.AddCommand(
|
||||
newDevAppMemberListCommand(runner),
|
||||
newDevAppMemberAddCommand(runner),
|
||||
@@ -178,6 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(security)
|
||||
security.AddCommand(newDevAppSecurityConfigCommand(runner))
|
||||
|
||||
robot := &cobra.Command{
|
||||
@@ -190,6 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(robot)
|
||||
robot.AddCommand(
|
||||
newDevAppRobotSubmitCommand(runner),
|
||||
newDevAppRobotResultCommand(runner),
|
||||
@@ -209,6 +217,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(version)
|
||||
version.AddCommand(
|
||||
newDevAppVersionCreateCommand(runner),
|
||||
newDevAppVersionListCommand(runner),
|
||||
@@ -228,6 +237,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(event)
|
||||
event.AddCommand(
|
||||
newDevAppEventListCommand(runner),
|
||||
newDevAppEventSubscribeCommand(runner),
|
||||
|
||||
@@ -221,6 +221,43 @@ func runDocUpload(cmd *cobra.Command, _ []string) error {
|
||||
return callMCPTool("commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
|
||||
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
|
||||
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
|
||||
// 兼容告警,调用方负责结果投影。
|
||||
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
|
||||
step1Args := map[string]any{}
|
||||
if folder != "" {
|
||||
step1Args["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
step1Args["workspaceId"] = workspace
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
commitArgs := map[string]any{
|
||||
"uploadKey": uploadKey,
|
||||
"name": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
}
|
||||
if folder != "" {
|
||||
commitArgs["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
commitArgs["workspaceId"] = workspace
|
||||
}
|
||||
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
|
||||
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
|
||||
var data map[string]any
|
||||
@@ -3764,6 +3801,9 @@ CLI 内部自动完成全部流程:
|
||||
xlsx, xls → 电子表格
|
||||
md, txt → 文字文档
|
||||
xmind, mark → 脑图
|
||||
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
|
||||
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
|
||||
为 md;上传到钉盘请用 dws drive upload
|
||||
|
||||
文件大小限制: 20MB
|
||||
|
||||
|
||||
@@ -210,8 +210,16 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
}
|
||||
return "", WrapError(err)
|
||||
}
|
||||
if result == nil {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "MCP 工具返回 nil result,无法判断操作结果",
|
||||
Suggestion: "不要把空回复当作成功;请重试并携带 operation/trace 信息排查服务端",
|
||||
Operation: serverID + "/" + toolName,
|
||||
}
|
||||
}
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" && c.Text != "" {
|
||||
if c.Type == "text" && strings.TrimSpace(c.Text) != "" {
|
||||
dumpRawToolResponse(serverID, toolName, c.Text)
|
||||
var errBody map[string]any
|
||||
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
|
||||
@@ -243,6 +251,12 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
return c.Text, nil
|
||||
}
|
||||
}
|
||||
// Some legacy tools intentionally use an empty text response as an
|
||||
// acknowledgement. This low-level parser cannot know the business contract,
|
||||
// so preserve that representation. Data-returning callers must validate the
|
||||
// operation-specific shape (for example records/tables/valid) before they
|
||||
// report success. Orchestrators whose contract requires a business result use
|
||||
// CallMCPWriteDataStrict and may prove an unknown effect by independent read-back.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,11 @@ func TestCrossPlatformCoverageMCPReturnTextClassification(t *testing.T) {
|
||||
}
|
||||
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "ignored"}, {Type: "text"}}}
|
||||
if got, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err != nil || got != "" {
|
||||
t.Fatalf("empty text result = %q, %v", got, err)
|
||||
t.Fatalf("low-level empty text representation = %q, %v", got, err)
|
||||
}
|
||||
caller.result = nil
|
||||
if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil {
|
||||
t.Fatal("nil result must fail")
|
||||
}
|
||||
caller.result = textToolResult("shortcut result")
|
||||
if got, err := CallMCPToolTextOnServer("server", "tool", nil); err != nil || got != "shortcut result" {
|
||||
|
||||
@@ -58,6 +58,11 @@ type importFlowConfig struct {
|
||||
timeoutAsResult bool
|
||||
nextCommand string
|
||||
poll importPollPolicy
|
||||
// uploadFallback 开启后,所有不在 supportedFormats 白名单内的文件
|
||||
// (html/pdf/zip/无扩展名等)不再报错断链,统一移交文档空间文件上传
|
||||
// 链路原样入库;白名单即后端转换能力的封闭集合,无需第二份格式枚举。
|
||||
// 回退共享 prepareImportFile 的存在性 / 20MB / 空文件校验。
|
||||
uploadFallback bool
|
||||
}
|
||||
|
||||
type preparedImportFile struct {
|
||||
@@ -98,6 +103,10 @@ func docImportFlowConfig() importFlowConfig {
|
||||
workspaceFlags: []string{"workspace", "workspace-id"},
|
||||
nextCommand: "dws doc import get --task-id %s",
|
||||
poll: defaultImportPollPolicy(),
|
||||
// 白名单外的格式改走文档空间的文件上传链路
|
||||
// (与 drive upload --workspace 同一条 doc-space 上传原语),
|
||||
// 目标 flags(--folder/--workspace)与 import 同构,链路不中断。
|
||||
uploadFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +174,11 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
|
||||
}
|
||||
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if !cfg.supportedFormats[extension] {
|
||||
// 非回退配置保持基线校验顺序:扩展名门禁先于导入目标校验
|
||||
// (sheet import 对无目标的非 Excel 文件必须先报 unsupported)。
|
||||
// uploadFallback 配置的白名单外文件继续走完共享校验,由
|
||||
// runImportCommand 分派到上传回退。
|
||||
if !cfg.supportedFormats[extension] && !cfg.uploadFallback {
|
||||
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
|
||||
}
|
||||
|
||||
@@ -197,11 +210,122 @@ func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args
|
||||
return callMCPToolReturnText(ctx, toolName, args)
|
||||
}
|
||||
|
||||
// runImportUploadFallback 承接白名单外格式:不再报错断链,改走文档空间
|
||||
// 文件上传链路原样入库。回退在 prepareImportFile 之后执行,共享存在性 /
|
||||
// 20MB / 空文件校验;不复用 runDocUpload,避免携带 doc upload 的
|
||||
// --workspace 兼容告警。移交事实通过 stderr 显式告知,机器可读结果统一
|
||||
// 携带 fallback=upload / converted=false 标记,防止 Agent 误判已完成
|
||||
// 在线文档转换。
|
||||
func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file preparedImportFile) error {
|
||||
label := file.extension
|
||||
if label == "" {
|
||||
label = "无扩展名"
|
||||
}
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
|
||||
label, cfg.supportedFormatsText))
|
||||
|
||||
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
|
||||
uploadName := file.name
|
||||
if filepath.Ext(uploadName) == "" && file.extension != "" {
|
||||
uploadName += "." + file.extension
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"file": file.path,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"size": file.size,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档(doc import 回退)")
|
||||
deps.Out.PrintKeyValue("文件", file.path)
|
||||
deps.Out.PrintKeyValue("名称", uploadName)
|
||||
deps.Out.PrintKeyValue("格式", file.extension)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("按原文件上传中(未转换为在线文档)...")
|
||||
}
|
||||
text, err := docSpaceUploadCommitText(ctx, file.path, uploadName, file.size, file.folder, file.workspace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fail-closed:commit 响应必须可解析且带文件标识才算成功;
|
||||
// 空响应(legacy ack)、非 JSON 或缺少标识都不得包装为 success
|
||||
commit, dentryID, err := parseUploadCommitResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"dentry_id": dentryID,
|
||||
"result": commit,
|
||||
})
|
||||
}
|
||||
|
||||
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
|
||||
// 按优先级排列;服务端可能返回平铺对象或包一层 result envelope。
|
||||
var uploadCommitIDKeys = []string{"dentryUuid", "dentryId", "nodeId", "fileId", "id"}
|
||||
|
||||
// parseUploadCommitResult 校验入库响应:拒绝空响应,要求 JSON 对象且
|
||||
// 含文件标识,返回解析后的对象与标识值。任何不满足都返回错误,
|
||||
// 由调用方向用户提示核对入库结果,而不是伪装成功。
|
||||
func parseUploadCommitResult(text string) (map[string]any, string, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return nil, "", fmt.Errorf("上传入库未返回结果(commit_uploaded_file 响应为空),无法确认文件已入库;请用 dws doc list 核对目标位置")
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, "", fmt.Errorf("上传入库响应无法解析为 JSON,无法确认文件已入库;原始响应: %s", trimmed)
|
||||
}
|
||||
payload := parsed
|
||||
if inner, ok := parsed["result"].(map[string]any); ok {
|
||||
payload = inner
|
||||
}
|
||||
for _, key := range uploadCommitIDKeys {
|
||||
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return parsed, v, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("上传入库响应缺少文件标识(%s 均为空),无法确认文件已入库;原始响应: %s", strings.Join(uploadCommitIDKeys, "/"), trimmed)
|
||||
}
|
||||
|
||||
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
|
||||
file, err := prepareImportFile(cmd, args, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
|
||||
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
|
||||
return runImportUploadFallback(cmd, cfg, file)
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
errUploadInfo = errors.New("upload info failed")
|
||||
errUploadPut = errors.New("put failed")
|
||||
)
|
||||
|
||||
func htmlFallbackCommand(t *testing.T, filePath string) *cobra.Command {
|
||||
t.Helper()
|
||||
// callMCPToolReturnText 从 os.Args 解析产品名(doc)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := &cobra.Command{Use: "import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("folder", "", "")
|
||||
cmd.Flags().String("workspace", "", "")
|
||||
cmd.Flags().String("folder-id", "", "")
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
if filePath != "" {
|
||||
if err := cmd.Flags().Set("file", filePath); err != nil {
|
||||
t.Fatalf("set import file: %v", err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
|
||||
uploadSteps := []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
|
||||
}
|
||||
|
||||
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout, warnings bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
|
||||
}
|
||||
if got := caller.args["workspaceId"]; got != "ws-1" {
|
||||
t.Fatalf("commit workspaceId = %v, want ws-1", got)
|
||||
}
|
||||
if got := caller.args["name"]; got != "sales.html" {
|
||||
t.Fatalf("commit name = %v, want original file name with extension", got)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("fallback markers missing: %#v", payload)
|
||||
}
|
||||
if payload["dentry_id"] != "node-1" {
|
||||
t.Fatalf("dentry_id = %v, want node-1", payload["dentry_id"])
|
||||
}
|
||||
if payload["requested_operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("requested_operation = %v", payload["requested_operation"])
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "文件上传链路") {
|
||||
t.Fatalf("fallback must announce the upload on stderr, got %q", warnings.String())
|
||||
}
|
||||
if strings.Contains(warnings.String(), "deprecated") {
|
||||
t.Fatalf("fallback must not emit the doc upload deprecation warning, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json dry run stays a single json document", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("dry-run fallback payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("any non-importable format is redirected, not enumerated", func(t *testing.T) {
|
||||
for _, ext := range []string{"pdf", "zip", "png", "mp4"} {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercase htm extension via positional argument", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
path := writeImportFixture(t, "HTM")
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden folder-id alias reaches the upload chain", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("folder-id", "folder-abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if got := caller.args["folderId"]; got != "folder-abc" {
|
||||
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extensionless file is redirected with a readable label", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var warnings bytes.Buffer
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
noExt := filepath.Join(t.TempDir(), "README")
|
||||
if err := os.WriteFile(noExt, []byte("plain"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, noExt)
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "无扩展名") {
|
||||
t.Fatalf("warning must label the extensionless file, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared 20MB limit", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
big := filepath.Join(t.TempDir(), "big.html")
|
||||
f, err := os.Create(big)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(importMaxFileSize + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, big)
|
||||
err = runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("runImportCommand() error = %v, want 20MB limit rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared empty-file guard", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
empty := filepath.Join(t.TempDir(), "empty.html")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, empty)
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "file is empty") {
|
||||
t.Fatalf("runImportCommand() error = %v, want empty-file rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("importable formats keep the conversion path", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "md"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("import dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("operation = %v, want conversion path", payload["operation"])
|
||||
}
|
||||
if _, ok := payload["fallback"]; ok {
|
||||
t.Fatalf("importable format must not carry fallback marker: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file falls through to the import required-flag error", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "--file is required") {
|
||||
t.Fatalf("runImportCommand() error = %v, want --file required", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text dry run prints the fallback plan as key values", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "doc import 回退") || !strings.Contains(stdout.String(), "sales.html") {
|
||||
t.Fatalf("text dry-run must print the fallback plan, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload chain errors propagate", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
putErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "upload info request fails", steps: []scriptedToolStep{{err: errUploadInfo}}, wantErr: "upload info failed"},
|
||||
{name: "upload credentials incomplete", steps: []scriptedToolStep{{text: `{"resourceUrl":""}`}}, wantErr: "incomplete upload credentials"},
|
||||
{name: "http put fails", steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`}}, putErr: errUploadPut, wantErr: "put failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit responses that cannot prove success are rejected", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
commit string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty legacy ack", commit: " ", wantErr: "响应为空"},
|
||||
{name: "non-json text", commit: "commit-ok-plain-text", wantErr: "无法解析为 JSON"},
|
||||
{name: "missing file identity", commit: `{"ok":true}`, wantErr: "缺少文件标识"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: tc.commit},
|
||||
}})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested result envelope yields the dentry id", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must stay JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dentry_id"] != "nested-node-9" {
|
||||
t.Fatalf("dentry_id = %v, want nested-node-9", payload["dentry_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet import keeps rejecting html without fallback", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
// 无导入目标时也必须先报 unsupported(基线校验顺序:扩展名先于目标)
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (no target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
|
||||
cmd = htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (with target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -143,39 +143,42 @@ func leafSpecCompositeLits(t *testing.T) []leafSpecLit {
|
||||
}
|
||||
dir := filepath.Dir(file)
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool {
|
||||
name := info.Name()
|
||||
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go")
|
||||
}, 0)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("parse helpers: %v", err)
|
||||
t.Fatalf("read helpers dir: %v", err)
|
||||
}
|
||||
var out []leafSpecLit
|
||||
for _, pkg := range pkgs {
|
||||
for _, f := range pkg.Files {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
fun, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
|
||||
return true
|
||||
}
|
||||
out = append(out, leafSpecLit{
|
||||
pos: fset.Position(lit.Pos()).String(),
|
||||
lit: lit,
|
||||
})
|
||||
return true
|
||||
})
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
parsed, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse helpers file %s: %v", name, err)
|
||||
}
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
fun, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
|
||||
return true
|
||||
}
|
||||
out = append(out, leafSpecLit{
|
||||
pos: fset.Position(lit.Pos()).String(),
|
||||
lit: lit,
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no NewLeafCommand(LeafSpec{...}) literals found")
|
||||
|
||||
@@ -44,6 +44,7 @@ func newSheetCommand() *cobra.Command {
|
||||
|
||||
命令结构:
|
||||
dws sheet create 创建钉钉表格文档
|
||||
dws sheet create-with-data 创建表格文档并写入初始数据(可选样式)
|
||||
dws sheet list 获取全部工作表列表
|
||||
dws sheet info 获取指定工作表详情
|
||||
dws sheet new 新建工作表
|
||||
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet chart update 更新浮动图表
|
||||
dws sheet chart delete 删除浮动图表
|
||||
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
|
||||
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
|
||||
dws sheet import 导入 xlsx/xls 为在线电子表格
|
||||
dws sheet template list 获取表格模板列表
|
||||
dws sheet template search 搜索表格模板
|
||||
@@ -159,6 +161,10 @@ func newSheetCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
// 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite
|
||||
// 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。
|
||||
createWithDataCmd := newSheetCreateWithDataCmd()
|
||||
exportCsvCmd := newSheetExportCsvCmd()
|
||||
importCmd := newSheetImportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
@@ -238,7 +244,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
for _, cmd := range standaloneCmds {
|
||||
|
||||
@@ -423,6 +423,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||||
group-dimension / ungroup-dimension
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
|
||||
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
|
||||
|
||||
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
|
||||
dws sheet group-dimension 命令。
|
||||
@@ -437,7 +439,8 @@ dws sheet group-dimension 命令。
|
||||
dws sheet batch-update --node NODE_ID --operations '[
|
||||
{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},
|
||||
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
|
||||
{"toolName":"csv-put","input":{"sheet-id":"Sheet1","start-cell":"C1","csv":"=1+1,\u0027=1+1"}}
|
||||
]' --yes
|
||||
|
||||
# 宽松模式
|
||||
@@ -514,12 +517,12 @@ func newRangeBatchClearCmd() *cobra.Command {
|
||||
}
|
||||
operations := make([]any, 0, len(ranges))
|
||||
for i, rng := range ranges {
|
||||
idx := strings.Index(rng, "!")
|
||||
if idx <= 0 || idx == len(rng)-1 {
|
||||
return fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", i, rng)
|
||||
// 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里
|
||||
// ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。
|
||||
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetName := strings.TrimSpace(rng[:idx])
|
||||
rangeAddr := strings.TrimSpace(rng[idx+1:])
|
||||
operations = append(operations, map[string]any{
|
||||
"toolName": "clear_range",
|
||||
"input": map[string]any{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type csvPutRecordingCaller struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *csvPutRecordingCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.tool = tool
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*csvPutRecordingCaller) Format() string { return "json" }
|
||||
func (*csvPutRecordingCaller) DryRun() bool { return false }
|
||||
func (*csvPutRecordingCaller) Fields() string { return "" }
|
||||
func (*csvPutRecordingCaller) JQ() string { return "" }
|
||||
|
||||
func TestCSVPutFormulaContractAndPassThrough(t *testing.T) {
|
||||
const formulaCSV = "=1+1,'=1+1"
|
||||
|
||||
root := &cobra.Command{Use: "sheet"}
|
||||
root.AddCommand(newDataCmds()...)
|
||||
csvPut := findCoverageSubcommand(t, root, "csv-put")
|
||||
for _, want := range []string{"支持公式", "以 = 开头时默认按公式解析", "以 = 开头的字面文本", "'=1+1"} {
|
||||
if !strings.Contains(csvPut.Short+"\n"+csvPut.Long+"\n"+csvPut.Example, want) {
|
||||
t.Fatalf("csv-put help does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csvPut.Long, "不支持公式") || strings.Contains(csvPut.Long, "=开头当文本") {
|
||||
t.Fatalf("csv-put help still advertises the old formula contract: %s", csvPut.Long)
|
||||
}
|
||||
if strings.Contains(csvPut.Long, "写入公式文本") {
|
||||
t.Fatalf("csv-put help incorrectly describes apostrophe escaping as formula text: %s", csvPut.Long)
|
||||
}
|
||||
|
||||
caller := &csvPutRecordingCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "csv-put"})
|
||||
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"csv-put",
|
||||
"--node", "node",
|
||||
"--sheet-id", "Sheet1",
|
||||
"--start-cell", "A1",
|
||||
"--csv", formulaCSV,
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute csv-put: %v", err)
|
||||
}
|
||||
if caller.tool != "set_range_from_csv" {
|
||||
t.Fatalf("tool = %q, want set_range_from_csv", caller.tool)
|
||||
}
|
||||
if got := caller.args["csv"]; got != formulaCSV {
|
||||
t.Fatalf("csv argument = %#v, want exact pass-through %q", got, formulaCSV)
|
||||
}
|
||||
if _, ok := caller.args["interpretFormulas"]; ok {
|
||||
t.Fatalf("csv-put unexpectedly added an interpretFormulas argument: %#v", caller.args)
|
||||
}
|
||||
|
||||
batchArgs := BuildCsvPutArgs(map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"start-cell": "A1",
|
||||
"csv": formulaCSV,
|
||||
})
|
||||
if got := batchArgs["csv"]; got != formulaCSV {
|
||||
t.Fatalf("batch csv argument = %#v, want exact pass-through %q", got, formulaCSV)
|
||||
}
|
||||
if _, ok := batchArgs["interpretFormulas"]; ok {
|
||||
t.Fatalf("batch csv-put unexpectedly added an interpretFormulas argument: %#v", batchArgs)
|
||||
}
|
||||
|
||||
batchHelp := newBatchUpdateCmd().Long
|
||||
for _, want := range []string{"csv-put", "以 = 开头时按公式解析", "以 = 开头的字面文本", "'=1+1"} {
|
||||
if !strings.Contains(batchHelp, want) {
|
||||
t.Fatalf("batch-update help does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(batchHelp, "写入公式文本") {
|
||||
t.Fatalf("batch-update help incorrectly describes apostrophe escaping as formula text: %s", batchHelp)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--match-case 区分大小写(默认 false)
|
||||
--match-entire-cell 完整单元格匹配(默认 false)
|
||||
--use-regexp 使用正则表达式匹配(默认 false)
|
||||
--match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值)
|
||||
--include-hidden 包含隐藏行/列(默认 false)
|
||||
--range 限定替换范围,A1 表示法(不传时在整个工作表中替换)
|
||||
|
||||
@@ -166,6 +167,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
toolArgs["matchEntireCell"] = matchEntireCell
|
||||
useRegExp, _ := cmd.Flags().GetBool("use-regexp")
|
||||
toolArgs["useRegExp"] = useRegExp
|
||||
matchFormula, _ := cmd.Flags().GetBool("match-formula")
|
||||
toolArgs["matchFormulaText"] = matchFormula
|
||||
includeHidden, _ := cmd.Flags().GetBool("include-hidden")
|
||||
toolArgs["includeHidden"] = includeHidden
|
||||
return callMCPTool("replace_all", toolArgs)
|
||||
@@ -198,6 +201,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "find", Property: "text"},
|
||||
{Name: "match-formula", Property: "matchFormulaText"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "replacement", Property: "replaceText"},
|
||||
{Name: "use-regexp", Property: "useRegExp"},
|
||||
@@ -212,6 +216,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
replaceCmd.Flags().Bool("match-case", false, "区分大小写 (默认 false)")
|
||||
replaceCmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
|
||||
replaceCmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配")
|
||||
replaceCmd.Flags().Bool("match-formula", false, "在公式文本中查找替换(默认 false)")
|
||||
replaceCmd.Flags().Bool("include-hidden", false, "包含隐藏行/列")
|
||||
|
||||
appendCmd := &cobra.Command{
|
||||
@@ -281,16 +286,17 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
|
||||
csvPutCmd := &cobra.Command{
|
||||
Use: "csv-put",
|
||||
Short: "将 CSV 数据写入表格指定位置(纯值,自动扩容)",
|
||||
Short: "将 CSV 数据写入表格指定位置(支持公式,自动扩容)",
|
||||
Long: `将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
|
||||
只写纯值,不支持公式/样式/批注。=开头当文本处理。自动扩容行列。
|
||||
写入值和公式,不支持样式/批注。字段值以 = 开头时默认按公式解析;
|
||||
如需写入以 = 开头的字面文本,在字段值前加单引号(例如 "'=1+1")。自动扩容行列。
|
||||
目标区域如含合并单元格,csv-put 会打散合并并覆盖所有单元格;这不同于
|
||||
range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
|
||||
--csv 支持三种输入:直接传文本、@filepath 从文件读取、- 从 stdin 读取。
|
||||
--allow-overwrite 默认 false,目标区域有数据时需显式传 --allow-overwrite 才能覆盖。`,
|
||||
Example: ` dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell A1 \
|
||||
--csv 'name,score\nalice,95\nbob,87'
|
||||
--csv "=1+1,'=1+1"
|
||||
|
||||
dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell B2 \
|
||||
--csv @data.csv --allow-overwrite
|
||||
@@ -347,17 +353,20 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
CLIPath: "sheet csv-put",
|
||||
PrimaryCLIPath: "sheet csv-put",
|
||||
},
|
||||
Description: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
|
||||
Description: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_range_from_csv"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
|
||||
UseWhen: []string{"写入纯值且超过约 5 行/20 格,或数据来自 CSV/表格文本时优先使用"},
|
||||
AvoidWhen: []string{"需要公式/超链接/富文本用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite"},
|
||||
Examples: []string{"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite"},
|
||||
AgentSummary: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
|
||||
UseWhen: []string{"需要从 CSV/表格文本批量写入值或以 = 开头的公式时优先使用"},
|
||||
AvoidWhen: []string{"需要超链接/富文本/样式/数据验证用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite;要把 = 开头内容写成字面文本需前加单引号"},
|
||||
Examples: []string{
|
||||
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite",
|
||||
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv \"=1+1,'=1+1\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
|
||||
@@ -3,12 +3,21 @@ package helpers
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// sizeTypeEnumHint 返回该维度支持的尺寸模式列表(行高含 auto,列宽不含,与飞书一致)。
|
||||
func sizeTypeEnumHint(dimension string) string {
|
||||
if dimension == "ROWS" {
|
||||
return "pixel / standard / auto"
|
||||
}
|
||||
return "pixel / standard"
|
||||
}
|
||||
|
||||
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
|
||||
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
|
||||
func newDimensionCmds() []*cobra.Command {
|
||||
@@ -43,8 +52,11 @@ func newDimensionCmds() []*cobra.Command {
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -428,8 +440,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -490,9 +505,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--dimension COLUMNS 时,--start-index 为列字母,如 "A" 表示从 A 列开始、"AB" 表示从 AB 列开始
|
||||
|
||||
支持在 --start-index 中携带工作表前缀(如 "Sheet1!3" / "Sheet1!A"),此时将忽略 --sheet-id。
|
||||
--hidden 与 --pixel-size 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)。
|
||||
当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
--pixel-size 单位为像素,dimension=ROWS 时表示行高、dimension=COLUMNS 时表示列宽。
|
||||
|
||||
尺寸模式(--size-type,对齐飞书):
|
||||
pixel 默认,按 --pixel-size 指定的像素值设置
|
||||
standard 恢复默认行高/列宽,无需 --pixel-size
|
||||
auto 按内容自适应行高,仅 ROWS 支持(列宽不提供自适应),无需 --pixel-size
|
||||
|
||||
常见场景:隐藏/显示指定连续行或列、批量调整行高/列宽、在同一次调用中同时修改尺寸与显隐。`,
|
||||
Example: ` # 隐藏第 3~4 行
|
||||
dws sheet update-dimension --node NODE_ID --sheet-id SHEET_ID --dimension ROWS --start-index "3" --length 2 --hidden
|
||||
@@ -517,8 +538,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -526,8 +550,30 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
}
|
||||
hiddenChanged := cmd.Flags().Changed("hidden")
|
||||
pixelSizeChanged := cmd.Flags().Changed("pixel-size")
|
||||
if !hiddenChanged && !pixelSizeChanged {
|
||||
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个")
|
||||
sizeType, _ := cmd.Flags().GetString("size-type")
|
||||
sizeType = strings.ToLower(strings.TrimSpace(sizeType))
|
||||
// 尺寸模式枚举按维度区分(与飞书一致):行高有 auto,列宽只有 pixel / standard
|
||||
switch {
|
||||
case sizeType == "" || sizeType == "pixel" || sizeType == "standard":
|
||||
case sizeType == "auto" && dimension == "ROWS":
|
||||
case sizeType == "auto":
|
||||
return fmt.Errorf("--size-type 对 COLUMNS 仅支持 pixel / standard(列宽不提供自适应)")
|
||||
default:
|
||||
return fmt.Errorf("--size-type 必须为 %s,当前值: %s", sizeTypeEnumHint(dimension), sizeType)
|
||||
}
|
||||
// standard/auto 由服务端决定尺寸,无需 --pixel-size
|
||||
sizeTypeDrivesSize := sizeType == "standard" || sizeType == "auto"
|
||||
if !hiddenChanged && !pixelSizeChanged && !sizeTypeDrivesSize {
|
||||
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)")
|
||||
}
|
||||
// standard/auto 说"尺寸交给服务端",--pixel-size 又指定固定像素,
|
||||
// 两者语义直接冲突;同时下发会得到依赖服务端实现的结果,故先拒。
|
||||
if sizeTypeDrivesSize && pixelSizeChanged {
|
||||
return fmt.Errorf("--size-type %s 表示尺寸由服务端决定,不能同时指定 --pixel-size;要指定固定像素请用 --size-type pixel(或省略 --size-type)", sizeType)
|
||||
}
|
||||
// 反向:显式声明 pixel 模式却不给像素值,同样是不完整的请求。
|
||||
if sizeType == "pixel" && !pixelSizeChanged {
|
||||
return fmt.Errorf("--size-type pixel 必须配合 --pixel-size 指定像素值;若本次只改显隐,请省略 --size-type")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
@@ -547,6 +593,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
}
|
||||
toolArgs["pixelSize"] = pixelSize
|
||||
}
|
||||
if sizeType != "" {
|
||||
toolArgs["sizeType"] = sizeType
|
||||
}
|
||||
return callMCPTool("update_dimension", toolArgs)
|
||||
},
|
||||
}
|
||||
@@ -587,6 +636,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
updateDimensionCmd.Flags().String("length", "", "更新数量,正整数 (必填),最大 5000")
|
||||
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
|
||||
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
|
||||
updateDimensionCmd.Flags().String("size-type", "", "尺寸模式(对齐飞书): pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)")
|
||||
|
||||
groupDimensionCmd := &cobra.Command{
|
||||
Use: "group-dimension",
|
||||
|
||||
@@ -2,6 +2,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -74,6 +75,84 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// --length 必须整个值都是合法正整数。改用 strconv.Atoi 之前走的是
|
||||
// fmt.Sscanf("%d"),只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作
|
||||
// ——delete 方向不可回滚。这是对既有命令的用户可见行为变更(已记入 CHANGELOG),
|
||||
// 用测试钉住,避免哪天改回宽松解析没人发现。
|
||||
func TestDimensionLengthRejectsTrailingCharacters(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
common := []string{"--node", "node", "--sheet-id", "sheet", "--dimension", "ROWS"}
|
||||
|
||||
for _, cmdName := range []string{"insert-dimension", "delete-dimension", "update-dimension"} {
|
||||
locator := []string{"--position", "1"}
|
||||
if cmdName == "update-dimension" {
|
||||
locator = []string{"--start-index", "1", "--hidden"}
|
||||
}
|
||||
for _, bad := range []string{"2x", "3foo", "1 2", "0x10", "abc", ""} {
|
||||
args := append(append(append([]string{}, common...), locator...), "--length", bad)
|
||||
err := executeDimensionCoverage(t, cmdName, args...)
|
||||
if err == nil {
|
||||
t.Errorf("%s --length %q 未报错,畸形长度被静默接受", cmdName, bad)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--length") {
|
||||
t.Errorf("%s --length %q: err = %v, want 指明 --length", cmdName, bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --size-type 的枚举按维度区分:行高有 auto,列宽只有 pixel / standard(对齐飞书)。
|
||||
func TestUpdateDimensionSizeTypeEnumIsDimensionScoped(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
common := []string{"--node", "node", "--sheet-id", "sheet", "--start-index", "1", "--length", "2"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dimension string
|
||||
sizeType string
|
||||
wantErr string
|
||||
}{
|
||||
// standard / auto 由服务端决定尺寸,不再要求 --hidden 或 --pixel-size
|
||||
{"rows-standard", "ROWS", "standard", ""},
|
||||
{"rows-auto", "ROWS", "auto", ""},
|
||||
{"columns-standard", "COLUMNS", "standard", ""},
|
||||
{"columns-auto-rejected", "COLUMNS", "auto", "仅支持 pixel / standard"},
|
||||
// 非法枚举的提示文案必须按维度给出不同的合法值列表
|
||||
{"rows-bogus-hint", "ROWS", "bogus", "必须为 pixel / standard / auto"},
|
||||
{"columns-bogus-hint", "COLUMNS", "bogus", "必须为 pixel / standard"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append(append([]string{}, common...), "--dimension", tc.dimension, "--size-type", tc.sizeType)
|
||||
err := executeDimensionCoverage(t, "update-dimension", args...)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pixel 仍需 --hidden 或 --pixel-size 之一
|
||||
args := append(append([]string{}, common...), "--dimension", "ROWS", "--size-type", "pixel")
|
||||
if err := executeDimensionCoverage(t, "update-dimension", args...); err == nil {
|
||||
t.Fatal("size-type=pixel 且未给 --pixel-size/--hidden 时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeTypeEnumHint(t *testing.T) {
|
||||
if got := sizeTypeEnumHint("ROWS"); got != "pixel / standard / auto" {
|
||||
t.Fatalf("ROWS hint = %q", got)
|
||||
}
|
||||
if got := sizeTypeEnumHint("COLUMNS"); got != "pixel / standard" {
|
||||
t.Fatalf("COLUMNS hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
|
||||
@@ -86,3 +165,80 @@ func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T)
|
||||
t.Fatalf("multi-select dropdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --size-type standard/auto 与 --pixel-size 语义冲突(一个说交给服务端、一个
|
||||
// 指定固定像素),必须在发请求前拒;反向 pixel 模式也必须带像素值。
|
||||
func TestUpdateDimensionRejectsSizeTypeAndPixelSizeConflict(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags [][2]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"standard-with-pixel-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}, {"pixel-size", "40"}},
|
||||
"不能同时指定 --pixel-size",
|
||||
},
|
||||
{
|
||||
"auto-with-pixel-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}, {"pixel-size", "40"}},
|
||||
"不能同时指定 --pixel-size",
|
||||
},
|
||||
{
|
||||
"explicit-pixel-without-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"hidden", "true"}},
|
||||
"--size-type pixel 必须配合 --pixel-size",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
|
||||
err := runUpdateDimensionForTest(t, caller, flags)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0(冲突必须在发请求前拒)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合法组合仍然放行
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags [][2]string
|
||||
}{
|
||||
{"standard-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}}},
|
||||
{"auto-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}}},
|
||||
{"pixel-with-size", [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"pixel-size", "40"}}},
|
||||
{"pixel-size-without-size-type", [][2]string{{"dimension", "COLUMNS"}, {"pixel-size", "120"}}},
|
||||
{"hidden-only", [][2]string{{"dimension", "ROWS"}, {"hidden", "true"}}},
|
||||
} {
|
||||
t.Run("ok/"+tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
|
||||
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
|
||||
if err := runUpdateDimensionForTest(t, caller, flags); err != nil {
|
||||
t.Fatalf("合法组合被拒: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateDimensionForTest(t *testing.T, caller *scriptedToolCaller, flags [][2]string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
installSheetProductArgs(t)
|
||||
for _, c := range newDimensionCmds() {
|
||||
if c.Name() != "update-dimension" {
|
||||
continue
|
||||
}
|
||||
for _, kv := range flags {
|
||||
if err := c.Flags().Set(kv[0], kv[1]); err != nil {
|
||||
t.Fatalf("set --%s: %v", kv[0], err)
|
||||
}
|
||||
}
|
||||
return c.RunE(c, nil)
|
||||
}
|
||||
t.Fatal("update-dimension not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV
|
||||
//
|
||||
// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子
|
||||
// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。
|
||||
// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一
|
||||
// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误
|
||||
// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。
|
||||
//
|
||||
// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按
|
||||
// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。
|
||||
// ============================================================================
|
||||
|
||||
func newSheetExportCsvCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "export-csv",
|
||||
Short: "导出单个工作表为纯 CSV(同步)",
|
||||
Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。
|
||||
|
||||
整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。
|
||||
|
||||
参数说明:
|
||||
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
|
||||
--sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表)
|
||||
--range 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
|
||||
--value-render-option 取值模式: formatted_value(默认) / raw_value / formula
|
||||
--output 本地保存路径(可选)。可为文件路径或目录(目录时存为
|
||||
sheet-export.csv);不传则把 CSV 打到 stdout
|
||||
--allow-truncated 允许数据被截断时仍然导出
|
||||
|
||||
截断行为:
|
||||
表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、
|
||||
或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。
|
||||
|
||||
落盘保证:
|
||||
写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误)
|
||||
不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。
|
||||
|
||||
权限要求:
|
||||
当前用户对目标表格具备可查看权限。`,
|
||||
Example: ` # 导出第一个工作表到 stdout(可管道处理)
|
||||
dws sheet export-csv --node NODE_ID
|
||||
|
||||
# 导出指定工作表为本地 CSV 文件
|
||||
dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv
|
||||
|
||||
# 只导出某个范围,取原始值
|
||||
dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`,
|
||||
RunE: runSheetExportCsv,
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "sheet",
|
||||
Name: "export_csv",
|
||||
CanonicalPath: "sheet.export_csv",
|
||||
CLIPath: "sheet export-csv",
|
||||
PrimaryCLIPath: "sheet export-csv",
|
||||
},
|
||||
Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
|
||||
UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"},
|
||||
AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"},
|
||||
Examples: []string{
|
||||
"dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv",
|
||||
"dws sheet export-csv --node <NODE_ID> --range A1:Z1000",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "range", Property: "range"},
|
||||
{Name: "sheet-id", Property: "sheetId"},
|
||||
{Name: "value-render-option", Property: "valueRenderOption"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)")
|
||||
cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)")
|
||||
cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula")
|
||||
cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout")
|
||||
cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// valueRenderOptionEnum 是 --value-render-option 的合法取值。
|
||||
var valueRenderOptionEnum = map[string]bool{
|
||||
"formatted_value": true, "raw_value": true, "formula": true,
|
||||
}
|
||||
|
||||
// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。
|
||||
func runSheetExportCsv(cmd *cobra.Command, _ []string) error {
|
||||
nodeID := mustGetFlag(cmd, "node")
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("flag --node is required")
|
||||
}
|
||||
sheetID, _ := cmd.Flags().GetString("sheet-id")
|
||||
rangeAddr, _ := cmd.Flags().GetString("range")
|
||||
valueRenderOption, _ := cmd.Flags().GetString("value-render-option")
|
||||
valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption))
|
||||
if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] {
|
||||
return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption)
|
||||
}
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
allowTruncated, _ := cmd.Flags().GetBool("allow-truncated")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "导出工作表为 CSV")
|
||||
deps.Out.PrintKeyValue("节点", nodeID)
|
||||
if sheetID != "" {
|
||||
deps.Out.PrintKeyValue("工作表", sheetID)
|
||||
}
|
||||
if outputPath != "" {
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"annotateRowNumbers": false,
|
||||
}
|
||||
if sheetID != "" {
|
||||
toolArgs["sheetId"] = sheetID
|
||||
}
|
||||
if rangeAddr != "" {
|
||||
toolArgs["range"] = rangeAddr
|
||||
}
|
||||
if valueRenderOption != "" {
|
||||
toolArgs["valueRenderOption"] = valueRenderOption
|
||||
}
|
||||
|
||||
// CSV 正文走 stdout,进度/警告一律不能污染它。
|
||||
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
}
|
||||
|
||||
csvContent, hasMore, err := parseGetRangeAsCsvResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0,
|
||||
// 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标
|
||||
// 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果
|
||||
// 必须显式加 --allow-truncated。
|
||||
if hasMore && !allowTruncated {
|
||||
return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+
|
||||
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+
|
||||
"或确认可接受不完整数据后加 --allow-truncated",
|
||||
firstNonEmpty(outputPath, "stdout"))
|
||||
}
|
||||
if hasMore {
|
||||
deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" +
|
||||
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。")
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
deps.Out.PrintRaw(csvContent)
|
||||
return nil
|
||||
}
|
||||
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
|
||||
outputPath = filepath.Join(outputPath, "sheet-export.csv")
|
||||
}
|
||||
// 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、
|
||||
// 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename,
|
||||
// 失败时原文件保持不变。
|
||||
// AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录
|
||||
// 不存在即报错」语义,避免把拼错的路径悄悄建成目录。
|
||||
if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil {
|
||||
return fmt.Errorf("写入 CSV 文件失败: %w", statErr)
|
||||
}
|
||||
if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil {
|
||||
return fmt.Errorf("写入 CSV 文件失败: %w", err)
|
||||
}
|
||||
if hasMore {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。
|
||||
//
|
||||
// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进
|
||||
// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。
|
||||
// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。
|
||||
func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) {
|
||||
var data map[string]any
|
||||
if e := json.Unmarshal([]byte(text), &data); e != nil {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e)
|
||||
}
|
||||
if raw, wrapped := data["result"]; wrapped {
|
||||
result, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text)
|
||||
}
|
||||
data = result
|
||||
}
|
||||
raw, exists := data["csv"]
|
||||
if !exists {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text)
|
||||
}
|
||||
csvVal, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text)
|
||||
}
|
||||
csv = csvVal
|
||||
if hm, ok := data["hasMore"].(bool); ok {
|
||||
hasMore = hm
|
||||
}
|
||||
return csv, hasMore, nil
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "sheet"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := newSheetExportCsvCmd()
|
||||
for index := 0; index < len(args); index += 2 {
|
||||
if err := cmd.Flags().Set(args[index], args[index+1]); err != nil {
|
||||
t.Fatalf("set %s: %v", args[index], err)
|
||||
}
|
||||
}
|
||||
return runSheetExportCsv(cmd, nil)
|
||||
}
|
||||
|
||||
// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里
|
||||
// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报
|
||||
// 成功。这里钉住两条命令的 flag 面互不渗透。
|
||||
func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) {
|
||||
xlsxOnly := []string{}
|
||||
newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) })
|
||||
sort.Strings(xlsxOnly)
|
||||
if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) {
|
||||
t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want)
|
||||
}
|
||||
|
||||
csvFlags := map[string]bool{}
|
||||
newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true })
|
||||
for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} {
|
||||
if !csvFlags[name] {
|
||||
t.Errorf("sheet export-csv 缺少 flag --%s", name)
|
||||
}
|
||||
}
|
||||
// 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。
|
||||
if csvFlags["export-format"] {
|
||||
t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvRequiresNode(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeSheetExportCsv(t, caller)
|
||||
if err == nil || !strings.Contains(err.Error(), "--node is required") {
|
||||
t.Fatalf("err = %v, want required node", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。
|
||||
func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty")
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") {
|
||||
t.Fatalf("err = %v, want enum rejection", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls)
|
||||
}
|
||||
|
||||
for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} {
|
||||
ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil {
|
||||
t.Fatalf("value-render-option=%q 应被接受: %v", option, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil {
|
||||
t.Fatalf("csv dry run: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run 不应发请求, calls = %d", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
// 截断默认失败,所以要显式放行才能走到落盘/输出。
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1",
|
||||
"value-render-option", "raw_value", "allow-truncated", "true"); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
for _, kv := range [][2]string{
|
||||
{"range", "A1:Z1000"},
|
||||
{"sheetId", "SHEET_1"},
|
||||
{"valueRenderOption", "raw_value"},
|
||||
} {
|
||||
if got := caller.args[kv[0]]; got != kv[1] {
|
||||
t.Errorf("%s 未透传: %#v", kv[0], got)
|
||||
}
|
||||
}
|
||||
// csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。
|
||||
if caller.args["annotateRowNumbers"] != false {
|
||||
t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"])
|
||||
}
|
||||
}
|
||||
|
||||
// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把
|
||||
// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。
|
||||
func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil {
|
||||
t.Fatal("截断时未放行应报错,而非静默成功")
|
||||
}
|
||||
for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误信息缺少 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// 关键:既有文件必须保持原样,不能被截断数据覆盖。
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(body) != "PREEXISTING\n" {
|
||||
t.Fatalf("目标文件被截断数据覆盖了: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 显式放行后允许落盘,但成功信息必须点明数据不完整。
|
||||
func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "output", out, "allow-truncated", "true"); err != nil {
|
||||
t.Fatalf("allow-truncated 应允许导出: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
if string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。
|
||||
func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}}
|
||||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil {
|
||||
t.Fatalf("完整读取不应要求放行: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(out)
|
||||
if err != nil || string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q, err = %v", string(body), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvWritesIntoDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv"))
|
||||
if err != nil {
|
||||
t.Fatalf("read exported csv: %v", err)
|
||||
}
|
||||
if string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、
|
||||
// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。
|
||||
func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||||
const original = "原有重要数据\n1,2,3\n"
|
||||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。
|
||||
previousRename := atomicRename
|
||||
t.Cleanup(func() { atomicRename = previousRename })
|
||||
atomicRename = func(string, string) error { return errors.New("no space left on device") }
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||||
t.Fatalf("err = %v, want write failure", err)
|
||||
}
|
||||
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(body) != original {
|
||||
t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body))
|
||||
}
|
||||
|
||||
// 临时文件不能留在目录里冒充导出产物。
|
||||
entries, readDirErr := os.ReadDir(filepath.Dir(out))
|
||||
if readDirErr != nil {
|
||||
t.Fatalf("read dir: %v", readDirErr)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != filepath.Base(out) {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("目录残留临时文件: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvReportsWriteFailure(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv")
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing)
|
||||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||||
t.Fatalf("err = %v, want write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvReportsReadFailure(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") {
|
||||
t.Fatalf("err = %v, want read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGetRangeAsCsvResult(t *testing.T) {
|
||||
// 兼容裸响应与 result 包装两种形状
|
||||
for _, body := range []string{
|
||||
`{"csv":"a,b\n","hasMore":true}`,
|
||||
`{"result":{"csv":"a,b\n","hasMore":true}}`,
|
||||
} {
|
||||
csv, hasMore, err := parseGetRangeAsCsvResult(body)
|
||||
if err != nil || csv != "a,b\n" || !hasMore {
|
||||
t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err)
|
||||
}
|
||||
}
|
||||
// 字段存在但为空串是合法的(真的空区域)
|
||||
csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`)
|
||||
if err != nil || csv != "" || hasMore {
|
||||
t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err)
|
||||
}
|
||||
}
|
||||
|
||||
// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用
|
||||
// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。
|
||||
func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) {
|
||||
for _, tc := range []struct{ body, want string }{
|
||||
{"not json", "解析 get_range_as_csv 响应失败"},
|
||||
{`{"message":"something odd"}`, "缺少 csv 字段"},
|
||||
{`{"csv":123}`, "csv 字段不是字符串"},
|
||||
{`{"csv":null}`, "csv 字段不是字符串"},
|
||||
{`{"result":"not-an-object"}`, "result 不是对象"},
|
||||
{`{"result":{"message":"odd"}}`, "缺少 csv 字段"},
|
||||
} {
|
||||
if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil ||
|
||||
!strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。
|
||||
func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||||
const original = "原有重要数据\n1,2,3\n"
|
||||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") {
|
||||
t.Fatalf("err = %v, want missing csv field", err)
|
||||
}
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(body) != original {
|
||||
t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会
|
||||
// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。
|
||||
func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
installSheetProductArgs(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps.Out.w, deps.Out.errW = &stdout, &stderr
|
||||
|
||||
cmd := newSheetExportCsvCmd()
|
||||
// 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。
|
||||
for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} {
|
||||
if err := cmd.Flags().Set(kv[0], kv[1]); err != nil {
|
||||
t.Fatalf("set --%s: %v", kv[0], err)
|
||||
}
|
||||
}
|
||||
if err := runSheetExportCsv(cmd, nil); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "a,b\nc,d\n" {
|
||||
t.Fatalf("stdout = %q, want 纯 CSV 正文", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") {
|
||||
t.Fatalf("stdout 混入了警告文本: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "CSV 已被截断") {
|
||||
t.Fatalf("stderr = %q, want 截断提示", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const sheetFormulaVerifyRemoteTool = "verify_formula"
|
||||
|
||||
func newSheetFormulaVerifyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "formula-verify",
|
||||
@@ -95,9 +97,9 @@ func newSheetFormulaVerifyCmd() *cobra.Command {
|
||||
// 返回 payload 判定是否存在公式错误,故走 ReturnText 再自行 PrintJSON。
|
||||
func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("sheet", "formula_verify", toolArgs)
|
||||
return callMCPToolOnServer("sheet", sheetFormulaVerifyRemoteTool, toolArgs)
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", "formula_verify", toolArgs)
|
||||
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", sheetFormulaVerifyRemoteTool, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
|
||||
return nil
|
||||
}
|
||||
if parsed == nil {
|
||||
return fmt.Errorf("formula_verify returned empty result")
|
||||
return fmt.Errorf("%s returned empty result", sheetFormulaVerifyRemoteTool)
|
||||
}
|
||||
if err := deps.Out.PrintJSON(parsed); err != nil {
|
||||
return err
|
||||
|
||||
@@ -42,6 +42,12 @@ func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testin
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", caller.calls)
|
||||
}
|
||||
if caller.server != "sheet" {
|
||||
t.Fatalf("server = %q, want sheet", caller.server)
|
||||
}
|
||||
if caller.tool != "verify_formula" {
|
||||
t.Fatalf("tool = %q, want verify_formula", caller.tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) {
|
||||
|
||||
@@ -168,7 +168,7 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
- 写图片到单元格建议直接用 dws sheet write-image(更简洁)
|
||||
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
|
||||
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入纯值
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
|
||||
- 清空整片区域请用 dws sheet range clear`,
|
||||
Example: ` # 写入文本
|
||||
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
|
||||
@@ -252,16 +252,16 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
CLIPath: "sheet range update",
|
||||
PrimaryCLIPath: "sheet range update",
|
||||
},
|
||||
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
|
||||
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "update_range"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
|
||||
UseWhen: []string{"需要写入少量单元格、公式或超链接,且能提供 sheetId 与精确 range 时"},
|
||||
AvoidWhen: []string{"大批量纯值写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
|
||||
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
|
||||
UseWhen: []string{"需要写入少量单元格、公式对象、超链接或富格式,且能提供 sheetId 与精确 range 时"},
|
||||
AvoidWhen: []string{"CSV 形式的大批量值或公式写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
|
||||
Examples: []string{"dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A1\" --values '[[{\"type\":\"text\",\"text\":\"张三\"}]]'"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
|
||||
+599
-373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。
|
||||
//
|
||||
// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的
|
||||
// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。
|
||||
// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。
|
||||
const budgetTestRange = "A1:Z1000"
|
||||
|
||||
func budgetTestRanges(n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||||
t.Fatalf("set --bg-color: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) {
|
||||
// 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。
|
||||
within, err := json.Marshal(budgetTestRanges(7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within))
|
||||
if err != nil {
|
||||
t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err)
|
||||
}
|
||||
if len(ops) != 7 {
|
||||
t.Fatalf("operations = %d, want 7", len(ops))
|
||||
}
|
||||
|
||||
// 第 8 个把累计推到 208000,越过 200000 上限。
|
||||
over, err := json.Marshal(budgetTestRanges(8))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over))
|
||||
if err == nil {
|
||||
t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝")
|
||||
}
|
||||
for _, want := range []string{"--ranges[7]", "208000", "200000"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) {
|
||||
writeBatch := func(t *testing.T, items []map[string]any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
item := func(i int) map[string]any {
|
||||
return map[string]any{
|
||||
"sheetId": fmt.Sprintf("Sheet%d", i+1),
|
||||
"range": budgetTestRange,
|
||||
"fontWeight": "bold",
|
||||
}
|
||||
}
|
||||
|
||||
// 累计预算:8 条 × 26000 = 208000 > 200000。
|
||||
overBudget := make([]map[string]any, 0, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
overBudget = append(overBudget, item(i))
|
||||
}
|
||||
_, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget))
|
||||
if err == nil {
|
||||
t.Fatal("--batch 累计 208000 格必须被拒绝")
|
||||
}
|
||||
for _, want := range []string{"第 8/8 条", "208000", "200000"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。
|
||||
tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1)
|
||||
for i := 0; i < maxBatchStyleRanges+1; i++ {
|
||||
tooMany = append(tooMany, map[string]any{
|
||||
"sheetId": "Sheet1", "range": "A1", "fontWeight": "bold",
|
||||
})
|
||||
}
|
||||
_, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany))
|
||||
if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") {
|
||||
t.Fatalf("error = %v, want --batch item cap", err)
|
||||
}
|
||||
|
||||
// 预算内的正常配置仍然照常展开。
|
||||
ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)}))
|
||||
if err != nil {
|
||||
t.Fatalf("2 条应在预算内: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations = %d, want 2", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) {
|
||||
var total int64
|
||||
if err := addBatchStyleCells(&total, 1000, 26); err != nil {
|
||||
t.Fatalf("first range: %v", err)
|
||||
}
|
||||
if total != 26000 {
|
||||
t.Fatalf("total = %d, want 26000", total)
|
||||
}
|
||||
// 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。
|
||||
var single int64
|
||||
if err := addBatchStyleCells(&single, 1000, 1000); err == nil {
|
||||
t.Fatal("1000x1000=1000000 必须超出累计预算")
|
||||
}
|
||||
}
|
||||
|
||||
// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致:
|
||||
// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。
|
||||
func TestStyleFlagNamesMatchBoundFlags(t *testing.T) {
|
||||
probe := &cobra.Command{Use: "probe"}
|
||||
bindStyleFlags(probe)
|
||||
|
||||
bound := map[string]bool{}
|
||||
probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true })
|
||||
|
||||
listed := map[string]bool{}
|
||||
for _, name := range styleFlagNames {
|
||||
if listed[name] {
|
||||
t.Fatalf("styleFlagNames 重复登记 %q", name)
|
||||
}
|
||||
listed[name] = true
|
||||
if !bound[name] {
|
||||
t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name)
|
||||
}
|
||||
}
|
||||
for name := range bound {
|
||||
if !listed[name] {
|
||||
t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略")
|
||||
}
|
||||
for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。
|
||||
func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) {
|
||||
tooMany := make([]string, 0, maxBatchStyleRanges+1)
|
||||
for i := 0; i <= maxBatchStyleRanges; i++ {
|
||||
tooMany = append(tooMany, `"Sheet1!A1"`)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ranges string
|
||||
style bool
|
||||
want string
|
||||
}{
|
||||
{name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"},
|
||||
{name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"},
|
||||
{
|
||||
name: "over-range-cap",
|
||||
ranges: "[" + strings.Join(tooMany, ",") + "]",
|
||||
style: true,
|
||||
want: "--ranges 最多 100 项",
|
||||
},
|
||||
{
|
||||
name: "missing-sheet-prefix",
|
||||
ranges: `["A1:B2"]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "blank-sheet-prefix",
|
||||
ranges: `[" !A1:B2"]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "blank-range-after-prefix",
|
||||
ranges: `["Sheet1! "]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "bad-range-address",
|
||||
ranges: `["Sheet1!zz"]`,
|
||||
style: true,
|
||||
want: "解析失败",
|
||||
},
|
||||
{
|
||||
name: "no-style-flag",
|
||||
ranges: `["Sheet1!A1:B2"]`,
|
||||
style: false,
|
||||
want: "至少需要指定一个样式参数",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
if tc.style {
|
||||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||||
t.Fatalf("set --bg-color: %v", err)
|
||||
}
|
||||
}
|
||||
_, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。
|
||||
//
|
||||
// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着
|
||||
// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是
|
||||
// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理,
|
||||
// 它此前只挡 == ""。三条路径都必须在发请求之前失败。
|
||||
func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchDir := t.TempDir()
|
||||
writeBatch := func(t *testing.T, name, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(batchDir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args func(t *testing.T) []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "set-style-ranges-blank-sheet",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(*testing.T) []string {
|
||||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"}
|
||||
},
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "set-style-batch-blank-sheet-id",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(t *testing.T) []string {
|
||||
p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`)
|
||||
return []string{"--node", "NODE_ID", "--batch", p}
|
||||
},
|
||||
want: "缺少 sheetId 或 range",
|
||||
},
|
||||
{
|
||||
name: "set-style-batch-blank-range",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(t *testing.T) []string {
|
||||
p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`)
|
||||
return []string{"--node", "NODE_ID", "--batch", p}
|
||||
},
|
||||
want: "缺少 sheetId 或 range",
|
||||
},
|
||||
{
|
||||
name: "batch-clear-ranges-blank-sheet",
|
||||
cmd: rangeBatchClearCoverageCommand,
|
||||
args: func(*testing.T) []string {
|
||||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`}
|
||||
},
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
cmd := tc.cmd()
|
||||
cmd.SetArgs(tc.args(t))
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾
|
||||
// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。
|
||||
func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "padded.json")
|
||||
if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
ops, err := buildBatchStyleOpsFromFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations = %d, want 1", len(ops))
|
||||
}
|
||||
op, _ := ops[0].(map[string]any)
|
||||
input, _ := op["input"].(map[string]any)
|
||||
if input["sheetId"] != " Sheet1 " {
|
||||
t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ")
|
||||
}
|
||||
if input["rangeAddress"] != " A1:B2 " {
|
||||
t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ")
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} {
|
||||
_, _, _ = parseA1Range(raw)
|
||||
}
|
||||
_ = fillStringMatrix(2, 2, "x")
|
||||
_ = fillIntMatrix(2, 2, 1)
|
||||
for _, spec := range []*styleSpec{
|
||||
{FontSize: 1, FontSizesJSON: `[[1]]`},
|
||||
{FontSize: -1},
|
||||
{FontSize: 12},
|
||||
{FontSizesJSON: `{`},
|
||||
{FontSizesJSON: `[[1,2]]`},
|
||||
{FontSizesJSON: `[[1]]`},
|
||||
for _, tc := range []struct {
|
||||
scalar int
|
||||
jsonStr string
|
||||
}{
|
||||
{1, `[[1]]`}, {-1, ""}, {12, ""}, {0, `{`}, {0, `[[1,2]]`}, {0, `[[1]]`}, {0, ""},
|
||||
} {
|
||||
_ = applyFontSize(spec, 1, 1, map[string]any{})
|
||||
if get, err := intGrid(tc.scalar, tc.jsonStr, "font-size", 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
_, _ = get(9, 9)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -32,7 +31,17 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
{"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil},
|
||||
{"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
_, _ = get(9, 9)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", `{`, `{}`, `{"nope":{"style":"solid"}}`, `{"top":{}}`,
|
||||
`{"top":{"style":"bogus"}}`, `{"top":{"style":"solid"}}`, `{"top":{"style":"solid","color":"#000"}}`} {
|
||||
_, _ = parseBorderStyles(raw)
|
||||
}
|
||||
for _, raw := range []string{"Sheet1!A1:B2", "A1:B2", "Sheet1!", "!A1"} {
|
||||
_, _, _ = splitSheetPrefixedRange(raw, 0)
|
||||
}
|
||||
_ = maxColLenStr([][]string{{}, {"a", "b"}})
|
||||
_ = maxColLen2D([][]int{{}, {1, 2}})
|
||||
@@ -46,8 +55,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
{styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31},
|
||||
{styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1},
|
||||
{styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1},
|
||||
{styleSpec{FontStyle: "bogus"}, 1, 1}, {styleSpec{FontStyle: "italic"}, 1, 1},
|
||||
{styleSpec{FontLine: "bogus"}, 1, 1}, {styleSpec{FontLine: "underline"}, 1, 1},
|
||||
{styleSpec{FontLine: "line-through"}, 1, 1}, {styleSpec{FontLine: "none"}, 1, 1},
|
||||
{styleSpec{FontFamily: "Arial"}, 1, 1},
|
||||
{styleSpec{BorderStylesJSON: `{"top":{"style":"solid"}}`}, 1, 1},
|
||||
{styleSpec{BorderStylesJSON: `{`}, 1, 1},
|
||||
{styleSpec{BgColor: "#FFF", FontSize: 12, HAlign: "center", VAlign: "top",
|
||||
FontColor: "#000", FontWeight: "bold"}, 2, 2},
|
||||
} {
|
||||
_ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&tc.spec, tc.rows, tc.cols)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,16 +62,22 @@ func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testin
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
|
||||
}
|
||||
results, _ := payload["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("JSON dry-run results = %#v", payload["results"])
|
||||
// batch-set-style 现在组装为一次 batch_update 原子提交,
|
||||
// dry-run 因此是单条标准记录,而不是本地循环产生的 results 数组。
|
||||
if payload["tool"] != "batch_update" {
|
||||
t.Fatalf("JSON dry-run tool = %#v, want batch_update", payload["tool"])
|
||||
}
|
||||
entry, _ := results[0].(map[string]any)
|
||||
if entry["dryRun"] != true || entry["tool"] != "update_range" {
|
||||
t.Fatalf("JSON dry-run result = %#v", entry)
|
||||
args, _ := payload["arguments"].(map[string]any)
|
||||
ops, _ := args["operations"].([]any)
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("JSON dry-run operations = %#v, want 1 item", args["operations"])
|
||||
}
|
||||
op, _ := ops[0].(map[string]any)
|
||||
if op["toolName"] != "set_cell_range" {
|
||||
t.Fatalf("JSON dry-run operation toolName = %#v", op["toolName"])
|
||||
}
|
||||
} else {
|
||||
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
|
||||
for _, want := range []string{"Tool:", "batch_update", "set_cell_range", "Arguments:"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
|
||||
}
|
||||
@@ -104,3 +110,104 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默
|
||||
// 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键
|
||||
// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用
|
||||
// parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。
|
||||
func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"near-miss-color-key", `{"top":{"style":"solid","colour":"#f00"}}`, `未知字段 "colour"`},
|
||||
{"unknown-key", `{"top":{"style":"solid","width":2}}`, `未知字段 "width"`},
|
||||
{"camel-case-alias-hinted", `{"top":{"style":"solid","Color":"#f00"}}`, `应为 "color"`},
|
||||
{"color-not-string", `{"top":{"style":"solid","color":123}}`, "color 必须是字符串,实际是 float64"},
|
||||
{"color-empty-string", `{"top":{"style":"solid","color":""}}`, "color 不能为空字符串"},
|
||||
{"style-not-string", `{"top":{"style":123}}`, "style 必须是字符串,实际是 float64"},
|
||||
{"style-empty-string", `{"top":{"style":""}}`, "缺少 style"},
|
||||
{"style-missing", `{"top":{"color":"#f00"}}`, "缺少 style"},
|
||||
{"style-null", `{"top":{"style":null}}`, "缺少 style"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseBorderStyles(tc.json)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "--border-styles-json.top") {
|
||||
t.Fatalf("err = %v, want the edge name in the message", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合法形态不能被误拒:只给 style、给 style+color、显式 null color(等同省略)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
json string
|
||||
wantColor any
|
||||
}{
|
||||
{"style-only", `{"top":{"style":"solid"}}`, nil},
|
||||
{"style-and-color", `{"top":{"style":"solid","color":"#FF0000"}}`, "#FF0000"},
|
||||
{"explicit-null-color", `{"top":{"style":"solid","color":null}}`, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := parseBorderStyles(tc.json)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBorderStyles(%s) = %v, want accepted", tc.json, err)
|
||||
}
|
||||
edge, _ := out["top"].(map[string]any)
|
||||
if edge["style"] != "solid" {
|
||||
t.Fatalf("style = %#v, want solid", edge["style"])
|
||||
}
|
||||
if edge["color"] != tc.wantColor {
|
||||
t.Fatalf("color = %#v, want %#v", edge["color"], tc.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 单区域与 batch 两条路径:非法边框配置必须在组装/下发之前失败。
|
||||
func TestBorderStyleRejectionHappensBeforeAnyRemoteCall(t *testing.T) {
|
||||
const badBorder = `{"top":{"style":"solid","colour":"#f00"}}`
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
fixture := `[{"sheetId":"Sheet1","range":"A1:B2","borderStylesJson":"{\"top\":{\"style\":\"solid\",\"colour\":\"#f00\"}}"}]`
|
||||
if err := os.WriteFile(batchPath, []byte(fixture), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"single-range", []string{"--node", "NODE_ID", "--sheet-id", "S", "--range", "A1:B2", "--border-styles-json", badBorder}},
|
||||
{"batch-ranges", []string{"--node", "NODE_ID", "--ranges", `["Sheet1!A1:B2"]`, "--border-styles-json", badBorder}},
|
||||
{"batch-file", []string{"--node", "NODE_ID", "--batch", batchPath}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
var output bytes.Buffer
|
||||
deps.Out.w = &output
|
||||
deps.Out.errW = &output
|
||||
|
||||
cmd := newRangeSetStyleCmd()
|
||||
if tc.name != "single-range" {
|
||||
cmd = newRangeBatchSetStyleCmd()
|
||||
}
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), `未知字段 "colour"`) {
|
||||
t.Fatalf("err = %v, want the unknown border field rejected", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T) {
|
||||
if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil {
|
||||
if _, err := buildStyleCells(&styleSpec{FontColorsJSON: "{"}, 1, 1); err == nil {
|
||||
t.Fatal("invalid font colors JSON returned nil")
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
|
||||
// become required; an existing path may not gain a new required flag,
|
||||
// - new commands and flags are allowed.
|
||||
//
|
||||
// The single exception to the type rule is an individually reviewed migration
|
||||
// listed in reviewedFlagTypeChanges, and only when nothing else about the flag
|
||||
// changed. See reviewed.go.
|
||||
//
|
||||
// Comparing the effective local + inherited set is intentional. It catches a
|
||||
// persistent flag whose scope is accidentally narrowed to its declaring
|
||||
// command, while allowing a local flag to move to an ancestor without breaking
|
||||
@@ -183,13 +187,20 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
|
||||
continue
|
||||
}
|
||||
if oldFlag.Type != newFlag.Type {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
// Resolve the exemption against the canonical path, not acceptedPath.
|
||||
// An aliased command reaches this function once per accepted spelling,
|
||||
// so keying on acceptedPath would let every alias spelling re-report a
|
||||
// reviewed migration.
|
||||
if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) ||
|
||||
flagContractOtherwiseChanged(oldFlag, newFlag) {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
@@ -310,6 +314,225 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// registerReviewedFixture puts a fixture command in the reviewed table for the
|
||||
// duration of one test, so the behaviour tests exercise the real lookup instead
|
||||
// of depending on whichever production entries happen to exist.
|
||||
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
|
||||
t.Helper()
|
||||
if _, exists := reviewedFlagTypeChanges[change]; exists {
|
||||
t.Fatalf("fixture %+v collides with a production entry", change)
|
||||
}
|
||||
reviewedFlagTypeChanges[change] = struct{}{}
|
||||
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAcceptsReviewedFlagTypeChange(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// Without an entry the migration is still a blocking change.
|
||||
if comparison := Compare(current, base, "base"); !hasChangeKind(comparison.Blocking, "flag_type_changed") {
|
||||
t.Fatalf("an unreviewed type change should block: %#v", comparison.Blocking)
|
||||
}
|
||||
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("a reviewed string->int migration should pass: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
// The reverse migration shares command and flag but not direction, so the
|
||||
// string->int entry must not admit it.
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("int->string must not be admitted by a string->int entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// Same flag and direction, different command.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws status", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an entry for another command must not admit this migration")
|
||||
}
|
||||
|
||||
// Same command and direction, different flag.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "elsewhere", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an entry for another flag must not admit this migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A reviewed migration is only accepted when the rest of the flag's contract
|
||||
// held still. Each case bundles one unrelated regression with the reviewed type
|
||||
// change and requires the type failure to reappear, which also anchors
|
||||
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
|
||||
// drifts out of sync fails here instead of silently widening the exemption.
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
base Flag
|
||||
next Flag
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "became required",
|
||||
base: Flag{Name: "query", Type: "string"},
|
||||
next: Flag{Name: "query", Type: "int", Required: true},
|
||||
kind: "flag_became_required",
|
||||
},
|
||||
{
|
||||
name: "lost shorthand",
|
||||
base: Flag{Name: "query", Type: "string", Shorthand: "q"},
|
||||
next: Flag{Name: "query", Type: "int"},
|
||||
kind: "flag_shorthand_changed",
|
||||
},
|
||||
{
|
||||
name: "changed no-opt",
|
||||
base: Flag{Name: "query", Type: "string", NoOpt: "all"},
|
||||
next: Flag{Name: "query", Type: "int", NoOpt: "1"},
|
||||
kind: "flag_no_opt_changed",
|
||||
},
|
||||
{
|
||||
name: "became hidden",
|
||||
base: Flag{Name: "query", Type: "string"},
|
||||
next: Flag{Name: "query", Type: "int", Hidden: true},
|
||||
kind: "flag_became_hidden",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"), testCommand("dws search", test.base))
|
||||
current := testSnapshot(testCommand("dws"), testCommand("dws search", test.next))
|
||||
comparison := Compare(current, base, "base")
|
||||
if !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
|
||||
}
|
||||
if !hasChangeKind(comparison.Blocking, "flag_type_changed") {
|
||||
t.Fatalf("a bundled regression must re-report the type change: %#v", comparison.Blocking)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The exemption is resolved against the canonical Command.Path, never the
|
||||
// alias-expanded accepted path. An aliased command enters compareEffectiveFlags
|
||||
// once per accepted spelling, so keying on the accepted path would let every
|
||||
// alias spelling re-report a reviewed migration.
|
||||
func TestCrossPlatformCoverageCompareResolvesReviewedFlagTypeChangeByCanonicalPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// The canonical path admits the migration for every accepted spelling.
|
||||
canonical := flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}
|
||||
registerReviewedFixture(t, canonical)
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("canonical path should admit the migration on every alias: %#v", comparison.Blocking)
|
||||
}
|
||||
delete(reviewedFlagTypeChanges, canonical)
|
||||
|
||||
// An entry spelled with the alias path matches nothing, because the lookup
|
||||
// never sees the accepted spelling.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws find", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an alias-spelled entry must not admit the migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A string -> int migration always changes the recorded default ("" to "0").
|
||||
// Defaults are not part of what this gate enforces, and folding them into
|
||||
// flagContractOtherwiseChanged would make every table entry dead on arrival.
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeToleratesDefaultDrift(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", Flag{Name: "query", Type: "string", Default: ""}),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", Flag{Name: "query", Type: "int", Default: "0"}),
|
||||
)
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("default drift must not defeat a reviewed migration: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
// reviewedEntryPattern matches one table entry as gofmt writes it on a single
|
||||
// line. It is used to read the sibling copy of the table out of the policy
|
||||
// helper, which cannot be imported: that directory is copied into a worktree at
|
||||
// a historical revision and built there, so it must not depend on this package.
|
||||
var reviewedEntryPattern = regexp.MustCompile(
|
||||
`\{CommandPath:\s*"([^"]*)",\s*Flag:\s*"([^"]*)",\s*From:\s*"([^"]*)",\s*To:\s*"([^"]*)"\}`)
|
||||
|
||||
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
|
||||
|
||||
// The two copies of the table must stay identical, otherwise a migration passes
|
||||
// one gate and is blocked by the other in the same CI job.
|
||||
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
|
||||
source, err := os.ReadFile(interfaceBaselineReviewedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s 失败: %v", interfaceBaselineReviewedPath, err)
|
||||
}
|
||||
|
||||
sibling := make(map[flagTypeChange]struct{})
|
||||
for _, match := range reviewedEntryPattern.FindAllStringSubmatch(string(source), -1) {
|
||||
sibling[flagTypeChange{CommandPath: match[1], Flag: match[2], From: match[3], To: match[4]}] = struct{}{}
|
||||
}
|
||||
|
||||
// Without this the whole guard degrades into comparing two empty sets the
|
||||
// moment the pattern stops matching, which is exactly how a duplicated
|
||||
// allowlist goes stale unnoticed.
|
||||
if len(sibling) == 0 {
|
||||
t.Fatalf("未能从 %s 提取到任何条目:正则或表的书写形态已漂移,这条守卫已失效", interfaceBaselineReviewedPath)
|
||||
}
|
||||
|
||||
for change := range reviewedFlagTypeChanges {
|
||||
if _, ok := sibling[change]; !ok {
|
||||
t.Errorf("%+v 只存在于 interfacesnapshot,缺在 %s", change, interfaceBaselineReviewedPath)
|
||||
}
|
||||
}
|
||||
for change := range sibling {
|
||||
if _, ok := reviewedFlagTypeChanges[change]; !ok {
|
||||
t.Errorf("%+v 只存在于 %s,缺在 interfacesnapshot", change, interfaceBaselineReviewedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
// flagTypeChange identifies one flag type migration exactly: which command,
|
||||
// which flag, and in which direction. Nothing is matched by wildcard — an entry
|
||||
// that differs in any of the four fields does not apply.
|
||||
//
|
||||
// CommandPath is the canonical Command.Path form, which always includes the root
|
||||
// command name ("dws minutes permission apply"). It is deliberately *not* the
|
||||
// alias-expanded accepted path: a command reachable through an alias enters
|
||||
// compareEffectiveFlags once per accepted spelling, and keying on the accepted
|
||||
// path would let every alias spelling bypass the exemption and re-report the
|
||||
// change.
|
||||
//
|
||||
// This table is duplicated in scripts/policy/interface-baseline/reviewed.go and
|
||||
// the two must stay identical; TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline
|
||||
// fails if they drift. The duplication is forced rather than chosen:
|
||||
// check-authoritative-interface-baselines.sh copies the whole
|
||||
// scripts/policy/interface-baseline directory into a worktree checked out at a
|
||||
// historical revision and builds it there, so that copy cannot import a package
|
||||
// this branch adds.
|
||||
type flagTypeChange struct {
|
||||
CommandPath string
|
||||
Flag string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
|
||||
// migrations this gate accepts. A flag type is part of the published CLI
|
||||
// contract, and a type swap cannot be proven safe from the type names alone —
|
||||
// whether old invocations still parse depends on the concrete value domain the
|
||||
// command enforces. So a migration is accepted only when this exact command,
|
||||
// flag, and old→new pair appear here. Adding an entry is a contract decision
|
||||
// and belongs in review, not in a feature change.
|
||||
//
|
||||
// Entries are direction-sensitive by construction: "string" → "int" is a
|
||||
// separate key from "int" → "string" and only the reviewed direction is
|
||||
// accepted. The reverse is not a candidate for this table — widening an int
|
||||
// flag back to string lets values the parser used to reject reach RunE, which
|
||||
// is a genuine loosening of validation rather than a type migration.
|
||||
//
|
||||
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
|
||||
// only when nothing else about the flag moved. See compareEffectiveFlags.
|
||||
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
|
||||
// "dws minutes permission apply --policy" moved from a String flag parsed
|
||||
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
|
||||
// same [2,4] domain check in RunE.
|
||||
//
|
||||
// This is admissible because the historical set of *successful* invocations
|
||||
// is a subset of the new one, not merely similar to it:
|
||||
//
|
||||
// - Old: the value had to parse as base-10 and land in [2,4]. That is
|
||||
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
|
||||
// ("+3", "03", "003", ...).
|
||||
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
|
||||
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
|
||||
// resolves to the same number under base 0, so no historical success
|
||||
// becomes a failure.
|
||||
// - Base 0 additionally accepts "0x3" and friends, which the old parser
|
||||
// rejected. That widens the accepted set; it cannot break a caller who
|
||||
// was already succeeding.
|
||||
// - Values outside [2,4] and non-numeric values still fail. Only the
|
||||
// failure *message and timing* move, from RunE to flag parsing.
|
||||
//
|
||||
// The flag's default value changes from "" to "0" as an unavoidable
|
||||
// consequence of the type. Neither gate compares defaults, and a default is
|
||||
// not reachable by a caller here because RunE requires the flag to be
|
||||
// explicitly set, so this is not a contract change.
|
||||
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
|
||||
}
|
||||
|
||||
// reviewedFlagTypeChange reports whether this exact command, flag and direction
|
||||
// is a reviewed migration. Callers must additionally establish that the rest of
|
||||
// the flag's contract is unchanged before skipping the failure.
|
||||
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
|
||||
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
|
||||
CommandPath: commandPath,
|
||||
Flag: flag,
|
||||
From: from,
|
||||
To: to,
|
||||
}]
|
||||
return reviewed
|
||||
}
|
||||
|
||||
// flagContractOtherwiseChanged reports whether anything this gate checks about a
|
||||
// flag, other than its type, differs between the two revisions. A reviewed type
|
||||
// migration is only accepted when this returns false, so the exemption cannot
|
||||
// carry an unrelated regression in behind it.
|
||||
//
|
||||
// Only the fields compareEffectiveFlags actually enforces are compared. Default
|
||||
// is deliberately excluded: a string → int migration always changes it ("" to
|
||||
// "0"), so including it would make every entry in the table dead. Deprecated is
|
||||
// excluded for the same reason it is not a blocking change on its own.
|
||||
func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
return true
|
||||
}
|
||||
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
|
||||
return true
|
||||
}
|
||||
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
|
||||
return true
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
+61
-23
@@ -32,9 +32,10 @@ import (
|
||||
// SetArgs so that Cobra's subsequent ExecuteC uses the corrected
|
||||
// values.
|
||||
//
|
||||
// If the target command cannot be resolved (e.g. the user typed a
|
||||
// non-existent command), PreParse is skipped silently and Cobra will
|
||||
// handle the error.
|
||||
// Explicit +shortcut tokens and annotated group subcommands are validated
|
||||
// before flag parsing so an unknown command cannot be misreported as an
|
||||
// unknown flag on its nearest parent. Other unresolved paths remain Cobra's
|
||||
// responsibility.
|
||||
func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
_, err := RunPreParseArgs(root, engine, os.Args[1:])
|
||||
return err
|
||||
@@ -45,13 +46,17 @@ func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
// exact same command traversal, FlagInfo extraction, handler chain, and
|
||||
// root.SetArgs delivery path.
|
||||
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
|
||||
if engine == nil || !engine.HasHandlers(PreParse) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(rawArgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalizedArgs, commandCorrection, fallbackErr := normalizeCommandPathFallback(root, engine, rawArgs)
|
||||
if fallbackErr != nil {
|
||||
ctx := &Context{Args: append([]string{}, rawArgs...)}
|
||||
if presentationErr := primeEarlyErrorPresentation(root, root, rawArgs); presentationErr != nil {
|
||||
slog.Debug("pipeline command-fallback presentation flags", "error", presentationErr)
|
||||
}
|
||||
return ctx, fallbackErr
|
||||
}
|
||||
|
||||
// Cobra's Traverse does not merge root persistent flags before deciding
|
||||
// whether a leading flag consumes the next token. For example, it can treat
|
||||
@@ -59,37 +64,52 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
|
||||
// the unrelated root command `event list`. Remove only known root-persistent
|
||||
// flags from the traversal copy; the original argv remains intact for the
|
||||
// handlers and Cobra's real parse.
|
||||
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
|
||||
target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateUnresolvedCommand(target, remaining); err != nil {
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, normalizedArgs...),
|
||||
Command: target.CommandPath(),
|
||||
}
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline command-resolution presentation flags", "error", presentationErr)
|
||||
}
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Build FlagInfo from the target command's registered flags.
|
||||
flagInfos := FlagInfoFromCommand(target)
|
||||
if len(flagInfos) == 0 {
|
||||
if (engine == nil || !engine.HasHandlers(PreParse) || len(flagInfos) == 0) && commandCorrection == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, rawArgs...),
|
||||
Args: append([]string{}, normalizedArgs...),
|
||||
// target.CommandPath() still carries the "dws" prefix; PreParse
|
||||
// handlers that key off a command (e.g. the semantic-alias table)
|
||||
// normalize it themselves, so the runtime key matches the build key.
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: flagInfos,
|
||||
}
|
||||
if commandCorrection != nil {
|
||||
ctx.Corrections = append(ctx.Corrections, *commandCorrection)
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
if engine != nil && engine.HasHandlers(PreParse) && len(flagInfos) > 0 {
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return ctx, err
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Only set corrected args if PreParse actually changed something.
|
||||
@@ -109,25 +129,43 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
|
||||
}
|
||||
|
||||
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
args, _ := argsForCommandTraversalWithPositions(root, rawArgs)
|
||||
return args
|
||||
}
|
||||
|
||||
func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) {
|
||||
if root == nil || len(rawArgs) == 0 {
|
||||
return rawArgs
|
||||
positions := make([]int, len(rawArgs))
|
||||
for index := range positions {
|
||||
positions[index] = index
|
||||
}
|
||||
return rawArgs, positions
|
||||
}
|
||||
flags := root.PersistentFlags()
|
||||
if flags == nil || !flags.HasFlags() {
|
||||
return rawArgs
|
||||
positions := make([]int, len(rawArgs))
|
||||
for index := range positions {
|
||||
positions[index] = index
|
||||
}
|
||||
return rawArgs, positions
|
||||
}
|
||||
|
||||
matcher := newFlagTokenMatcher(flags)
|
||||
out := make([]string, 0, len(rawArgs))
|
||||
positions := make([]int, 0, len(rawArgs))
|
||||
for index := 0; index < len(rawArgs); index++ {
|
||||
argument := rawArgs[index]
|
||||
if argument == "--" {
|
||||
out = append(out, rawArgs[index:]...)
|
||||
for position := index; position < len(rawArgs); position++ {
|
||||
positions = append(positions, position)
|
||||
}
|
||||
break
|
||||
}
|
||||
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
|
||||
if !matched {
|
||||
out = append(out, argument)
|
||||
positions = append(positions, index)
|
||||
continue
|
||||
}
|
||||
if index+1 < len(rawArgs) {
|
||||
@@ -140,7 +178,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, positions
|
||||
}
|
||||
|
||||
// separatedBoolValue recognises model-friendly `--flag false` and exact
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
commandPathFallbackModeRewrite = "rewrite"
|
||||
commandPathFallbackModeAmbiguous = "ambiguous"
|
||||
)
|
||||
|
||||
// normalizeCommandPathFallback applies only an exact reviewed path match. It
|
||||
// runs before Cobra resolves a leaf, then the canonical argv continues through
|
||||
// the ordinary parameter alias, validation, safety, and execution pipeline.
|
||||
func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, *Correction, error) {
|
||||
if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs)
|
||||
entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs)
|
||||
if !ok {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
// Runtime extensions are mounted on the root before PreParse runs. An exact
|
||||
// executable command (or exact Cobra alias) is user/runtime authority and
|
||||
// must win over the reviewed recovery dictionary. Hint-only compatibility
|
||||
// nodes are not executable business commands, so fallback may supersede
|
||||
// them as it does for the distribution-owned declaration tree.
|
||||
if exactRunnableCommandPath(root, traversalArgs[:tokenCount]) {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case commandPathFallbackModeRewrite:
|
||||
if strings.TrimSpace(entry.To) == "" {
|
||||
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has no rewrite target", entry.From))
|
||||
}
|
||||
rewritten := rewriteCommandPathTokens(rawArgs, positions[:tokenCount], strings.Fields(entry.To))
|
||||
correction := &Correction{
|
||||
Handler: "command-path-fallback",
|
||||
Phase: PreParse,
|
||||
Field: "command_path",
|
||||
Original: entry.From,
|
||||
Corrected: entry.To,
|
||||
Kind: "reviewed-fallback",
|
||||
}
|
||||
return rewritten, correction, nil
|
||||
case commandPathFallbackModeAmbiguous:
|
||||
return rawArgs, nil, ambiguousCommandPathFallbackError(root, entry)
|
||||
default:
|
||||
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has invalid mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
func exactRunnableCommandPath(root *cobra.Command, path []string) bool {
|
||||
if root == nil || len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
current := root
|
||||
if path[0] == root.Name() {
|
||||
path = path[1:]
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range path {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == token || containsExactCommandAlias(child.Aliases, token) {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current.Runnable() && !cmdutil.IsHintOnlyCommand(current)
|
||||
}
|
||||
|
||||
func containsExactCommandAlias(aliases []string, token string) bool {
|
||||
for _, alias := range aliases {
|
||||
if alias == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func longestCommandPathFallback(engine *Engine, traversalArgs []string) (CommandPathFallback, int, bool) {
|
||||
var selected CommandPathFallback
|
||||
selectedCount := 0
|
||||
parts := make([]string, 0, len(traversalArgs))
|
||||
for index, argument := range traversalArgs {
|
||||
argument = strings.TrimSpace(argument)
|
||||
if argument == "" || argument == "--" || strings.HasPrefix(argument, "-") {
|
||||
break
|
||||
}
|
||||
parts = append(parts, argument)
|
||||
if candidate, ok := engine.lookupCommandPathFallback(strings.Join(parts, " ")); ok {
|
||||
selected = candidate
|
||||
selectedCount = index + 1
|
||||
}
|
||||
}
|
||||
return selected, selectedCount, selectedCount > 0
|
||||
}
|
||||
|
||||
func rewriteCommandPathTokens(rawArgs []string, commandPositions []int, canonicalTokens []string) []string {
|
||||
if len(commandPositions) == 0 || len(canonicalTokens) == 0 {
|
||||
return append([]string(nil), rawArgs...)
|
||||
}
|
||||
remove := make(map[int]bool, len(commandPositions))
|
||||
for _, position := range commandPositions {
|
||||
remove[position] = true
|
||||
}
|
||||
insertAt := commandPositions[0]
|
||||
out := make([]string, 0, len(rawArgs)-len(commandPositions)+len(canonicalTokens))
|
||||
for index, argument := range rawArgs {
|
||||
if index == insertAt {
|
||||
out = append(out, canonicalTokens...)
|
||||
}
|
||||
if remove[index] {
|
||||
continue
|
||||
}
|
||||
out = append(out, argument)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ambiguousCommandPathFallbackError(root *cobra.Command, entry CommandPathFallback) error {
|
||||
rootName := "dws"
|
||||
if root != nil && strings.TrimSpace(root.Name()) != "" {
|
||||
rootName = root.Name()
|
||||
}
|
||||
paths := make([]string, 0, len(entry.Candidates))
|
||||
actions := make([]string, 0, len(entry.Candidates))
|
||||
for _, candidate := range entry.Candidates {
|
||||
path := strings.TrimSpace(rootName + " " + candidate)
|
||||
paths = append(paths, fmt.Sprintf("%q", path))
|
||||
actions = append(actions, fmt.Sprintf("Run '%s --help' to inspect this candidate", path))
|
||||
}
|
||||
hint := "Inspect the reviewed candidates and choose by the original user intent: " + strings.Join(paths, ", ")
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("command path %q is ambiguous", strings.TrimSpace(rootName+" "+entry.From)),
|
||||
apperrors.WithReason("ambiguous_command_fallback"),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
raw := []string{"--format", "json", "chat", "+bad", "--query", "project"}
|
||||
ctx, err := RunPreParseArgs(root, engine, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := []string{"--format", "json", "chat", "+good", "--query", "project"}
|
||||
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) || ctx.Command != "dws chat +good" {
|
||||
t.Fatalf("context = %#v, want args=%v command=dws chat +good", ctx, wantArgs)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Handler != "command-path-fallback" ||
|
||||
ctx.Corrections[0].Original != "chat +bad" || ctx.Corrections[0].Corrected != "chat +good" {
|
||||
t.Fatalf("corrections = %#v", ctx.Corrections)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat +good:project" {
|
||||
t.Fatalf("executed = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat group search": {From: "chat group search", Mode: "rewrite", To: "chat search"},
|
||||
})
|
||||
raw := []string{"chat", "--format", "json", "group", "search", "--query", "project"}
|
||||
ctx, err := RunPreParseArgs(root, engine, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := []string{"chat", "search", "--format", "json", "--query", "project"}
|
||||
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("rewritten args = %#v, want %v", ctx, wantArgs)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat search:project" {
|
||||
t.Fatalf("executed = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackDefersToExactRunnableCommandAndAlias(t *testing.T) {
|
||||
if exactRunnableCommandPath(nil, []string{"dws"}) {
|
||||
t.Fatal("nil root unexpectedly resolved an executable path")
|
||||
}
|
||||
emptyRoot := &cobra.Command{Use: "dws"}
|
||||
if exactRunnableCommandPath(emptyRoot, []string{"dws"}) {
|
||||
t.Fatal("root-only path unexpectedly resolved as an executable leaf")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "primary", path: "+members"},
|
||||
{name: "alias", path: "+runtime-members"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executed := ""
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
members := &cobra.Command{
|
||||
Use: "+members",
|
||||
Aliases: []string{"+runtime-members"},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
sentinel, _ := cmd.Flags().GetString("sentinel")
|
||||
executed = cmd.CommandPath() + ":" + sentinel
|
||||
return nil
|
||||
},
|
||||
}
|
||||
members.Flags().String("sentinel", "", "")
|
||||
chat.AddCommand(members)
|
||||
root.AddCommand(chat)
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat " + test.path: {From: "chat " + test.path, Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
args := []string{"chat", test.path, "--sentinel", "fixture"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx != nil {
|
||||
t.Fatalf("runtime command unexpectedly entered correction pipeline: %#v", ctx)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if executed != "dws chat +members:fixture" {
|
||||
t.Fatalf("runtime command execution = %q", executed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackMaySupersedeHintOnlyCommand(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
chat, _, err := root.Find([]string{"chat"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat.AddCommand(cmdutil.HintSubCmd("+bad", "use the reviewed recovery"))
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
args := []string{"chat", "+bad", "--query", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +good" || len(ctx.Corrections) != 1 {
|
||||
t.Fatalf("hint-only fallback context = %#v", ctx)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat +good:project" {
|
||||
t.Fatalf("hint-only fallback execution = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRejectsAmbiguousCommandFallbackWithoutDispatch(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +choose": {
|
||||
From: "chat +choose",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +good", "chat +other"},
|
||||
},
|
||||
})
|
||||
ctx, err := RunPreParseArgs(root, engine, []string{"--format", "json", "chat", "+choose", "--query", "project"})
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous fallback returned nil context")
|
||||
}
|
||||
structured := requireCommandResolutionError(t, err, "ambiguous_command_fallback")
|
||||
if len(structured.Actions) != 2 || !strings.Contains(structured.Hint, "dws chat +good") || !strings.Contains(structured.Hint, "dws chat +other") {
|
||||
t.Fatalf("ambiguous recovery = %#v", structured)
|
||||
}
|
||||
if *executed != "" {
|
||||
t.Fatalf("ambiguous fallback dispatched %q", *executed)
|
||||
}
|
||||
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
|
||||
t.Fatalf("format = %q, %v; want primed json", format, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackLeavesUnknownAndCanonicalFlagErrorsDistinct(t *testing.T) {
|
||||
root, _ := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
_, err := RunPreParseArgs(root, engine, []string{"chat", "+missing", "--query", "project"})
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
|
||||
validRoot, _ := commandFallbackPipelineRoot()
|
||||
validRaw := []string{"chat", "+good", "--not-a-flag", "value"}
|
||||
if ctx, err := RunPreParseArgs(validRoot, engine, validRaw); ctx != nil || err != nil {
|
||||
t.Fatalf("valid command preparse = %#v, %v", ctx, err)
|
||||
}
|
||||
validRoot.SetArgs(validRaw)
|
||||
_, executeErr := validRoot.ExecuteC()
|
||||
if executeErr == nil || !strings.Contains(executeErr.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", executeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackCannotBypassCanonicalConfirmation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDispatch int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "confirmation missing", args: []string{"chat", "+danger"}, wantErr: true},
|
||||
{name: "confirmed", args: []string{"chat", "+danger", "--yes"}, wantDispatch: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dispatches := 0
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
write := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
confirmed, _ := cmd.Flags().GetBool("yes")
|
||||
if !confirmed {
|
||||
return apperrors.NewValidation("confirmation required", apperrors.WithReason("confirmation_required"))
|
||||
}
|
||||
dispatches++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
write.Flags().Bool("yes", false, "")
|
||||
chat.AddCommand(write)
|
||||
root.AddCommand(chat)
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +danger": {From: "chat +danger", Mode: "rewrite", To: "chat +write"},
|
||||
})
|
||||
if _, err := RunPreParseArgs(root, engine, test.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := root.ExecuteC()
|
||||
if (err != nil) != test.wantErr || dispatches != test.wantDispatch {
|
||||
t.Fatalf("execute error=%v dispatches=%d, want error=%v dispatches=%d", err, dispatches, test.wantErr, test.wantDispatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackRejectsInvalidGeneratedMode(t *testing.T) {
|
||||
root, _ := commandFallbackPipelineRoot()
|
||||
for _, entry := range []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: "rewrite"},
|
||||
{From: "chat +bad", Mode: "invalid", To: "chat +good"},
|
||||
} {
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{"chat +bad": entry})
|
||||
_, err := RunPreParseArgs(root, engine, []string{"chat", "+bad"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryInternal {
|
||||
t.Fatalf("invalid entry error = %T %v", err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsKeepsCommandErrorWhenPresentationPrimingFails(t *testing.T) {
|
||||
fallbackRoot, _ := commandFallbackPipelineRoot()
|
||||
fallbackEngine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +choose": {
|
||||
From: "chat +choose",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +good", "chat +other"},
|
||||
},
|
||||
})
|
||||
_, err := RunPreParseArgs(fallbackRoot, fallbackEngine, []string{"chat", "+choose", "--format"})
|
||||
requireCommandResolutionError(t, err, "ambiguous_command_fallback")
|
||||
|
||||
resolutionRoot, _ := commandFallbackPipelineRoot()
|
||||
_, err = RunPreParseArgs(resolutionRoot, nil, []string{"chat", "+missing", "--format"})
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
|
||||
handlerRoot, _ := commandFallbackPipelineRoot()
|
||||
handlerEngine := NewEngine()
|
||||
handlerEngine.Register(newStub("fail-before-presentation", PreParse, func(*Context) error {
|
||||
return errors.New("handler failure")
|
||||
}))
|
||||
ctx, err := RunPreParseArgs(handlerRoot, handlerEngine, []string{"chat", "+good", "--query", "project", "--format"})
|
||||
if ctx == nil || err == nil || !strings.Contains(err.Error(), "handler failure") {
|
||||
t.Fatalf("handler error after presentation failure = context %#v, error %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackTraversalRemainingBranches(t *testing.T) {
|
||||
args, positions := argsForCommandTraversalWithPositions(nil, []string{"chat", "+good"})
|
||||
if !reflect.DeepEqual(args, []string{"chat", "+good"}) || !reflect.DeepEqual(positions, []int{0, 1}) {
|
||||
t.Fatalf("nil-root traversal = args %v, positions %v", args, positions)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
raw := []string{"chat", "+good", "--", "--format", "json"}
|
||||
args, positions = argsForCommandTraversalWithPositions(root, raw)
|
||||
if !reflect.DeepEqual(args, raw) || !reflect.DeepEqual(positions, []int{0, 1, 2, 3, 4}) {
|
||||
t.Fatalf("terminator traversal = args %v, positions %v", args, positions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewriteCommandPathTokensLeavesArgsWhenRewriteIsEmpty(t *testing.T) {
|
||||
raw := []string{"chat", "+bad", "--query", "project"}
|
||||
for name, test := range map[string]struct {
|
||||
positions []int
|
||||
tokens []string
|
||||
}{
|
||||
"no command positions": {tokens: []string{"chat", "+good"}},
|
||||
"no canonical tokens": {positions: []int{0, 1}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := rewriteCommandPathTokens(raw, test.positions, test.tokens)
|
||||
if !reflect.DeepEqual(got, raw) {
|
||||
t.Fatalf("rewriteCommandPathTokens() = %v, want %v", got, raw)
|
||||
}
|
||||
got[0] = "mutated"
|
||||
if raw[0] != "chat" {
|
||||
t.Fatal("empty rewrite returned aliased argv")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackPipelineEngine(entries map[string]CommandPathFallback) *Engine {
|
||||
engine := NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (CommandPathFallback, bool) {
|
||||
entry, ok := entries[path]
|
||||
return entry, ok
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
func commandFallbackPipelineRoot() (*cobra.Command, *string) {
|
||||
executed := new(string)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{
|
||||
Use: "+good",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
*executed = cmd.CommandPath() + ":" + query
|
||||
return nil
|
||||
},
|
||||
}
|
||||
good.Flags().String("query", "", "")
|
||||
good.Flags().String("keyword", "", "")
|
||||
other := &cobra.Command{Use: "+other", Run: func(cmd *cobra.Command, _ []string) { *executed = cmd.CommandPath() }}
|
||||
search := &cobra.Command{
|
||||
Use: "search",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
*executed = cmd.CommandPath() + ":" + query
|
||||
return nil
|
||||
},
|
||||
}
|
||||
search.Flags().String("query", "", "")
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search"))
|
||||
chat.AddCommand(good, other, search, group)
|
||||
root.AddCommand(chat)
|
||||
return root, executed
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const maxCommandSuggestions = 3
|
||||
|
||||
// validateUnresolvedCommand gives command identity precedence over flag
|
||||
// parsing. Cobra otherwise falls back to the nearest parent when a child is
|
||||
// unknown, so a later flag can incorrectly turn an invented command into an
|
||||
// "unknown flag" error on that parent.
|
||||
//
|
||||
// Only two unambiguous command containers are handled here:
|
||||
// - a top-level service followed by an explicit +shortcut token; and
|
||||
// - a command explicitly annotated as a group container.
|
||||
//
|
||||
// Other unresolved positionals remain Cobra's responsibility so commands that
|
||||
// legitimately accept positional arguments are not reclassified.
|
||||
func validateUnresolvedCommand(target *cobra.Command, remaining []string) error {
|
||||
if target == nil || len(remaining) == 0 {
|
||||
return nil
|
||||
}
|
||||
candidate := strings.TrimSpace(remaining[0])
|
||||
if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExplicitShortcutCandidate(target, candidate) {
|
||||
return unknownShortcutError(target, candidate)
|
||||
}
|
||||
if cmdutil.IsGroup(target) {
|
||||
return unknownSubcommandError(target, candidate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool {
|
||||
if target == nil || !strings.HasPrefix(candidate, "+") {
|
||||
return false
|
||||
}
|
||||
root := target.Root()
|
||||
if root == nil || target == root || target.Parent() != root {
|
||||
return false
|
||||
}
|
||||
for _, child := range target.Commands() {
|
||||
if strings.HasPrefix(child.Name(), "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unknownShortcutError(parent *cobra.Command, candidate string) error {
|
||||
action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()),
|
||||
apperrors.WithReason("unknown_shortcut"),
|
||||
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
|
||||
func unknownSubcommandError(parent *cobra.Command, candidate string) error {
|
||||
action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath())
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()),
|
||||
apperrors.WithReason("unknown_subcommand"),
|
||||
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
|
||||
func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string {
|
||||
suggestions := commandSuggestions(parent, candidate)
|
||||
if len(suggestions) > maxCommandSuggestions {
|
||||
suggestions = suggestions[:maxCommandSuggestions]
|
||||
}
|
||||
paths := make([]string, 0, len(suggestions))
|
||||
for _, suggestion := range suggestions {
|
||||
paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion))
|
||||
}
|
||||
switch len(paths) {
|
||||
case 0:
|
||||
return fallback
|
||||
case 1:
|
||||
return "Did you mean " + paths[0] + "?"
|
||||
default:
|
||||
return "Did you mean one of: " + strings.Join(paths, ", ") + "?"
|
||||
}
|
||||
}
|
||||
|
||||
// commandSuggestions mirrors Cobra's suggestion rules without mutating
|
||||
// SuggestionsMinimumDistance. Cobra only installs its default distance inside
|
||||
// the final error renderer; this guard runs earlier and must remain safe for
|
||||
// command trees shared by concurrent tests and callers.
|
||||
func commandSuggestions(parent *cobra.Command, candidate string) []string {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
minimumDistance := parent.SuggestionsMinimumDistance
|
||||
if minimumDistance <= 0 {
|
||||
minimumDistance = 2
|
||||
}
|
||||
lowerCandidate := strings.ToLower(candidate)
|
||||
shortcutOnly := strings.HasPrefix(candidate, "+")
|
||||
seen := make(map[string]bool)
|
||||
var suggestions []string
|
||||
for _, child := range parent.Commands() {
|
||||
if !child.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
name := child.Name()
|
||||
if shortcutOnly && !strings.HasPrefix(name, "+") {
|
||||
continue
|
||||
}
|
||||
matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance ||
|
||||
strings.HasPrefix(strings.ToLower(name), lowerCandidate)
|
||||
if !matched {
|
||||
for _, explicit := range child.SuggestFor {
|
||||
if strings.EqualFold(candidate, explicit) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched && !seen[name] {
|
||||
seen[name] = true
|
||||
suggestions = append(suggestions, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(suggestions)
|
||||
return suggestions
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
root.AddCommand(chat)
|
||||
|
||||
err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"})
|
||||
structured := requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` {
|
||||
t.Fatalf("Message = %q", structured.Message)
|
||||
}
|
||||
if structured.Hint != `Did you mean "dws chat +chat-messages"?` {
|
||||
t.Fatalf("Hint = %q", structured.Hint)
|
||||
}
|
||||
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" {
|
||||
t.Fatalf("Actions = %#v", structured.Actions)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 {
|
||||
t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
dev := &cobra.Command{Use: "dev"}
|
||||
app := &cobra.Command{Use: "app"}
|
||||
cmdutil.MarkGroup(app)
|
||||
app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
|
||||
dev.AddCommand(app)
|
||||
root.AddCommand(dev)
|
||||
|
||||
err := validateUnresolvedCommand(app, []string{"search", "--keyword", "x"})
|
||||
structured := requireCommandResolutionError(t, err, "unknown_subcommand")
|
||||
if structured.Message != `unknown subcommand "search" for "dws dev app"` {
|
||||
t.Fatalf("Message = %q", structured.Message)
|
||||
}
|
||||
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" {
|
||||
t.Fatalf("Actions = %#v", structured.Actions)
|
||||
}
|
||||
|
||||
positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(positional)
|
||||
for name, test := range map[string]struct {
|
||||
target *cobra.Command
|
||||
remaining []string
|
||||
}{
|
||||
"legitimate positional": {target: positional, remaining: []string{"+chat-messages"}},
|
||||
"flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}},
|
||||
"dash terminator": {target: app, remaining: []string{"--", "search"}},
|
||||
"nil target": {target: nil, remaining: []string{"search"}},
|
||||
"empty remaining": {target: app},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateUnresolvedCommand(test.target, test.remaining); err != nil {
|
||||
t.Fatalf("validateUnresolvedCommand() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat)
|
||||
|
||||
ctx, err := RunPreParseArgs(root, nil, []string{
|
||||
"--format", "json", "chat", "+chat-mesages", "--keyword", "x",
|
||||
})
|
||||
if ctx == nil || ctx.Command != "dws chat" {
|
||||
t.Fatalf("Context = %#v", ctx)
|
||||
}
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
|
||||
t.Fatalf("format = %q, %v; want primed json presentation", format, getErr)
|
||||
}
|
||||
|
||||
validRoot := &cobra.Command{Use: "dws"}
|
||||
validChat := &cobra.Command{Use: "chat"}
|
||||
validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
|
||||
validRoot.AddCommand(validChat)
|
||||
if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil {
|
||||
t.Fatalf("valid shortcut = %#v, %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100}
|
||||
root.AddCommand(group)
|
||||
for _, name := range []string{"alpha", "beta", "delta", "gamma"} {
|
||||
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
|
||||
}
|
||||
hint := commandSuggestionHint(group, "missing", "fallback")
|
||||
if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") {
|
||||
t.Fatalf("bounded hint = %q", hint)
|
||||
}
|
||||
|
||||
empty := &cobra.Command{Use: "empty"}
|
||||
root.AddCommand(empty)
|
||||
if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" {
|
||||
t.Fatalf("fallback hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPaths(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
service := &cobra.Command{Use: "demo"}
|
||||
group := &cobra.Command{Use: "nested"}
|
||||
service.AddCommand(group)
|
||||
root.AddCommand(service)
|
||||
|
||||
if isExplicitShortcutCandidate(root, "+missing") {
|
||||
t.Fatal("root command was treated as a shortcut service")
|
||||
}
|
||||
if isExplicitShortcutCandidate(group, "+missing") {
|
||||
t.Fatal("nested command was treated as a top-level shortcut service")
|
||||
}
|
||||
if got := commandSuggestions(nil, "missing"); got != nil {
|
||||
t.Fatalf("commandSuggestions(nil) = %#v", got)
|
||||
}
|
||||
|
||||
service.SuggestionsMinimumDistance = 1
|
||||
service.AddCommand(&cobra.Command{
|
||||
Use: "canonical",
|
||||
SuggestFor: []string{"invented"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
})
|
||||
if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" {
|
||||
t.Fatalf("explicit SuggestFor suggestions = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error {
|
||||
t.Helper()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v, want *errors.Error", err, err)
|
||||
}
|
||||
if structured.Category != apperrors.CategoryValidation || structured.Reason != reason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
return structured
|
||||
}
|
||||
@@ -21,9 +21,23 @@ import "fmt"
|
||||
// for reads after all handlers have been registered; registration
|
||||
// itself is not concurrent-safe and should be done at startup.
|
||||
type Engine struct {
|
||||
handlers map[Phase][]Handler
|
||||
handlers map[Phase][]Handler
|
||||
commandPathFallbackLookup CommandPathFallbackLookup
|
||||
}
|
||||
|
||||
// CommandPathFallback is the pipeline-local projection of one generated
|
||||
// recovery record. The app injects the cli lookup so pipeline stays independent
|
||||
// from the authored/generator package and can be tested with small fixtures.
|
||||
type CommandPathFallback struct {
|
||||
From string
|
||||
Mode string
|
||||
To string
|
||||
Candidates []string
|
||||
}
|
||||
|
||||
// CommandPathFallbackLookup resolves an exact normalized path.
|
||||
type CommandPathFallbackLookup func(path string) (CommandPathFallback, bool)
|
||||
|
||||
// HandlerError preserves the pipeline location of a handler failure for logs
|
||||
// and diagnostics while keeping the underlying domain error available to
|
||||
// user-facing adapters through Unwrap.
|
||||
@@ -62,6 +76,24 @@ func (e *Engine) RegisterAll(handlers ...Handler) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetCommandPathFallbackLookup installs the build-time generated command-path
|
||||
// recovery lookup. It must be configured before the engine is used.
|
||||
func (e *Engine) SetCommandPathFallbackLookup(lookup CommandPathFallbackLookup) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.commandPathFallbackLookup = lookup
|
||||
}
|
||||
|
||||
func (e *Engine) lookupCommandPathFallback(path string) (CommandPathFallback, bool) {
|
||||
if e == nil || e.commandPathFallbackLookup == nil {
|
||||
return CommandPathFallback{}, false
|
||||
}
|
||||
entry, ok := e.commandPathFallbackLookup(path)
|
||||
entry.Candidates = append([]string(nil), entry.Candidates...)
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
// Handlers returns the registered handlers for a given phase, in
|
||||
// registration order. The returned slice must not be modified.
|
||||
func (e *Engine) Handlers(phase Phase) []Handler {
|
||||
|
||||
@@ -15,6 +15,7 @@ package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -62,6 +63,22 @@ func TestNewEngine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackLookupHandlesNilEngine(t *testing.T) {
|
||||
var engine *Engine
|
||||
engine.SetCommandPathFallbackLookup(func(string) (CommandPathFallback, bool) {
|
||||
t.Fatal("nil engine installed or called fallback lookup")
|
||||
return CommandPathFallback{}, false
|
||||
})
|
||||
if entry, ok := engine.lookupCommandPathFallback("chat +bad"); ok || !reflect.DeepEqual(entry, CommandPathFallback{}) {
|
||||
t.Fatalf("nil engine lookup = %#v, %v", entry, ok)
|
||||
}
|
||||
|
||||
engine = NewEngine()
|
||||
if entry, ok := engine.lookupCommandPathFallback("chat +bad"); ok || !reflect.DeepEqual(entry, CommandPathFallback{}) {
|
||||
t.Fatalf("unset engine lookup = %#v, %v", entry, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndHandlerCount(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.Register(newStub("a", PreParse, nil))
|
||||
|
||||
@@ -59,6 +59,51 @@ func parseJSONObject(flag, s string) (map[string]any, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// resolveNamedList finds a list in a response envelope without conflating a
|
||||
// missing/malformed collection with a legitimate empty array. Some AITable MCP
|
||||
// versions use a direct collection key while others wrap it one level below
|
||||
// data/result, so all reviewed shapes are accepted but at least one explicit
|
||||
// JSON array must be present.
|
||||
func resolveNamedList(operation string, data map[string]any, outerKeys, innerKeys []string) ([]any, error) {
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("%s response is nil and does not establish a list result", operation)
|
||||
}
|
||||
invalid := make([]string, 0)
|
||||
for _, key := range outerKeys {
|
||||
value, exists := data[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if list, ok := value.([]any); ok {
|
||||
return list, nil
|
||||
}
|
||||
envelope, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
invalid = append(invalid, fmt.Sprintf("%s=%T", key, value))
|
||||
continue
|
||||
}
|
||||
foundInner := false
|
||||
for _, innerKey := range innerKeys {
|
||||
innerValue, innerExists := envelope[innerKey]
|
||||
if !innerExists {
|
||||
continue
|
||||
}
|
||||
foundInner = true
|
||||
if list, ok := innerValue.([]any); ok {
|
||||
return list, nil
|
||||
}
|
||||
invalid = append(invalid, fmt.Sprintf("%s.%s=%T", key, innerKey, innerValue))
|
||||
}
|
||||
if !foundInner {
|
||||
invalid = append(invalid, fmt.Sprintf("%s has no recognized collection", key))
|
||||
}
|
||||
}
|
||||
if len(invalid) > 0 {
|
||||
return nil, fmt.Errorf("%s response has no valid list collection (%v)", operation, invalid)
|
||||
}
|
||||
return nil, fmt.Errorf("%s response is missing its list collection", operation)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// base: Base 管理(server: aitable)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -116,7 +161,10 @@ var BaseList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bases := baseListProject(data)
|
||||
bases, err := baseListProject("list_bases", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(bases), "bases": bases})
|
||||
},
|
||||
}
|
||||
@@ -124,15 +172,19 @@ var BaseList = shortcut.Shortcut{
|
||||
// baseListProject reshapes list_bases / search_bases responses into a clean
|
||||
// {baseId, baseName} list — clean output projection. Both the list
|
||||
// container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func baseListProject(data map[string]any) []map[string]any {
|
||||
raw := baseListResolveList(data)
|
||||
// candidate keys. An explicitly returned empty array is a successful empty
|
||||
// result; a missing/malformed collection is a protocol error and must not be
|
||||
// reported as count=0.
|
||||
func baseListProject(operation string, data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := baseListResolveList(operation, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("%s response item %d must be an object, got %T", operation, index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := baseListFirst(m, "baseId", "base_id", "id"); ok {
|
||||
@@ -141,36 +193,20 @@ func baseListProject(data map[string]any) []map[string]any {
|
||||
if v, ok := baseListFirst(m, "baseName", "base_name", "name", "title"); ok {
|
||||
row["baseName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["baseId"]; !ok {
|
||||
return nil, fmt.Errorf("%s response item %d is missing baseId", operation, index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// baseListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level under a common envelope.
|
||||
func baseListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"bases", "result", "data", "list", "items", "records"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"bases", "list", "items", "result", "data", "records"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func baseListResolveList(operation string, data map[string]any) ([]any, error) {
|
||||
return resolveNamedList(operation, data,
|
||||
[]string{"bases", "result", "data", "list", "items", "records"},
|
||||
[]string{"bases", "list", "items", "result", "data", "records"})
|
||||
}
|
||||
|
||||
// baseListFirst returns the first present candidate key's value.
|
||||
@@ -230,7 +266,10 @@ var BaseSearch = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bases := baseListProject(data)
|
||||
bases, err := baseListProject("search_bases", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(bases), "bases": bases})
|
||||
},
|
||||
}
|
||||
@@ -339,7 +378,7 @@ var BaseDelete = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "待删除 Base ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +base-delete --base-id BASE_ID --yes`},
|
||||
Tips: []string{`dws aitable +base-delete --base-id BASE_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"baseId": rt.Str("base-id")}
|
||||
if rt.Changed("reason") {
|
||||
@@ -474,7 +513,7 @@ var TableDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "待删除 Table ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +table-delete --base-id B --table-id T --yes`},
|
||||
Tips: []string{`dws aitable +table-delete --base-id B --table-id T`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -601,7 +640,7 @@ var FieldDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "field-id", Type: shortcut.FlagString, Desc: "待删除字段 ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F --yes`},
|
||||
Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_field", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -704,26 +743,16 @@ var RecordUpdate = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-update",
|
||||
Product: serverMain,
|
||||
Description: "批量更新记录(每条含 recordId 与 cells),单次最多 100 条",
|
||||
Intent: "当你要批量修改已有行的字段值(如把一批任务标为已完成,每条须带 recordId)时使用;会实际覆盖对应单元格,单次最多 100 条。",
|
||||
Description: "批量更新记录,自动按 100 条分片并逐批读回验证",
|
||||
Intent: "当你要批量修改已有行的字段值(每条须带 recordId)时使用;自动按 100 条分片,任一批失败或读回不一致即非零退出并给出 nextOffset。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,如 '[{\"recordId\":\"rec\",\"cells\":{...}}]'", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
recs, err := parseJSONAny("records", rt.Str("records"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("update_records", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"records": recs,
|
||||
})
|
||||
},
|
||||
Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`},
|
||||
Execute: executeRecordUpdateBatches,
|
||||
}
|
||||
|
||||
// RecordDelete 删除行记录(delete_records)。
|
||||
@@ -731,22 +760,16 @@ var RecordDelete = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-delete",
|
||||
Product: serverMain,
|
||||
Description: "批量删除记录(不可逆),单次最多 100 条",
|
||||
Intent: "当你确认要批量删除若干行记录时使用;不可逆,按 recordId 列表删除,单次最多 100 条。",
|
||||
Description: "批量删除记录(不可逆),自动按 100 条分片并逐批确认记录已不存在",
|
||||
Intent: "当你确认要批量删除若干行记录时使用;自动按 100 条分片,只有每批全部 recordId 读回均不存在才判定成功,否则非零退出并给出 nextOffset。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2 --yes`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_records", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"recordIds": rt.StrSlice("record-ids"),
|
||||
})
|
||||
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔;最多 10000 个唯一 ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2`},
|
||||
Execute: executeRecordDeleteBatches,
|
||||
}
|
||||
|
||||
// RecordQueryEmpty 查询空行(query_empty_records,server: aitable-helper)。
|
||||
@@ -919,26 +942,16 @@ var RecordUpsert = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+record-upsert",
|
||||
Product: serverHelper,
|
||||
Description: "按 records 是否带 recordId 自动拆分 create / update",
|
||||
Intent: "当你有一批数据、其中部分是新增部分是更新、不想自己区分时使用;按记录是否带 recordId 自动拆分为创建或更新并实际写入,单次最多 100 条。",
|
||||
Description: "按 recordId 自动拆分 create/update,按 100 条分片并读回验证",
|
||||
Intent: "当一批数据中部分新增、部分更新时使用;自动按 recordId 拆分并以 100 条为批次写入,创建必须返回新 recordId,全部批次都需读回验证。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,单次最多 100 条", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
recs, err := parseJSONAny("records", rt.Str("records"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("record_upsert", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"records": recs,
|
||||
})
|
||||
},
|
||||
Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`},
|
||||
Execute: executeRecordUpsertBatches,
|
||||
}
|
||||
|
||||
// RecordPrimaryDocGet 查询记录主键文档(get_primary_doc,server: aitable-helper)。
|
||||
@@ -1047,7 +1060,10 @@ var TemplateSearch = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
templates := templateSearchProject(data)
|
||||
templates, err := templateSearchProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(templates), "templates": templates})
|
||||
},
|
||||
}
|
||||
@@ -1055,15 +1071,17 @@ var TemplateSearch = shortcut.Shortcut{
|
||||
// templateSearchProject reshapes the raw search_templates response into a clean
|
||||
// {templateId, templateName} list — clean output projection. Both
|
||||
// the list container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func templateSearchProject(data map[string]any) []map[string]any {
|
||||
raw := templateSearchResolveList(data)
|
||||
// candidate keys. Only an explicit array can establish a successful result.
|
||||
func templateSearchProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := templateSearchResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("search_templates response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := templateSearchFirst(m, "templateId", "template_id", "id"); ok {
|
||||
@@ -1072,37 +1090,21 @@ func templateSearchProject(data map[string]any) []map[string]any {
|
||||
if v, ok := templateSearchFirst(m, "templateName", "template_name", "name", "title"); ok {
|
||||
row["templateName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["templateId"]; !ok {
|
||||
return nil, fmt.Errorf("search_templates response item %d is missing templateId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// templateSearchResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level under a
|
||||
// common envelope key.
|
||||
func templateSearchResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"templates", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"templates", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func templateSearchResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("search_templates", data,
|
||||
[]string{"templates", "result", "data", "list", "items"},
|
||||
[]string{"templates", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// templateSearchFirst returns the first present candidate key's value.
|
||||
@@ -1198,7 +1200,10 @@ var ViewGet = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
views := viewGetProject(data)
|
||||
views, err := viewGetProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(views), "views": views})
|
||||
},
|
||||
}
|
||||
@@ -1206,15 +1211,17 @@ var ViewGet = shortcut.Shortcut{
|
||||
// viewGetProject reshapes the raw get_views response into a clean
|
||||
// {viewId, viewName, viewType} list — clean output projection. Both
|
||||
// the list container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func viewGetProject(data map[string]any) []map[string]any {
|
||||
raw := viewGetResolveList(data)
|
||||
// candidate keys. Only an explicit array can establish a successful result.
|
||||
func viewGetProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := viewGetResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("get_views response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := viewGetFirst(m, "viewId", "view_id", "id"); ok {
|
||||
@@ -1226,37 +1233,21 @@ func viewGetProject(data map[string]any) []map[string]any {
|
||||
if v, ok := viewGetFirst(m, "viewType", "view_type", "type"); ok {
|
||||
row["viewType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["viewId"]; !ok {
|
||||
return nil, fmt.Errorf("get_views response item %d is missing viewId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// viewGetResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level under a common envelope
|
||||
// key.
|
||||
func viewGetResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"views", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"views", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func viewGetResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("get_views", data,
|
||||
[]string{"views", "result", "data", "list", "items"},
|
||||
[]string{"views", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// viewGetFirst returns the first present candidate key's value.
|
||||
@@ -1355,7 +1346,7 @@ var ViewDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "view-id", Type: shortcut.FlagString, Desc: "待删除 View ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V --yes`},
|
||||
Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_view", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -1668,23 +1659,28 @@ var FormList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forms := formListProject(data)
|
||||
forms, err := formListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
|
||||
},
|
||||
}
|
||||
|
||||
// formListProject reshapes the raw list_form_views response into a clean form
|
||||
// view list ({viewId, viewName, viewType}) — clean output projection.
|
||||
// Both the list container and the per-item field names are probed
|
||||
// defensively across candidate keys, so an empty/unknown shape yields an empty
|
||||
// list rather than a crash or fabricated data.
|
||||
func formListProject(data map[string]any) []map[string]any {
|
||||
raw := formListResolveList(data)
|
||||
// Both the list container and the per-item field names are probed defensively
|
||||
// across candidate keys. Only an explicit array can establish success.
|
||||
func formListProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := formListResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("list_form_views response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := formListFirst(m, "viewId", "view_id", "id"); ok {
|
||||
@@ -1696,36 +1692,20 @@ func formListProject(data map[string]any) []map[string]any {
|
||||
if v, ok := formListFirst(m, "viewType", "view_type", "type"); ok {
|
||||
row["viewType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["viewId"]; !ok {
|
||||
return nil, fmt.Errorf("list_form_views response item %d is missing viewId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// formListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array container or nesting one level under a common envelope.
|
||||
func formListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"forms", "views", "formViews", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"forms", "views", "formViews", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func formListResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("list_form_views", data,
|
||||
[]string{"forms", "views", "formViews", "result", "data", "list", "items"},
|
||||
[]string{"forms", "views", "formViews", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// formListFirst returns the first present candidate key's value.
|
||||
@@ -1751,7 +1731,7 @@ var FormDelete = shortcut.Shortcut{
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
|
||||
{Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V --yes`},
|
||||
Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_form_view", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2013,7 +1993,7 @@ var WorkflowDisable = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W --yes`},
|
||||
Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("disable_workflow", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2069,23 +2049,27 @@ var WorkflowList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workflows := workflowListProject(data)
|
||||
workflows, err := workflowListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(workflows), "workflows": workflows})
|
||||
},
|
||||
}
|
||||
|
||||
// workflowListProject reshapes the raw list_workflows response into a clean
|
||||
// workflow list ({workflowId, name, status/enabled}) — output-projection
|
||||
// clean output projection. Both the list container and the per-item field names are
|
||||
// probed defensively across candidate keys, so an empty/unknown shape yields an
|
||||
// empty list rather than a crash or fabricated data.
|
||||
func workflowListProject(data map[string]any) []map[string]any {
|
||||
raw := workflowListResolveList(data)
|
||||
// clean output projection. Only an explicit array can establish success.
|
||||
func workflowListProject(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := workflowListResolveList(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
for index, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
|
||||
@@ -2097,37 +2081,21 @@ func workflowListProject(data map[string]any) []map[string]any {
|
||||
if v, ok := workflowListFirst(m, "status", "enabled", "isEnabled", "state"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
if _, ok := row["workflowId"]; !ok {
|
||||
return nil, fmt.Errorf("list_workflows response item %d is missing workflowId", index)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// workflowListResolveList locates the list payload inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level under a
|
||||
// common envelope.
|
||||
func workflowListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"workflows", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"workflows", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
func workflowListResolveList(data map[string]any) ([]any, error) {
|
||||
return resolveNamedList("list_workflows", data,
|
||||
[]string{"workflows", "result", "data", "list", "items"},
|
||||
[]string{"workflows", "list", "items", "result", "data"})
|
||||
}
|
||||
|
||||
// workflowListFirst returns the first present candidate key's value.
|
||||
@@ -2281,7 +2249,7 @@ var DashboardDelete = shortcut.Shortcut{
|
||||
{Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D --yes`},
|
||||
Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2512,7 +2480,7 @@ var ChartDelete = shortcut.Shortcut{
|
||||
{Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true},
|
||||
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
|
||||
},
|
||||
Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C --yes`},
|
||||
Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2744,7 +2712,7 @@ var AdvpermDisable = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +advperm-disable --base-id B --yes`},
|
||||
Tips: []string{`dws aitable +advperm-disable --base-id B`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("set_advanced_permission", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2908,7 +2876,7 @@ var RoleDelete = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID(数字 long 字符串)", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +role-delete --base-id B --role-id R --yes`},
|
||||
Tips: []string{`dws aitable +role-delete --base-id B --role-id R`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_role", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -2986,7 +2954,7 @@ var SectionDelete = shortcut.Shortcut{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
|
||||
{Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws aitable +section-delete --base-id B --section-id S --yes`},
|
||||
Tips: []string{`dws aitable +section-delete --base-id B --section-id S`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_section", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
@@ -3131,7 +3099,7 @@ var SectionMoveNode = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(withReviewedAITableShortcutContracts(
|
||||
BaseList,
|
||||
BaseSearch,
|
||||
BaseGet,
|
||||
@@ -3139,7 +3107,10 @@ func init() {
|
||||
BaseUpdate,
|
||||
BaseDelete,
|
||||
BaseCopy,
|
||||
BaseSchemaSnapshot,
|
||||
BaseBootstrap,
|
||||
TableGet,
|
||||
TableCopy,
|
||||
TableUpdate,
|
||||
TableDelete,
|
||||
FieldGet,
|
||||
@@ -3152,12 +3123,17 @@ func init() {
|
||||
RecordHistoryList,
|
||||
RecordShareURL,
|
||||
RecordUpsert,
|
||||
RecordUpsertByKey,
|
||||
RecordBulkPatch,
|
||||
RecordPrimaryDocGet,
|
||||
RecordPrimaryDocCreate,
|
||||
TemplateSearch,
|
||||
AttachmentUpload,
|
||||
AttachmentPut,
|
||||
AttachmentRemove,
|
||||
ViewGet,
|
||||
ViewUpdate,
|
||||
ViewPresetApply,
|
||||
ViewDuplicate,
|
||||
ViewDelete,
|
||||
ViewGetLock,
|
||||
@@ -3179,6 +3155,7 @@ func init() {
|
||||
WorkflowDisable,
|
||||
WorkflowGet,
|
||||
WorkflowList,
|
||||
WorkflowDeploy,
|
||||
DashboardConfigExample,
|
||||
DashboardGet,
|
||||
DashboardUpdate,
|
||||
@@ -3209,5 +3186,6 @@ func init() {
|
||||
SectionListEmpty,
|
||||
SectionListNodes,
|
||||
SectionMoveNode,
|
||||
)
|
||||
URLResolve,
|
||||
)...)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user