diff --git a/.changes/command-typo-guidance.md b/.changes/command-typo-guidance.md new file mode 100644 index 00000000..76a13aa9 --- /dev/null +++ b/.changes/command-typo-guidance.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list. diff --git a/.changes/released/1.0.60-beta.2/1056-drive-permission-get-setting.md b/.changes/released/1.0.60-beta.2/1056-drive-permission-get-setting.md new file mode 100644 index 00000000..260e6810 --- /dev/null +++ b/.changes/released/1.0.60-beta.2/1056-drive-permission-get-setting.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node ` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call. diff --git a/.changes/released/1.0.60-beta.2/1082-sheet-whiteboard-shortcuts.md b/.changes/released/1.0.60-beta.2/1082-sheet-whiteboard-shortcuts.md new file mode 100644 index 00000000..0b8017b0 --- /dev/null +++ b/.changes/released/1.0.60-beta.2/1082-sheet-whiteboard-shortcuts.md @@ -0,0 +1,6 @@ +--- +category: Added +--- + +- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification. +- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads. diff --git a/.changes/released/1.0.60-beta.2/1083-aisearch-contact-live-shortcuts.md b/.changes/released/1.0.60-beta.2/1083-aisearch-contact-live-shortcuts.md new file mode 100644 index 00000000..f91fb86c --- /dev/null +++ b/.changes/released/1.0.60-beta.2/1083-aisearch-contact-live-shortcuts.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts. diff --git a/.changes/released/1.0.60-beta.2/1085-permission-error-guidance.md b/.changes/released/1.0.60-beta.2/1085-permission-error-guidance.md new file mode 100644 index 00000000..3d9bb49a --- /dev/null +++ b/.changes/released/1.0.60-beta.2/1085-permission-error-guidance.md @@ -0,0 +1,24 @@ +--- +category: Changed +--- + +- **Permission error guidance and error rendering** (#1085) — + permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code + instead of a generic business-error rendering; document/wiki-specific errors + (the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`, + or the role-threshold wording like + “需要您具备 MANAGER 及以上角色”) carry apply-permission guidance + (`dws drive permission apply-info` / `dws drive permission apply`), while + permission failures carrying only generic code names (`FORBIDDEN`, + `NO_PERMISSION` — also returned by attendance and event-subscription tools) + or other products' wording keep their product-specific or + product-neutral suggestion instead of a misleading document-permission hint; + member-validation failures such as + “用户不存在/不属于当前组织” are classified as tool errors with a + `--members`-with-`corpId` suggestion instead of a misleading + resource-not-found error; business error output now surfaces the backend + message with `code`/`logId` appended for traceability; and the + `update_permission` / `remove_permission` / `update_member` / + `remove_member` tools — whose servers return a literal `null` on successful + no-payload writes — now render `{}` so downstream JSON consumers do not fail + parsing `null`; other tools keep raw `null` output unchanged. diff --git a/.changes/released/1.0.60-beta.2/1085-permission-member-pagination.md b/.changes/released/1.0.60-beta.2/1085-permission-member-pagination.md new file mode 100644 index 00000000..a666c707 --- /dev/null +++ b/.changes/released/1.0.60-beta.2/1085-permission-member-pagination.md @@ -0,0 +1,22 @@ +--- +category: Added +--- + +- **Permission and member list pagination** (#1085) — `drive/doc permission + list` and `wiki member list` now accept `--next-token` to follow the + server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and + map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults + 200` path; `permission add/update/remove` and `wiki member add/update/remove` + additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG + grantee types. The optional `--notify` defaults to `false` and is omitted from + the server request unless passed explicitly, so member grants no longer notify + recipients by default. These commands also declare cursor pagination + (`next-token`) in the Agent schema contract, mirroring the internal CLI parity + change. Because a single batch remove can revoke access for up to 30 + USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role + groups can indirectly affect many more users — `drive/doc permission + remove` and `wiki member remove` now declare + `confirmation=user_required` and gate the actual tool call behind user + confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their + confirmation-gate failure now also passes through verbatim instead of being + reclassified as a permission-denied or unclassified error. diff --git a/.changes/released/1.0.60-beta.2/agoal-scorecard-search-entities.md b/.changes/released/1.0.60-beta.2/agoal-scorecard-search-entities.md new file mode 100644 index 00000000..1906a4bb --- /dev/null +++ b/.changes/released/1.0.60-beta.2/agoal-scorecard-search-entities.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination. diff --git a/.changes/released/1.0.60-beta.2/aitable-datasource-shortcuts.md b/.changes/released/1.0.60-beta.2/aitable-datasource-shortcuts.md new file mode 100644 index 00000000..b299e701 --- /dev/null +++ b/.changes/released/1.0.60-beta.2/aitable-datasource-shortcuts.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide. diff --git a/.changes/released/1.0.60-beta.2/doc-read-public-and-history-version.md b/.changes/released/1.0.60-beta.2/doc-read-public-and-history-version.md new file mode 100644 index 00000000..b04e979d --- /dev/null +++ b/.changes/released/1.0.60-beta.2/doc-read-public-and-history-version.md @@ -0,0 +1,13 @@ +--- +category: Added +--- + +- **Doc public-link and historical-version reads** — `dws doc read` forwards + the reviewed `password` (internet-public documents with password protection) + and `historyVersion` (read content as of a listed historical version; `0` + denotes the document's initial version) parameters on the markdown, JSONML, + and scope read paths via `--password` / `--version`; `dws doc +fetch` gains + `--password` and `--version` with the same `historyVersion` forwarding, while + `--revision` stays rejected with explicit guidance: revision is the document + edit revision returned by JSONML reads for `+update --expected-revision` + conditional writes, not a historical version number. diff --git a/.changes/released/1.0.60-beta.2/edu-college-vendor-extensions.md b/.changes/released/1.0.60-beta.2/edu-college-vendor-extensions.md new file mode 100644 index 00000000..463f8c96 --- /dev/null +++ b/.changes/released/1.0.60-beta.2/edu-college-vendor-extensions.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`. diff --git a/.changes/released/1.0.60-beta.2/legacy-global-slot-recovery.md b/.changes/released/1.0.60-beta.2/legacy-global-slot-recovery.md new file mode 100644 index 00000000..daaf9dab --- /dev/null +++ b/.changes/released/1.0.60-beta.2/legacy-global-slot-recovery.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations. diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/released/1.0.60-beta.2/oa-approval-attachment-upload.md similarity index 100% rename from .changes/oa-approval-attachment-upload.md rename to .changes/released/1.0.60-beta.2/oa-approval-attachment-upload.md diff --git a/.changes/released/1.0.60-beta.2/sheet-float-image-local-file.md b/.changes/released/1.0.60-beta.2/sheet-float-image-local-file.md new file mode 100644 index 00000000..cf00618c --- /dev/null +++ b/.changes/released/1.0.60-beta.2/sheet-float-image-local-file.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow. diff --git a/.changes/sheet-revision-changesets.md b/.changes/released/1.0.60-beta.2/sheet-revision-changesets.md similarity index 100% rename from .changes/sheet-revision-changesets.md rename to .changes/released/1.0.60-beta.2/sheet-revision-changesets.md diff --git a/.changes/remove-edu-college-vendor-extensions.md b/.changes/remove-edu-college-vendor-extensions.md new file mode 100644 index 00000000..032cfc01 --- /dev/null +++ b/.changes/remove-edu-college-vendor-extensions.md @@ -0,0 +1,5 @@ +--- +category: Removed +--- + +- **Education and college vendor extensions removed** — removes `dws edu-contact`, `dws edu-group`, `dws edu-app`, `dws edu-familygroup`, and `dws college-contact` from the CLI, Schema, bundled Skills, and open-edition MCP endpoint registry. Future DWS packages no longer expose these five command surfaces. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index eea35124..b8b67108 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,3 +4,13 @@ paths: # GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's # bundled workflow schema has not caught up with the platform syntax. - 'unexpected key "queue" for "concurrency" section' + .github/workflows/coverage-baseline-promotion.yml: + ignore: + # Serialize every acknowledgement for one Formula target without + # allowing Actions' default single-pending replacement to orphan a run. + - 'unexpected key "queue" for "concurrency" section' + .github/workflows/coverage-baseline-repair.yml: + ignore: + # Keep the closed-event dispatcher and its exact-SHA producer queued for + # the same target instead of replacing either half of the repair chain. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49a76f09..31c4a2ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,16 @@ on: branches: - main pull_request: + types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled] permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Only duplicate runs for the exact PR base/head revision cancel each other. + # A later revision must not kill an earlier cold-cache producer, and every + # protected-main SHA keeps an independent producer run. + group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }} cancel-in-progress: true jobs: @@ -544,7 +548,8 @@ jobs: - app-schema - app-a-b - app-c-a-l - - app-c-m-r + - app-c-m-o + - app-c-p-r - app-c-s-z - app-c-other - app-d-r @@ -692,7 +697,8 @@ jobs: - app-schema - app-a-b - app-c-a-l - - app-c-m-r + - app-c-m-o + - app-c-p-r - app-c-s-z - app-c-other - app-d-r @@ -835,7 +841,9 @@ jobs: if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 5 - permissions: {} + permissions: + contents: read + pull-requests: read steps: - name: Verify test shards env: @@ -914,6 +922,283 @@ jobs: done test "$failed" -eq 0 + # Null and non-built-in merge identities emit either the protected-main + # push or the trusted pull_request_target closed repair. The built-in + # Actions identity is the exceptional unsafe path, so its own token must + # prove that main-merge-writers never lets it update main. + - name: Verify auto-merge identity + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullNumber = context.payload.pull_request.number; + const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; + const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim(); + if ( + !appSlug || + appSlug !== appSlug.toLowerCase() || + appSlug === 'github-actions' + ) { + core.setFailed( + 'Reviewer Router App slug repository variable is missing or unsafe.', + ); + return; + } + const expectedAppOwner = `${appSlug}[bot]`; + const writerRulesetName = 'main-merge-writers'; + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + const {data: repository} = await github.rest.repos.get({owner, repo}); + function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; + } + const mergeDefaultsProjection = classifyMergeDefaults(repository); + if (mergeDefaultsProjection === 'invalid') { + core.setFailed( + 'Repository merge-message defaults are malformed or changed from their reviewed values.', + ); + return; + } + if (mergeDefaultsProjection === 'omitted') { + core.info( + 'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.', + ); + } + + const appliedRules = await github.paginate( + 'GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: 'main', per_page: 100}, + ); + const repositorySource = `${owner}/${repo}`.toLowerCase(); + const applicableRulesetIDs = [ + ...new Set( + appliedRules + .filter(rule => + rule.ruleset_source_type === 'Repository' && + rule.ruleset_source?.toLowerCase() === repositorySource && + Number.isSafeInteger(Number(rule.ruleset_id)) && + Number(rule.ruleset_id) > 0, + ) + .map(rule => Number(rule.ruleset_id)), + ), + ]; + const activeMainRulesets = []; + for (const rulesetID of applicableRulesetIDs) { + const {data: ruleset} = await github.request( + 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', + {owner, repo, ruleset_id: rulesetID}, + ); + if ( + ruleset.enforcement !== 'active' || + ruleset.target !== 'branch' || + ruleset.source_type !== 'Repository' || + ruleset.source?.toLowerCase() !== repositorySource + ) { + core.setFailed( + `Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`, + ); + return; + } + activeMainRulesets.push(ruleset); + } + + const writerRulesets = activeMainRulesets.filter( + ruleset => ruleset.name === writerRulesetName, + ); + if (writerRulesets.length !== 1) { + core.setFailed( + `Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`, + ); + return; + } + const writerRuleset = writerRulesets[0]; + const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; + const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + // GitHub's read projection omits the entire parameters property + // when this exception is disabled. Accept only that exact omission + // or a one-field object containing exact false. + function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); + return ( + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); + } + function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false + ); + } + if ( + writerIncludes.length !== 1 || + writerIncludes[0] !== 'refs/heads/main' || + writerExcludes.length !== 0 || + typeof writerRuleset.node_id !== 'string' || + !writerRuleset.node_id || + writerRuleset.rules?.length !== 1 || + !isStrictUpdateRule(writerRuleset.rules[0]) || + writerRuleset.current_user_can_bypass !== 'never' + ) { + core.setFailed( + `${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`, + ); + return; + } + const {node: graphWriterRuleset} = await github.graphql( + `query ReviewerRouterWriterRule($rulesetID: ID!) { + node(id: $rulesetID) { + ... on RepositoryRuleset { + databaseId + name + enforcement + target + rules(first: 2) { + totalCount + nodes { + type + parameters { + __typename + ... on UpdateParameters { + updateAllowsFetchAndMerge + } + } + } + } + } + } + }`, + {rulesetID: writerRuleset.node_id}, + ); + if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) { + core.setFailed( + `${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`, + ); + return; + } + + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' + ) { + core.setFailed( + `PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`, + ); + return; + } + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + if (mergeTexts.some(value => skipWorkflowPattern.test(value))) { + core.setFailed( + `PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`, + ); + return; + } + if (!currentPull.auto_merge) { + core.info( + `PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`, + ); + return; + } + const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase(); + const safeCommitHeadline = `Merge pull request #${pullNumber}`; + const safeCommitBody = + `Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`; + if ( + enabledBy === expectedAppOwner && + currentPull.auto_merge.commit_title === safeCommitHeadline && + currentPull.auto_merge.commit_message === safeCommitBody + ) { + core.info( + `PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`, + ); + return; + } + if (attempt < maxAttempts) { + core.info( + `PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`, + ); + await new Promise(resolve => setTimeout(resolve, 5000)); + continue; + } + core.setFailed( + `PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`, + ); + } + test-darwin: name: Test (macOS auth/keychain) needs: lint @@ -1244,7 +1529,7 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out repository uses: actions/checkout@v4 @@ -1379,6 +1664,143 @@ jobs: path: coverage-base.txt retention-days: 1 + # Documentation and release-seal pushes do not change executable coverage, + # but their new main SHA is still a future PR merge base. Promote only an + # exact predecessor cache after independently proving the whole push changed + # metadata paths; fall back to a full authoritative profile on a cold chain. + coverage-main-metadata: + name: Coverage (main metadata cache) + needs: lint + if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out exact metadata-only main revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Set up Go + id: setup-go-metadata + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Verify metadata-only main successor + shell: bash + env: + PUSH_BEFORE_SHA: ${{ github.event.before }} + PUSH_AFTER_SHA: ${{ github.event.after }} + run: | + set -euo pipefail + full_commit='^[0-9a-f]{40}$' + [[ "$PUSH_BEFORE_SHA" =~ $full_commit ]] + [[ "$PUSH_AFTER_SHA" =~ $full_commit ]] + test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000 + test "$PUSH_AFTER_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null + git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" + + changed_count=0 + while IFS= read -r -d '' path; do + changed_count=$((changed_count + 1)) + case "$path" in + CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*) + ;; + .changes/*) + if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] || + [[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then + continue + fi + echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2 + exit 1 + ;; + *) + echo "Refusing coverage-cache promotion for executable path: $path" >&2 + exit 1 + ;; + esac + done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA") + test "$changed_count" -gt 0 + echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV" + + - name: Restore existing current-SHA coverage profile + id: metadata-current-cache + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }} + + - name: Validate existing current-SHA coverage profile + if: steps.metadata-current-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Restore exact predecessor coverage profile + id: metadata-source-cache + if: steps.metadata-current-cache.outputs.cache-hit != 'true' + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }} + + - name: Validate promoted predecessor coverage profile + if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Install archive tooling for cold metadata baseline + if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true' + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi + + - name: Recompute cold metadata baseline + if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true' + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + go test -count=1 -p 1 \ + -coverprofile=coverage-cache.txt \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Save metadata main SHA coverage profile + if: steps.metadata-current-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }} + + # actions/cache/save reports upload failures as warnings. Convert an + # absent exact target key into a hard producer failure. + - name: Verify metadata main SHA coverage cache exists + id: metadata-target-cache-verification + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact metadata main SHA coverage cache + env: + EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true + coverage: name: Coverage needs: @@ -1387,6 +1809,7 @@ jobs: - coverage-current-full - coverage-supporting - coverage-baseline + - coverage-main-metadata - coverage-darwin - coverage-windows if: ${{ always() && needs.lint.result == 'success' }} @@ -1403,6 +1826,7 @@ jobs: CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} + MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }} DARWIN_RESULT: ${{ needs.coverage-darwin.result }} WINDOWS_RESULT: ${{ needs.coverage-windows.result }} run: | @@ -1411,10 +1835,14 @@ jobs: current_full_expected=skipped supporting_expected=skipped baseline_expected=success + main_metadata_expected=skipped native_expected=skipped if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then current_expected=skipped baseline_expected=skipped + if [ "$GITHUB_EVENT_NAME" = push ]; then + main_metadata_expected=success + fi elif [ "$FULL_SUITE" = true ]; then current_expected=skipped current_full_expected=success @@ -1430,7 +1858,8 @@ jobs: "current:$CURRENT_RESULT:$current_expected" \ "current shards:$CURRENT_FULL_RESULT:$current_full_expected" \ "supporting:$SUPPORTING_RESULT:$supporting_expected" \ - "baseline:$BASELINE_RESULT:$baseline_expected" + "baseline:$BASELINE_RESULT:$baseline_expected" \ + "main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected" do name="${profile%%:*}" remainder="${profile#*:}" @@ -1570,6 +1999,22 @@ jobs: path: coverage-cache.txt key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} + - name: Verify push coverage cache exists + id: push-cache-verification + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact push coverage cache + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + env: + EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true + - name: Generate coverage report if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' run: | @@ -1597,7 +2042,10 @@ jobs: name: Policy needs: lint runs-on: ubuntu-latest - timeout-minutes: 10 + # Full policy regenerates and validates the runtime Schema several times. + # Keep job-level headroom for large reviewed command-surface additions; + # individual policy gates retain their own fail-closed checks. + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/.github/workflows/coverage-baseline-promotion.yml b/.github/workflows/coverage-baseline-promotion.yml new file mode 100644 index 00000000..6cff727e --- /dev/null +++ b/.github/workflows/coverage-baseline-promotion.yml @@ -0,0 +1,322 @@ +name: Coverage Baseline Promotion + +run-name: Promote coverage baseline for ${{ github.event.client_payload.target_sha }} + +on: + repository_dispatch: + types: [coverage-baseline-promote] + +# repository_dispatch loads this workflow from the protected default branch. +# The requested target is treated as untrusted input until the validation step +# proves it is an exact Formula-only successor already contained in main. +permissions: + checks: write + contents: read + +concurrency: + group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }} + cancel-in-progress: false + queue: max + +jobs: + promote: + if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Validate Formula-only main target + id: validate-target + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const targetSha = context.payload.client_payload?.target_sha; + const sourceRunId = context.payload.client_payload?.source_run_id; + const checkRunId = Number(context.payload.client_payload?.check_run_id); + if (!/^[0-9a-f]{40}$/.test(targetSha || '')) { + throw new Error('coverage-baseline-promote requires one full target_sha'); + } + if (!/^[1-9][0-9]*$/.test(sourceRunId || '')) { + throw new Error('coverage-baseline-promote requires one source_run_id'); + } + if (!Number.isSafeInteger(checkRunId) || checkRunId <= 0) { + throw new Error('coverage-baseline-promote requires one safe check_run_id'); + } + + // Bind the finalizer before any target or cache validation. A + // later failure must complete the release-created acknowledgement + // instead of leaving Release to poll a permanently queued check. + const promotionExternalId = `release-${sourceRunId}-${targetSha}`; + const {data: promotionCheck} = await github.rest.checks.get({ + owner, + repo, + check_run_id: checkRunId, + }); + if ( + promotionCheck.id !== checkRunId || + promotionCheck.head_sha !== targetSha || + promotionCheck.name !== 'Coverage Baseline Cache' || + promotionCheck.external_id !== promotionExternalId || + promotionCheck.app?.slug !== 'github-actions' || + promotionCheck.status !== 'queued' || + promotionCheck.conclusion !== null + ) { + throw new Error('coverage baseline acknowledgement has an invalid identity'); + } + core.setOutput('target_sha', targetSha); + core.setOutput('check_run_id', String(checkRunId)); + core.setOutput('check_external_id', promotionExternalId); + + const {data: targetCommit} = await github.rest.repos.getCommit({ + owner, + repo, + ref: targetSha, + per_page: 100, + }); + const files = targetCommit.files || []; + const message = targetCommit.commit.message; + const formulaPath = files[0]?.filename; + const stableFormula = + formulaPath === 'Formula/dingtalk-workspace-cli.rb' && + /^chore: update formula for v[0-9]+\.[0-9]+\.[0-9]+ \[skip ci\]$/.test(message); + const betaFormula = + formulaPath === 'Formula/dingtalk-workspace-cli-beta.rb' && + /^chore: update beta formula for v[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]* \[skip ci\]$/.test(message); + if ( + targetCommit.sha !== targetSha || + targetCommit.parents.length !== 1 || + targetCommit.author?.login !== 'github-actions[bot]' || + targetCommit.committer?.login !== 'github-actions[bot]' || + files.length !== 1 || + !['added', 'modified'].includes(files[0].status) || + (!stableFormula && !betaFormula) + ) { + throw new Error( + `${targetSha} is not an exact release-produced Formula-only commit`, + ); + } + + const parentSha = targetCommit.parents[0].sha; + const requiredContexts = [ + 'Lint', + 'Test', + 'Coverage', + 'Policy', + 'Edition', + 'Interface Integrity', + 'AI Behavior', + 'CLI Smoke', + 'Mock MCP', + ]; + async function requireSuccessfulAdmission(ref, label) { + for (let attempt = 1; attempt <= 6; attempt += 1) { + const runs = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref, + filter: 'latest', + per_page: 100, + }); + const latestByName = new Map(); + for (const run of runs) { + if ( + run.head_sha !== ref || + run.app?.slug !== 'github-actions' || + !requiredContexts.includes(run.name) + ) { + continue; + } + const current = latestByName.get(run.name); + if (!current || run.id > current.id) { + latestByName.set(run.name, run); + } + } + const invalid = requiredContexts.filter((name) => { + const run = latestByName.get(name); + return !run || run.conclusion !== 'success'; + }); + if (invalid.length === 0) { + return; + } + if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 5000)); + continue; + } + throw new Error( + `${label} ${ref} lacks successful Code Admission contexts: ${invalid.join(', ')}`, + ); + } + } + await requireSuccessfulAdmission(parentSha, 'Formula parent'); + await requireSuccessfulAdmission(targetSha, 'Formula target'); + + const {data: branch} = await github.rest.repos.getBranch({ + owner, + repo, + branch: context.payload.repository.default_branch, + }); + const {data: containment} = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${targetSha}...${branch.commit.sha}`, + }); + if (!['ahead', 'identical'].includes(containment.status)) { + throw new Error(`${targetSha} is not contained in the protected default branch`); + } + + core.setOutput('parent_sha', parentSha); + core.setOutput('formula_path', formulaPath); + + - name: Mark Formula cache promotion in progress + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + await github.rest.checks.update({ + ...context.repo, + check_run_id: Number('${{ steps.validate-target.outputs.check_run_id }}'), + status: 'in_progress', + started_at: new Date().toISOString(), + output: { + title: 'Producing exact-SHA coverage baseline', + summary: 'The trusted default-branch workflow is validating or producing the main-scoped cache.', + }, + }); + + - name: Check out validated Formula-only target + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.validate-target.outputs.target_sha }} + + - name: Verify checked-out Formula-only identity + shell: bash + env: + TARGET_SHA: ${{ steps.validate-target.outputs.target_sha }} + PARENT_SHA: ${{ steps.validate-target.outputs.parent_sha }} + FORMULA_PATH: ${{ steps.validate-target.outputs.formula_path }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + test "$(git rev-parse HEAD^)" = "$PARENT_SHA" + test "$(git diff --name-only --no-renames "$PARENT_SHA" "$TARGET_SHA")" = "$FORMULA_PATH" + + - name: Set up Go + id: setup-go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + + - name: Restore existing target coverage profile + id: target-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Validate existing target coverage profile + if: steps.target-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Restore exact Formula parent coverage profile + id: parent-cache + if: steps.target-cache.outputs.cache-hit != 'true' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Validate promoted Formula parent profile + if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Install archive tooling for cold Formula baseline + if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true' + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi + + - name: Recompute cold Formula baseline + if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true' + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + go test -count=1 -p 1 \ + -coverprofile=coverage-cache.txt \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Save Formula main SHA coverage profile + if: steps.target-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Verify Formula main SHA coverage cache exists + id: formula-target-cache-verification + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact Formula main SHA coverage cache + env: + EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true + + - name: Complete Formula cache promotion acknowledgement + if: ${{ always() && steps.validate-target.outputs.check_run_id != '' }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + PROMOTION_JOB_STATUS: ${{ job.status }} + with: + script: | + const checkRunId = Number('${{ steps.validate-target.outputs.check_run_id }}'); + const targetSha = '${{ steps.validate-target.outputs.target_sha }}'; + const expectedExternalId = '${{ steps.validate-target.outputs.check_external_id }}'; + const {data: currentCheck} = await github.rest.checks.get({ + ...context.repo, + check_run_id: checkRunId, + }); + if ( + currentCheck.head_sha !== targetSha || + currentCheck.name !== 'Coverage Baseline Cache' || + currentCheck.external_id !== expectedExternalId || + currentCheck.app?.slug !== 'github-actions' + ) { + throw new Error('refusing to update a changed promotion acknowledgement'); + } + const succeeded = process.env.PROMOTION_JOB_STATUS === 'success'; + await github.rest.checks.update({ + ...context.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion: succeeded ? 'success' : 'failure', + completed_at: new Date().toISOString(), + output: { + title: succeeded + ? 'Exact-SHA coverage baseline is available' + : 'Exact-SHA coverage baseline promotion failed', + summary: succeeded + ? `Verified the main-scoped exact cache for ${targetSha}.` + : `Promotion failed for ${targetSha}; rerun the failed Release job after correcting the producer.`, + }, + }); diff --git a/.github/workflows/coverage-baseline-repair.yml b/.github/workflows/coverage-baseline-repair.yml new file mode 100644 index 00000000..2d1b30f7 --- /dev/null +++ b/.github/workflows/coverage-baseline-repair.yml @@ -0,0 +1,560 @@ +name: Coverage Baseline Repair + +run-name: Repair coverage baseline from ${{ github.event_name }} + +on: + pull_request_target: + branches: [main] + types: [closed] + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + repository_dispatch: + types: [coverage-baseline-repair] + schedule: + - cron: "23 * * * *" + workflow_dispatch: + +# pull_request_target and workflow_run are allowed to inspect only GitHub API +# data and dispatch the trusted producer. GitHub deliberately makes both +# triggers read-only for the default-branch cache, so all checkout and cache +# writes live in repository_dispatch, schedule, or main-only workflow_dispatch. +permissions: + contents: read + +concurrency: + group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }} + cancel-in-progress: false + # Retain every pending repair for one target. actionlint v1.7.12's bundled + # schema predates GitHub's concurrency.queue support. + queue: max + +jobs: + dispatch-merged-pr: + if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: write + pull-requests: read + steps: + # Never check out or execute pull-request content in this privileged + # base-owned event. Re-read the merged PR, bind every immutable identity, + # prove the result is in main, and send only those values to the producer. + - name: Dispatch trusted merged-PR repair + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventPull = context.payload.pull_request; + const fullCommit = /^[0-9a-f]{40}$/; + const pullNumber = Number(eventPull?.number); + const headSha = eventPull?.head?.sha; + const baseRef = eventPull?.base?.ref; + const mergeCommitSha = eventPull?.merge_commit_sha; + if ( + context.payload.repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' || + context.payload.repository?.default_branch !== 'main' || + !Number.isSafeInteger(pullNumber) || + pullNumber <= 0 || + !fullCommit.test(headSha || '') || + baseRef !== 'main' || + !fullCommit.test(mergeCommitSha || '') + ) { + throw new Error('closed PR event has an invalid repository or revision identity'); + } + + // REST base.sha follows the live base branch and can move after + // merge. Bind the closed event's stable PR head snapshot and merge + // facts, then authorize the target through main containment. + function isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + ) { + return ( + currentPull?.number === pullNumber && + currentPull.state === 'closed' && + currentPull.merged === true && + typeof currentPull.merged_at === 'string' && + currentPull.merged_at.length > 0 && + currentPull.base?.ref === 'main' && + currentPull.head?.sha === headSha && + currentPull.merge_commit_sha === mergeCommitSha + ); + } + + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (!isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + )) { + throw new Error(`PR #${pullNumber} no longer matches the merged-main event`); + } + + async function requireMainContainment(targetSha) { + let lastState = 'not checked'; + for (let attempt = 1; attempt <= 6; attempt += 1) { + try { + const {data: branch} = await github.rest.repos.getBranch({ + owner, + repo, + branch: 'main', + }); + const {data: comparison} = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${targetSha}...${branch.commit.sha}`, + }); + lastState = comparison.status; + if (['ahead', 'identical'].includes(comparison.status)) { + return; + } + } catch (error) { + lastState = error.message; + } + if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + throw new Error( + `${targetSha} is not contained in protected main after retries: ${lastState}`, + ); + } + await requireMainContainment(mergeCommitSha); + + // Normal App or human merges emit a protected-main push run whose + // CI producer owns this exact key. Give Actions event delivery a + // short visibility window and avoid a duplicate full-suite repair. + // A workflow-skip directive or suppressed built-in-token event has + // no such run, so only that missing-event path reaches dispatch. + const {data: ciWorkflow} = await github.rest.actions.getWorkflow({ + owner, + repo, + workflow_id: '.github/workflows/ci.yml', + }); + if ( + ciWorkflow.name !== 'CI' || + ciWorkflow.path !== '.github/workflows/ci.yml' || + ciWorkflow.state !== 'active' + ) { + throw new Error('protected CI workflow identity is not active or exact'); + } + for (let attempt = 1; attempt <= 12; attempt += 1) { + const {data: workflowRuns} = + await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + branch: 'main', + event: 'push', + per_page: 100, + }); + const exactPushRun = workflowRuns.workflow_runs.find(run => + run.name === 'CI' && + run.workflow_id === ciWorkflow.id && + run.path === ciWorkflow.path && + run.event === 'push' && + run.head_sha === mergeCommitSha && + run.head_branch === 'main' && + ['queued', 'in_progress', 'completed'].includes(run.status), + ); + if (exactPushRun) { + core.info( + `CI push run ${exactPushRun.id} already owns the exact-SHA producer for ${mergeCommitSha}; repair dispatch is unnecessary.`, + ); + return; + } + if (attempt < 12) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + + // repository_dispatch is one of GitHub's explicit GITHUB_TOKEN + // recursion exceptions and receives default-branch cache-write scope. + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: 'coverage-baseline-repair', + client_payload: { + source: 'merged_pr', + pull_number: String(pullNumber), + head_sha: headSha, + merge_commit_sha: mergeCommitSha, + source_run_id: String(context.runId), + }, + }); + core.info( + `Dispatched exact-SHA coverage repair for merged PR #${pullNumber} at ${mergeCommitSha}.`, + ); + + dispatch-failed-ci: + if: >- + ${{ + github.event_name == 'workflow_run' && + github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' && + github.event.workflow_run.name == 'CI' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.status == 'completed' && + github.event.workflow_run.conclusion != 'success' + }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: write + steps: + # workflow_run cannot write the default-branch cache. Re-read the exact + # completed CI run from Actions, bind it to the protected CI workflow and + # main revision, then use the repository_dispatch recursion exception. + - name: Dispatch trusted failed-CI repair + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const upstream = 'DingTalk-Real-AI/dingtalk-workspace-cli'; + const eventRun = context.payload.workflow_run; + const fullCommit = /^[0-9a-f]{40}$/; + const runID = Number(eventRun?.id); + const runAttempt = Number(eventRun?.run_attempt); + const headSha = eventRun?.head_sha; + const conclusion = eventRun?.conclusion; + if ( + context.payload.repository?.full_name !== upstream || + context.payload.repository?.default_branch !== 'main' || + !Number.isSafeInteger(runID) || + runID <= 0 || + !Number.isSafeInteger(runAttempt) || + runAttempt <= 0 || + eventRun?.name !== 'CI' || + eventRun?.event !== 'push' || + eventRun?.head_branch !== 'main' || + eventRun?.status !== 'completed' || + typeof conclusion !== 'string' || + conclusion.length === 0 || + conclusion === 'success' || + !fullCommit.test(headSha || '') + ) { + throw new Error('workflow_run event is not one completed non-success main CI push'); + } + + const {data: ciWorkflow} = await github.rest.actions.getWorkflow({ + owner, + repo, + workflow_id: '.github/workflows/ci.yml', + }); + const {data: currentRun} = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: runID, + }); + if ( + ciWorkflow.name !== 'CI' || + ciWorkflow.path !== '.github/workflows/ci.yml' || + eventRun.workflow_id !== ciWorkflow.id || + currentRun.id !== runID || + currentRun.workflow_id !== ciWorkflow.id || + currentRun.name !== 'CI' || + currentRun.event !== 'push' || + currentRun.head_branch !== 'main' || + currentRun.head_sha !== headSha || + currentRun.run_attempt !== runAttempt || + currentRun.status !== 'completed' || + currentRun.conclusion !== conclusion || + currentRun.conclusion === 'success' || + currentRun.repository?.full_name !== upstream || + currentRun.head_repository?.full_name !== upstream + ) { + throw new Error(`CI workflow run ${runID} no longer matches the completed event`); + } + + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: 'coverage-baseline-repair', + client_payload: { + source: 'failed_ci', + workflow_run_id: String(runID), + workflow_run_attempt: String(runAttempt), + workflow_conclusion: conclusion, + merge_commit_sha: headSha, + source_run_id: String(context.runId), + }, + }); + core.info( + `Dispatched exact-SHA coverage repair for ${conclusion} CI run ${runID} at ${headSha}.`, + ); + + repair: + if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Resolve trusted main repair target + id: resolve-target + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const fullCommit = /^[0-9a-f]{40}$/; + if ( + context.payload.repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' || + context.payload.repository?.default_branch !== 'main' + ) { + throw new Error('coverage repair is restricted to the protected upstream repository'); + } + + async function requireMainContainment(targetSha) { + let lastState = 'not checked'; + for (let attempt = 1; attempt <= 6; attempt += 1) { + try { + const {data: branch} = await github.rest.repos.getBranch({ + owner, + repo, + branch: 'main', + }); + const {data: comparison} = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${targetSha}...${branch.commit.sha}`, + }); + lastState = comparison.status; + if (['ahead', 'identical'].includes(comparison.status)) { + return branch.commit.sha; + } + } catch (error) { + lastState = error.message; + } + if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + throw new Error( + `${targetSha} is not contained in protected main after retries: ${lastState}`, + ); + } + + // The dispatcher froze the stable PR head snapshot in this payload. + // Do not re-read mutable base.sha; bind the head and stable merge + // facts, then prove protected-main containment below. + function isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + ) { + return ( + currentPull?.number === pullNumber && + currentPull.state === 'closed' && + currentPull.merged === true && + typeof currentPull.merged_at === 'string' && + currentPull.merged_at.length > 0 && + currentPull.base?.ref === 'main' && + currentPull.head?.sha === headSha && + currentPull.merge_commit_sha === mergeCommitSha + ); + } + + let targetSha; + if (context.eventName === 'repository_dispatch') { + const payload = context.payload.client_payload || {}; + const sourceRunIDText = String(payload.source_run_id || ''); + if (!/^[1-9][0-9]*$/.test(sourceRunIDText)) { + throw new Error('coverage-baseline-repair payload has an invalid source run'); + } + if (payload.source === 'merged_pr') { + const rawPullNumber = String(payload.pull_number || ''); + const pullNumber = Number(rawPullNumber); + const headSha = payload.head_sha; + targetSha = payload.merge_commit_sha; + if ( + !/^[1-9][0-9]*$/.test(rawPullNumber) || + !Number.isSafeInteger(pullNumber) || + !fullCommit.test(headSha || '') || + !fullCommit.test(targetSha || '') + ) { + throw new Error('coverage-baseline-repair payload has an invalid PR identity'); + } + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (!isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + targetSha, + )) { + throw new Error( + `repair payload no longer matches merged PR #${pullNumber}`, + ); + } + } else if (payload.source === 'failed_ci') { + const rawWorkflowRunID = String(payload.workflow_run_id || ''); + const workflowRunID = Number(rawWorkflowRunID); + const rawWorkflowRunAttempt = String(payload.workflow_run_attempt || ''); + const workflowRunAttempt = Number(rawWorkflowRunAttempt); + const workflowConclusion = payload.workflow_conclusion; + targetSha = payload.merge_commit_sha; + if ( + !/^[1-9][0-9]*$/.test(rawWorkflowRunID) || + !Number.isSafeInteger(workflowRunID) || + !/^[1-9][0-9]*$/.test(rawWorkflowRunAttempt) || + !Number.isSafeInteger(workflowRunAttempt) || + typeof workflowConclusion !== 'string' || + workflowConclusion.length === 0 || + workflowConclusion === 'success' || + !fullCommit.test(targetSha || '') + ) { + throw new Error('coverage-baseline-repair payload has an invalid CI identity'); + } + const {data: ciWorkflow} = await github.rest.actions.getWorkflow({ + owner, + repo, + workflow_id: '.github/workflows/ci.yml', + }); + const {data: currentRun} = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: workflowRunID, + }); + if ( + ciWorkflow.name !== 'CI' || + ciWorkflow.path !== '.github/workflows/ci.yml' || + currentRun.id !== workflowRunID || + currentRun.workflow_id !== ciWorkflow.id || + currentRun.name !== 'CI' || + currentRun.event !== 'push' || + currentRun.head_branch !== 'main' || + currentRun.head_sha !== targetSha || + currentRun.run_attempt !== workflowRunAttempt || + currentRun.status !== 'completed' || + currentRun.conclusion !== workflowConclusion || + currentRun.conclusion === 'success' || + currentRun.repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' || + currentRun.head_repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' + ) { + throw new Error( + `repair payload no longer matches failed CI run ${workflowRunID}`, + ); + } + } else { + throw new Error('coverage-baseline-repair payload has an unknown source'); + } + await requireMainContainment(targetSha); + } else { + if (context.ref !== 'refs/heads/main') { + throw new Error('scheduled and manual repair must run from refs/heads/main'); + } + // github.sha is the default-branch tip that keyed this workflow's + // concurrency group. Keep the producer bound to that exact + // event-time target even if main advances while this run queues. + targetSha = context.sha; + if (!fullCommit.test(targetSha || '')) { + throw new Error('protected main did not resolve to one full commit SHA'); + } + await requireMainContainment(targetSha); + } + core.setOutput('target_sha', targetSha); + core.info(`Resolved protected-main coverage repair target ${targetSha}.`); + + - name: Check out exact protected-main target + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.resolve-target.outputs.target_sha }} + + - name: Verify checked-out repair target + env: + TARGET_SHA: ${{ steps.resolve-target.outputs.target_sha }} + run: test "$(git rev-parse HEAD)" = "$TARGET_SHA" + + - name: Set up Go + id: setup-go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + + - name: Restore exact target coverage profile + id: target-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Validate existing exact target profile + if: steps.target-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Install archive tooling for cold repair + if: steps.target-cache.outputs.cache-hit != 'true' + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi + + - name: Recompute complete target coverage profile + if: steps.target-cache.outputs.cache-hit != 'true' + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + go test -count=1 -p 1 \ + -coverprofile=coverage-cache.txt \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Save exact protected-main coverage profile + if: steps.target-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + # Cache uploads are fail-open warnings. A lookup-only restore plus the + # explicit cache-hit assertion makes an absent or partial key fail hard. + - name: Verify exact protected-main coverage cache exists + id: target-cache-verification + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact protected-main coverage cache + env: + EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true diff --git a/.github/workflows/issue-to-dingtalk.yml b/.github/workflows/issue-to-dingtalk.yml index 0bbef4b6..72135f6d 100644 --- a/.github/workflows/issue-to-dingtalk.yml +++ b/.github/workflows/issue-to-dingtalk.yml @@ -14,9 +14,12 @@ jobs: uses: actions/github-script@v7 with: script: | - const webhook = process.env.DINGTALK_WEBHOOK; - if (!webhook) { - console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification'); + const webhooks = [ + process.env.DINGTALK_WEBHOOK, + process.env.DINGTALK_WEBHOOK_SECONDARY + ].filter(Boolean); + if (webhooks.length === 0) { + console.log('⚠️ No DingTalk webhook configured, skipping notification'); return; } @@ -39,12 +42,15 @@ jobs: } }; - await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(message) - }); + await Promise.all(webhooks.map(webhook => + fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message) + }) + )); - console.log('✅ DingTalk notification sent'); + console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`); env: DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} + DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 774495a5..89f38b3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1112,6 +1112,9 @@ jobs: needs: [release-contract, release-validation, release, verify-darwin-signatures] runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }} + coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }} permissions: checks: write contents: write @@ -1495,6 +1498,7 @@ jobs: DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com - name: Seal Formula-only Code Admission contexts + id: seal-formula if: ${{ github.repository_owner == 'DingTalk-Real-AI' }} uses: actions/github-script@v7 env: @@ -1515,6 +1519,8 @@ jobs: const sourcePath = channel === "stable" ? "dist/homebrew/dingtalk-workspace-cli.rb" : "dist/homebrew/dingtalk-workspace-cli-beta.rb"; + core.setOutput("coverage_baseline_required", "false"); + core.setOutput("coverage_baseline_commit", ""); const expectedMessage = channel === "stable" ? `chore: update formula for ${version} [skip ci]` : `chore: update beta formula for ${version} [skip ci]`; @@ -1650,6 +1656,11 @@ jobs: }, }); } + core.setOutput("coverage_baseline_required", "true"); + core.setOutput("coverage_baseline_commit", commit); + core.info( + `Formula-only Code Admission is sealed for ${commit}; the independent confirmation job will dispatch its exact-SHA cache producer.`, + ); - name: Reverify exact immutable npm package run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" @@ -2177,6 +2188,117 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + coverage-baseline-confirmation: + name: Confirm Formula coverage baseline + # Once Formula sealing has exposed a target SHA, later publication + # verification failures must not orphan its exact-main cache producer. + if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }} + needs: publish-release + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + checks: write + contents: write + steps: + - name: Require exact Formula cache acknowledgement + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }} + FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }} + with: + script: | + const rawRequired = process.env.BASELINE_REQUIRED; + if (!['true', 'false'].includes(rawRequired)) { + throw new Error(`Formula baseline requirement is invalid: ${rawRequired || 'empty'}`); + } + const required = rawRequired === 'true'; + const targetSha = process.env.FORMULA_COMMIT; + if (!required) { + if (targetSha) { + throw new Error('Formula baseline outputs are inconsistent for a no-op publication'); + } + core.info('Formula was already current; no new exact-SHA cache acknowledgement is required.'); + return; + } + if (!/^[0-9a-f]{40}$/.test(targetSha)) { + throw new Error('Formula baseline target output is malformed'); + } + const expectedExternalId = `release-${context.runId}-${targetSha}`; + let promotionCheck; + try { + const created = await github.rest.checks.create({ + ...context.repo, + name: 'Coverage Baseline Cache', + head_sha: targetSha, + status: 'queued', + external_id: expectedExternalId, + output: { + title: 'Waiting for exact-SHA baseline promotion', + summary: + 'The independent release governance job is waiting for the default-branch cache producer.', + }, + }); + promotionCheck = created.data; + await github.rest.repos.createDispatchEvent({ + ...context.repo, + event_type: 'coverage-baseline-promote', + client_payload: { + target_sha: targetSha, + source_run_id: String(context.runId), + check_run_id: String(promotionCheck.id), + }, + }); + } catch (error) { + if (promotionCheck) { + try { + await github.rest.checks.update({ + ...context.repo, + check_run_id: promotionCheck.id, + status: 'completed', + conclusion: 'failure', + completed_at: new Date().toISOString(), + output: { + title: 'Coverage baseline dispatch failed', + summary: `Release could not dispatch the exact-SHA producer: ${error.message}`, + }, + }); + } catch (cleanupError) { + core.error( + `Could not close failed cache acknowledgement ${promotionCheck.id}: ${cleanupError.message}`, + ); + } + } + throw error; + } + const checkRunId = promotionCheck.id; + for (let attempt = 1; attempt <= 180; attempt += 1) { + const {data: currentCheck} = await github.rest.checks.get({ + ...context.repo, + check_run_id: checkRunId, + }); + if ( + currentCheck.head_sha !== targetSha || + currentCheck.name !== 'Coverage Baseline Cache' || + currentCheck.external_id !== expectedExternalId || + currentCheck.app?.slug !== 'github-actions' + ) { + throw new Error('Formula baseline promotion acknowledgement changed identity'); + } + if (currentCheck.status === 'completed') { + if (currentCheck.conclusion !== 'success') { + throw new Error( + `Formula baseline promotion failed with ${currentCheck.conclusion || 'unknown'}`, + ); + } + core.info(`Formula baseline promotion completed for ${targetSha}.`); + return; + } + if (attempt < 180) { + await new Promise(resolve => setTimeout(resolve, 10000)); + } + } + throw new Error(`Formula baseline promotion timed out for ${targetSha}`); + release-delivery-gate: name: Release delivery gate if: ${{ !cancelled() }} @@ -2189,6 +2311,7 @@ jobs: - verify-darwin-signatures - publish-release - publish-channels + - coverage-baseline-confirmation - mirror-gitee-release - repair-npm - repair-channel @@ -2211,6 +2334,7 @@ jobs: DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }} PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }} PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }} + COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }} MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }} @@ -2234,6 +2358,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success require_result publish-release "$PUBLISH_RELEASE_RESULT" success require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success if test "$GITEE_FALLBACK_ENABLED" = true; then require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success else @@ -2273,6 +2398,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped @@ -2294,6 +2420,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped @@ -2309,6 +2436,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped require_cloud_jobs_skipped @@ -2323,6 +2451,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_cloud_jobs_skipped @@ -2337,6 +2466,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_cloud_jobs_skipped diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index 105ac713..0b7988f5 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -3,21 +3,24 @@ name: Reviewer routing on: pull_request_target: branches: [main] - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled] + push: + branches: [main] + workflow_dispatch: # pull_request_target deliberately runs only this workflow from the protected # base branch. Never check out or execute pull-request code here. permissions: - contents: write + contents: read pull-requests: write concurrency: - group: reviewer-router-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: reviewer-router-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || 'reconcile-main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} jobs: route: - if: github.event.pull_request.draft == false + if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -26,7 +29,7 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - - name: Route review and enable auto-merge + - name: Route review uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | @@ -34,6 +37,7 @@ jobs: const repo = context.repo.repo; const pullNumber = context.payload.pull_request.number; const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; const { REVIEWER_POOL, requestReviewersWithFallback, @@ -50,6 +54,7 @@ jobs: }); if ( currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || currentPull.state !== 'open' || currentPull.draft || currentPull.base.ref !== 'main' @@ -256,41 +261,6 @@ jobs: } } - async function enableAutoMerge() { - try { - const currentPull = await getReadyEventPull('auto-merge enable'); - if (!currentPull) { - return; - } - if (currentPull.auto_merge) { - core.info(`Auto-merge is already enabled for PR #${pullNumber}.`); - return; - } - await github.graphql( - `mutation EnableAutoMerge($pullRequestId: ID!) { - enablePullRequestAutoMerge( - input: { - pullRequestId: $pullRequestId - mergeMethod: MERGE - } - ) { - pullRequest { - autoMergeRequest { - enabledAt - } - } - } - }`, - {pullRequestId: currentPull.node_id}, - ); - core.info(`Enabled native auto-merge for PR #${pullNumber}.`); - } catch (error) { - core.warning( - `Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`, - ); - } - } - try { await routeReview(); } catch (error) { @@ -298,4 +268,930 @@ jobs: `Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`, ); } - await enableAutoMerge(); + manage-auto-merge: + if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + # Contents write is isolated to the trusted cleanup job because GitHub + # treats auto-merge management as merge authority. Only the App token is + # ever used to enable auto-merge. + permissions: + contents: write + pull-requests: write + steps: + # Auto-merge must use a dedicated identity. GitHub suppresses most + # workflow events created by the built-in GITHUB_TOKEN, including the + # main push that produces post-merge admission and baseline caches. + # Clear the known-unsafe owner and any workflow-skipping merge request + # before touching App credentials. Missing credentials then fail closed + # with manual merge intact. + - name: Clear unsafe auto-merge request + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullNumber = context.payload.pull_request.number; + const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; + const unsafeOwner = 'github-actions[bot]'; + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' + ) { + core.info( + `PR #${pullNumber} state or revision no longer matches this ready-main event; unsafe-owner cleanup stopped.`, + ); + return; + } + + const enabledBy = currentPull.auto_merge?.enabled_by?.login?.toLowerCase(); + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + const skipRequested = mergeTexts.some(value => + skipWorkflowPattern.test(value), + ); + if (!currentPull.auto_merge || (enabledBy !== unsafeOwner && !skipRequested)) { + core.info( + currentPull.auto_merge + ? `PR #${pullNumber} auto-merge is owned by ${enabledBy || 'an unknown actor'}; preserving it.` + : `PR #${pullNumber} has no auto-merge request to clear.`, + ); + return; + } + + await github.graphql( + `mutation DisableAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { + id + } + } + }`, + {pullRequestId: currentPull.node_id}, + ); + const {data: cleanedPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + const cleanedOwner = cleanedPull.auto_merge?.enabled_by?.login?.toLowerCase(); + if ( + cleanedPull.head.sha === eventHeadSha && + cleanedPull.base.sha === eventBaseSha && + cleanedPull.state === 'open' && + !cleanedPull.draft && + cleanedPull.base.ref === 'main' && + cleanedPull.auto_merge + ) { + throw new Error( + `PR #${pullNumber} still has auto-merge owned by ${cleanedOwner || 'an unknown actor'} after unsafe-request cleanup.`, + ); + } + core.info( + skipRequested + ? `Cleared workflow-skipping auto-merge metadata from PR #${pullNumber}.` + : `Cleared built-in GITHUB_TOKEN auto-merge ownership from PR #${pullNumber}.`, + ); + - name: Mint Reviewer Router GitHub App token + id: reviewer-router-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }} + private-key: ${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + - name: Enable auto-merge with dedicated App + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + MINTED_REVIEWER_ROUTER_APP_SLUG: ${{ steps.reviewer-router-token.outputs.app-slug }} + REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }} + with: + github-token: ${{ steps.reviewer-router-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullNumber = context.payload.pull_request.number; + const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; + const mintedAppSlug = process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim(); + const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim(); + if ( + !appSlug || + appSlug !== appSlug.toLowerCase() || + appSlug === 'github-actions' || + mintedAppSlug !== appSlug + ) { + throw new Error( + 'Minted Reviewer Router App slug does not exactly match the reviewed repository variable.', + ); + } + const expectedAppOwner = `${appSlug}[bot]`; + const {data: repository} = await github.rest.repos.get({owner, repo}); + function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; + } + const mergeDefaultsProjection = classifyMergeDefaults(repository); + if (mergeDefaultsProjection !== 'reviewed') { + throw new Error( + 'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.', + ); + } + const repositorySource = `${owner}/${repo}`.toLowerCase(); + const appliedRules = await github.paginate( + 'GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: 'main', per_page: 100}, + ); + const applicableRulesetIDs = [ + ...new Set( + appliedRules + .map(rule => Number(rule.ruleset_id)) + .filter(rulesetID => + Number.isSafeInteger(rulesetID) && rulesetID > 0, + ), + ), + ]; + const activeMainRulesets = []; + for (const rulesetID of applicableRulesetIDs) { + const {data: ruleset} = await github.request( + 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', + {owner, repo, ruleset_id: rulesetID}, + ); + if ( + ruleset.enforcement !== 'active' || + ruleset.target !== 'branch' + ) { + throw new Error( + `Applicable ruleset ${ruleset.name || rulesetID} is not an active branch ruleset.`, + ); + } + activeMainRulesets.push(ruleset); + } + const writerRulesets = activeMainRulesets.filter( + ruleset => ruleset.name === 'main-merge-writers', + ); + if (writerRulesets.length !== 1) { + throw new Error( + 'Expected exactly one active main-merge-writers ruleset on main.', + ); + } + const writerRuleset = writerRulesets[0]; + const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; + const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + // GitHub's read projection omits the entire parameters property + // when this exception is disabled. Accept only that exact omission + // or a one-field object containing exact false. + function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); + return ( + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); + } + function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false + ); + } + if ( + writerRuleset.source_type !== 'Repository' || + writerRuleset.source?.toLowerCase() !== repositorySource || + writerIncludes.length !== 1 || + writerIncludes[0] !== 'refs/heads/main' || + writerExcludes.length !== 0 || + typeof writerRuleset.node_id !== 'string' || + !writerRuleset.node_id || + writerRuleset.rules?.length !== 1 || + !isStrictUpdateRule(writerRuleset.rules[0]) || + writerRuleset.current_user_can_bypass !== 'pull_requests_only' + ) { + throw new Error( + 'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.', + ); + } + const {node: graphWriterRuleset} = await github.graphql( + `query ReviewerRouterWriterRule($rulesetID: ID!) { + node(id: $rulesetID) { + ... on RepositoryRuleset { + databaseId + name + enforcement + target + rules(first: 2) { + totalCount + nodes { + type + parameters { + __typename + ... on UpdateParameters { + updateAllowsFetchAndMerge + } + } + } + } + } + } + }`, + {rulesetID: writerRuleset.node_id}, + ); + if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) { + throw new Error( + 'Reviewer Router App requires GraphQL to expose one strict UPDATE rule with updateAllowsFetchAndMerge=false.', + ); + } + for (const ruleset of activeMainRulesets) { + if (ruleset.id === writerRuleset.id) { + continue; + } + if (ruleset.current_user_can_bypass !== 'never') { + throw new Error( + `Reviewer Router App must never bypass main ruleset ${ruleset.name || ruleset.id}.`, + ); + } + } + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + const safeCommitHeadline = `Merge pull request #${pullNumber}`; + const safeCommitBody = + `Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`; + + async function disableAutoMerge(pull, phase) { + await github.graphql( + `mutation DisableAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { id } + } + }`, + {pullRequestId: pull.node_id}, + ); + const {data: revertedPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (revertedPull.auto_merge) { + throw new Error( + `PR #${pullNumber} retained auto-merge owned by ${revertedPull.auto_merge.enabled_by?.login || 'an unknown actor'} after ${phase}.`, + ); + } + return revertedPull; + } + + async function getReadyEventPull(phase) { + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' + ) { + core.info( + `PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; auto-merge stopped.`, + ); + return null; + } + return currentPull; + } + + let currentPull = await getReadyEventPull('auto-merge enable'); + if (!currentPull) { + return; + } + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + if (mergeTexts.some(value => skipWorkflowPattern.test(value))) { + if (currentPull.auto_merge) { + await disableAutoMerge(currentPull, 'workflow-skip cleanup'); + } + throw new Error( + `PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`, + ); + } + if (currentPull.auto_merge) { + const existingOwner = currentPull.auto_merge.enabled_by?.login?.toLowerCase(); + if ( + existingOwner === expectedAppOwner && + currentPull.auto_merge.commit_title === safeCommitHeadline && + currentPull.auto_merge.commit_message === safeCommitBody + ) { + core.info( + `PR #${pullNumber} already has the reviewed ${expectedAppOwner} auto-merge request; preserving it.`, + ); + return; + } + await disableAutoMerge( + currentPull, + existingOwner === expectedAppOwner + ? 'unsafe dedicated-App metadata cleanup' + : 'non-dedicated owner cleanup', + ); + currentPull = await getReadyEventPull('post-owner cleanup'); + if (!currentPull) { + return; + } + if (currentPull.auto_merge) { + throw new Error( + `PR #${pullNumber} acquired another auto-merge owner before dedicated-App enable.`, + ); + } + } + await github.graphql( + `mutation EnableAutoMerge( + $pullRequestId: ID! + $expectedHeadOid: GitObjectID! + $commitHeadline: String! + $commitBody: String! + ) { + enablePullRequestAutoMerge( + input: { + pullRequestId: $pullRequestId + mergeMethod: MERGE + expectedHeadOid: $expectedHeadOid + commitHeadline: $commitHeadline + commitBody: $commitBody + } + ) { + pullRequest { + autoMergeRequest { + enabledAt + } + } + } + }`, + { + pullRequestId: currentPull.node_id, + expectedHeadOid: eventHeadSha, + commitHeadline: safeCommitHeadline, + commitBody: safeCommitBody, + }, + ); + const {data: enabledPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + const enabledBy = enabledPull?.auto_merge?.enabled_by?.login?.toLowerCase(); + const exactRevision = + enabledPull.head.sha === eventHeadSha && + enabledPull.base.sha === eventBaseSha && + enabledPull.state === 'open' && + !enabledPull.draft && + enabledPull.base.ref === 'main'; + if (!exactRevision && enabledBy === expectedAppOwner) { + await disableAutoMerge(enabledPull, 'stale-enable cleanup'); + } + if (!exactRevision) { + throw new Error( + `PR #${pullNumber} state or revision changed during dedicated-App enable.`, + ); + } + if (enabledBy !== expectedAppOwner) { + if (enabledPull.auto_merge) { + await disableAutoMerge(enabledPull, 'unexpected owner cleanup'); + } + throw new Error( + `PR #${pullNumber} auto-merge owner ${enabledBy || 'unknown'} does not match ${expectedAppOwner}.`, + ); + } + if ( + enabledPull.auto_merge?.commit_title !== safeCommitHeadline || + enabledPull.auto_merge?.commit_message !== safeCommitBody + ) { + await disableAutoMerge(enabledPull, 'unsafe-merge-message cleanup'); + throw new Error( + `PR #${pullNumber} dedicated-App merge metadata was not preserved exactly.`, + ); + } + core.info( + `Enabled native auto-merge for PR #${pullNumber} with ${enabledBy}.`, + ); + + # Existing ready PRs do not emit a fresh routing event when this workflow is + # deployed. The protected-main push that deploys it migrates every non-App + # request, repairs App requests with unreviewed metadata, and clears + # workflow-skipping merge metadata; workflow_dispatch provides an idempotent + # recovery path. Null and exact safe App requests remain untouched. + reconcile: + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Mint Reviewer Router GitHub App token + id: reviewer-router-reconcile-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }} + private-key: ${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + - name: Reconcile unsafe auto-merge requests + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + MINTED_REVIEWER_ROUTER_APP_SLUG: ${{ steps.reviewer-router-reconcile-token.outputs.app-slug }} + REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }} + with: + github-token: ${{ steps.reviewer-router-reconcile-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const mintedAppSlug = process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim(); + const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim(); + if ( + !appSlug || + appSlug !== appSlug.toLowerCase() || + appSlug === 'github-actions' || + mintedAppSlug !== appSlug + ) { + throw new Error( + 'Minted Reviewer Router App slug does not exactly match the reviewed repository variable.', + ); + } + const expectedAppOwner = `${appSlug}[bot]`; + const {data: repository} = await github.rest.repos.get({owner, repo}); + function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; + } + const mergeDefaultsProjection = classifyMergeDefaults(repository); + if (mergeDefaultsProjection !== 'reviewed') { + throw new Error( + 'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.', + ); + } + const repositorySource = `${owner}/${repo}`.toLowerCase(); + const appliedRules = await github.paginate( + 'GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: 'main', per_page: 100}, + ); + const applicableRulesetIDs = [ + ...new Set( + appliedRules + .map(rule => Number(rule.ruleset_id)) + .filter(rulesetID => + Number.isSafeInteger(rulesetID) && rulesetID > 0, + ), + ), + ]; + const activeMainRulesets = []; + for (const rulesetID of applicableRulesetIDs) { + const {data: ruleset} = await github.request( + 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', + {owner, repo, ruleset_id: rulesetID}, + ); + if ( + ruleset.enforcement !== 'active' || + ruleset.target !== 'branch' + ) { + throw new Error( + `Applicable ruleset ${ruleset.name || rulesetID} is not an active branch ruleset.`, + ); + } + activeMainRulesets.push(ruleset); + } + const writerRulesets = activeMainRulesets.filter( + ruleset => ruleset.name === 'main-merge-writers', + ); + if (writerRulesets.length !== 1) { + throw new Error( + 'Expected exactly one active main-merge-writers ruleset on main.', + ); + } + const writerRuleset = writerRulesets[0]; + const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; + const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + // GitHub's read projection omits the entire parameters property + // when this exception is disabled. Accept only that exact omission + // or a one-field object containing exact false. + function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); + return ( + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); + } + function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false + ); + } + if ( + writerRuleset.source_type !== 'Repository' || + writerRuleset.source?.toLowerCase() !== repositorySource || + writerIncludes.length !== 1 || + writerIncludes[0] !== 'refs/heads/main' || + writerExcludes.length !== 0 || + typeof writerRuleset.node_id !== 'string' || + !writerRuleset.node_id || + writerRuleset.rules?.length !== 1 || + !isStrictUpdateRule(writerRuleset.rules[0]) || + writerRuleset.current_user_can_bypass !== 'pull_requests_only' + ) { + throw new Error( + 'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.', + ); + } + const {node: graphWriterRuleset} = await github.graphql( + `query ReviewerRouterWriterRule($rulesetID: ID!) { + node(id: $rulesetID) { + ... on RepositoryRuleset { + databaseId + name + enforcement + target + rules(first: 2) { + totalCount + nodes { + type + parameters { + __typename + ... on UpdateParameters { + updateAllowsFetchAndMerge + } + } + } + } + } + } + }`, + {rulesetID: writerRuleset.node_id}, + ); + if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) { + throw new Error( + 'Reviewer Router App requires GraphQL to expose one strict UPDATE rule with updateAllowsFetchAndMerge=false.', + ); + } + for (const ruleset of activeMainRulesets) { + if (ruleset.id === writerRuleset.id) { + continue; + } + if (ruleset.current_user_can_bypass !== 'never') { + throw new Error( + `Reviewer Router App must never bypass main ruleset ${ruleset.name || ruleset.id}.`, + ); + } + } + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + const openPulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + base: 'main', + per_page: 100, + }); + let migrated = 0; + let skipped = 0; + const failures = []; + + async function disableAutoMerge(pullNumber, pullRequestId, phase) { + await github.graphql( + `mutation DisableAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { id } + } + }`, + {pullRequestId}, + ); + const {data: revertedPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (revertedPull.auto_merge) { + throw new Error( + `auto-merge owned by ${revertedPull.auto_merge.enabled_by?.login || 'an unknown actor'} survived ${phase}`, + ); + } + return revertedPull; + } + + for (const candidate of openPulls) { + const safeCommitHeadline = `Merge pull request #${candidate.number}`; + const safeCommitBody = + `Merged by the dedicated Reviewer Router GitHub App for PR #${candidate.number}.`; + const candidateOwner = + candidate.auto_merge?.enabled_by?.login?.toLowerCase(); + const candidateMergeTexts = [ + candidate.title, + candidate.auto_merge?.commit_title, + candidate.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + const candidateSkipRequested = candidateMergeTexts.some(value => + skipWorkflowPattern.test(value), + ); + const candidateHasSafeAppRequest = + candidateOwner === expectedAppOwner && + candidate.auto_merge?.commit_title === safeCommitHeadline && + candidate.auto_merge?.commit_message === safeCommitBody && + !candidateSkipRequested; + if ( + candidate.draft || + candidate.base.ref !== 'main' || + !candidate.auto_merge || + candidateHasSafeAppRequest + ) { + skipped += 1; + continue; + } + let stage = 'preflight'; + try { + const expectedHeadSha = candidate.head.sha; + const expectedBaseSha = candidate.base.sha; + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + }); + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + const skipRequested = mergeTexts.some(value => + skipWorkflowPattern.test(value), + ); + const currentOwner = + currentPull.auto_merge?.enabled_by?.login?.toLowerCase(); + const currentHasSafeAppRequest = + currentOwner === expectedAppOwner && + currentPull.auto_merge?.commit_title === safeCommitHeadline && + currentPull.auto_merge?.commit_message === safeCommitBody && + !skipRequested; + if ( + currentPull.head.sha !== expectedHeadSha || + currentPull.base.sha !== expectedBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' || + !currentPull.auto_merge || + currentHasSafeAppRequest + ) { + skipped += 1; + core.info(`PR #${candidate.number} changed before reconciliation; skipped.`); + continue; + } + + stage = skipRequested + ? 'disable workflow-skipping auto-merge request' + : currentOwner === expectedAppOwner + ? 'repair dedicated-App merge metadata' + : 'replace non-dedicated auto-merge owner'; + const disabledPull = await disableAutoMerge( + candidate.number, + currentPull.node_id, + stage, + ); + stage = 'post-disable validation'; + if ( + disabledPull.head.sha !== expectedHeadSha || + disabledPull.base.sha !== expectedBaseSha || + disabledPull.state !== 'open' || + disabledPull.draft || + disabledPull.base.ref !== 'main' + ) { + throw new Error( + 'revision changed after the unsafe owner was disabled; left manual-merge only', + ); + } + if (disabledPull.auto_merge) { + throw new Error('another auto-merge owner appeared after disable'); + } + if (skipRequested) { + throw new Error( + 'workflow-skip directive found in merge metadata; left manual-merge only', + ); + } + + stage = 'dedicated-App enable'; + await github.graphql( + `mutation EnableAutoMerge( + $pullRequestId: ID! + $expectedHeadOid: GitObjectID! + $commitHeadline: String! + $commitBody: String! + ) { + enablePullRequestAutoMerge( + input: { + pullRequestId: $pullRequestId + mergeMethod: MERGE + expectedHeadOid: $expectedHeadOid + commitHeadline: $commitHeadline + commitBody: $commitBody + } + ) { + pullRequest { id } + } + }`, + { + pullRequestId: disabledPull.node_id, + expectedHeadOid: expectedHeadSha, + commitHeadline: safeCommitHeadline, + commitBody: safeCommitBody, + }, + ); + stage = 'post-enable validation'; + const {data: enabledPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + }); + const enabledBy = enabledPull.auto_merge?.enabled_by?.login?.toLowerCase(); + const revisionChanged = + enabledPull.head.sha !== expectedHeadSha || + enabledPull.base.sha !== expectedBaseSha || + enabledPull.state !== 'open' || + enabledPull.draft || + enabledPull.base.ref !== 'main'; + if (revisionChanged && enabledBy === expectedAppOwner) { + await disableAutoMerge( + candidate.number, + enabledPull.node_id, + 'stale-enable cleanup', + ); + } + if (revisionChanged) { + throw new Error('revision changed during dedicated-App enable'); + } + if (enabledBy !== expectedAppOwner) { + if (enabledPull.auto_merge) { + await disableAutoMerge( + candidate.number, + enabledPull.node_id, + 'unexpected owner cleanup', + ); + } + throw new Error( + `auto-merge owner ${enabledBy || 'unknown'} does not match ${expectedAppOwner}`, + ); + } + if ( + enabledPull.auto_merge?.commit_title !== safeCommitHeadline || + enabledPull.auto_merge?.commit_message !== safeCommitBody + ) { + await disableAutoMerge( + candidate.number, + enabledPull.node_id, + 'unsafe merge-message cleanup', + ); + throw new Error('dedicated-App merge metadata was not preserved exactly'); + } + migrated += 1; + core.info(`Migrated PR #${candidate.number} auto-merge to ${enabledBy}.`); + } catch (error) { + const failure = `PR #${candidate.number} failed during ${stage}: ${error.message}`; + failures.push(failure); + core.error(failure); + } + } + core.info( + `Reconciliation summary: migrated=${migrated}, skipped=${skipped}, failed=${failures.length}.`, + ); + if (failures.length > 0) { + core.setFailed( + `Legacy auto-merge reconciliation had ${failures.length} failure(s): ${failures.join('; ')}`, + ); + } diff --git a/AGENTS.md b/AGENTS.md index 98f247fd..a8a2a21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with ## Command framework declaration - Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0** -- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New` +- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New` +- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves - **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`) - Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`. - `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final. @@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with - **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**) - **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed) - Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind. +- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface. - Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority. - **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority - Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared diff --git a/CHANGELOG.md b/CHANGELOG.md index 16dddbec..145012a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.60-beta.2] - 2026-08-24 + +### Added + +- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node ` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call. + +- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification. +- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads. + +- **Permission and member list pagination** (#1085) — `drive/doc permission + list` and `wiki member list` now accept `--next-token` to follow the + server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and + map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults + 200` path; `permission add/update/remove` and `wiki member add/update/remove` + additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG + grantee types. The optional `--notify` defaults to `false` and is omitted from + the server request unless passed explicitly, so member grants no longer notify + recipients by default. These commands also declare cursor pagination + (`next-token`) in the Agent schema contract, mirroring the internal CLI parity + change. Because a single batch remove can revoke access for up to 30 + USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role + groups can indirectly affect many more users — `drive/doc permission + remove` and `wiki member remove` now declare + `confirmation=user_required` and gate the actual tool call behind user + confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their + confirmation-gate failure now also passes through verbatim instead of being + reclassified as a permission-denied or unclassified error. + +- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination. + +- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide. + +- **Doc public-link and historical-version reads** — `dws doc read` forwards + the reviewed `password` (internet-public documents with password protection) + and `historyVersion` (read content as of a listed historical version; `0` + denotes the document's initial version) parameters on the markdown, JSONML, + and scope read paths via `--password` / `--version`; `dws doc +fetch` gains + `--password` and `--version` with the same `historyVersion` forwarding, while + `--revision` stays rejected with explicit guidance: revision is the document + edit revision returned by JSONML reads for `+update --expected-revision` + conditional writes, not a historical version number. + +- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`. + +- **OA approval attachment upload** — `dws oa approval attachment upload --file ` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted. + +- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets. + +- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow. + +### Changed + +- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts. + +- **Permission error guidance and error rendering** (#1085) — + permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code + instead of a generic business-error rendering; document/wiki-specific errors + (the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`, + or the role-threshold wording like + “需要您具备 MANAGER 及以上角色”) carry apply-permission guidance + (`dws drive permission apply-info` / `dws drive permission apply`), while + permission failures carrying only generic code names (`FORBIDDEN`, + `NO_PERMISSION` — also returned by attendance and event-subscription tools) + or other products' wording keep their product-specific or + product-neutral suggestion instead of a misleading document-permission hint; + member-validation failures such as + “用户不存在/不属于当前组织” are classified as tool errors with a + `--members`-with-`corpId` suggestion instead of a misleading + resource-not-found error; business error output now surfaces the backend + message with `code`/`logId` appended for traceability; and the + `update_permission` / `remove_permission` / `update_member` / + `remove_member` tools — whose servers return a literal `null` on successful + no-payload writes — now render `{}` so downstream JSON consumers do not fail + parsing `null`; other tools keep raw `null` output unchanged. + +### Fixed + +- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations. + + ## [1.0.60-beta.1] - 2026-08-21 ### Changed diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 28413205..2a651910 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -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.60-beta.1" + version "1.0.60-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.60-beta.1/dws-darwin-arm64.tar.gz" - sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz" + sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz" - sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz" + sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz" - sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz" + sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz" - sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz" + sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip" - sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip" + sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5" end def install diff --git a/Makefile b/Makefile index 73913b28..98424658 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -20,6 +20,7 @@ help: @printf " make test - Run the Go test suite\n" @printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n" @printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n" + @printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n" @printf " make lint - Run formatting checks, go vet, and staticcheck\n" @printf " make format-check - Check all repository Go source files with gofmt\n" @printf " make fmt - Format all repository Go source files\n" @@ -62,6 +63,9 @@ test-auth-legacy-compat: @mkdir -p "$(POLICY_GOTMPDIR)" @GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh +shortcut-public-e2e-proof: build + @GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh + lint: @./scripts/dev/lint.sh @@ -84,7 +88,7 @@ fmt: $(GO_SOURCE_LIST) > "$$go_files"; \ xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files" -policy: test-auth-legacy-compat +policy: test-auth-legacy-compat shortcut-public-e2e-proof @mkdir -p "$(POLICY_GOTMPDIR)" @$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh @$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh diff --git a/build/npm/bin/dws.js b/build/npm/bin/dws.js index 8b2b703d..3973dcf0 100755 --- a/build/npm/bin/dws.js +++ b/build/npm/bin/dws.js @@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) { process.exit(1); } -const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), { +// Interactive commands must remain in the terminal's foreground session so +// prompts can use /dev/tty. Non-interactive launches use a separate process +// group, allowing a signal sent only to this wrapper to reach the full vendor +// process tree exactly once. +const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY; + +const child = childProcess.spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", + detached: isolateVendorProcessGroup, }); -if (result.error) { - console.error(result.error.message); - process.exit(1); +let spawnFailed = false; +let forwardedSignal = null; +const forwardedSignals = ["SIGINT", "SIGTERM"]; + +function forwardSignal(signal) { + forwardedSignal = signal; + if (child.exitCode === null && child.signalCode === null) { + if (process.platform === "win32") { + child.kill(signal); + return; + } + if (!isolateVendorProcessGroup) { + // Ctrl-C is generated for the whole foreground process group, including + // the vendor. SIGTERM is not terminal-generated and still needs an + // explicit handoff when a process manager targets only this wrapper. + if (signal === "SIGTERM") { + child.kill(signal); + } + return; + } + try { + // detached makes the vendor PID the leader of its POSIX process group. + // Signal the whole group so any subprocesses inherit the same shutdown. + process.kill(-child.pid, signal); + } catch (error) { + // The group may have completed between the state check and kill. + if (error.code !== "ESRCH") { + throw error; + } + } + } } -process.exit(result.status === null ? 1 : result.status); +const signalHandlers = new Map( + forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]), +); +for (const signal of forwardedSignals) { + process.on(signal, signalHandlers.get(signal)); +} + +child.on("error", (error) => { + spawnFailed = true; + console.error(error.message); +}); + +child.on("close", (code, signal) => { + for (const forwarded of forwardedSignals) { + process.removeListener(forwarded, signalHandlers.get(forwarded)); + } + + const exitSignal = forwardedSignal || signal; + if (exitSignal && process.platform !== "win32") { + process.kill(process.pid, exitSignal); + return; + } + process.exitCode = spawnFailed || code === null ? 1 : code; +}); diff --git a/docs/automation.md b/docs/automation.md index b2d5de21..87f0c911 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -62,9 +62,124 @@ make lint git diff --check ``` +## Reviewer Router GitHub App + +Reviewer requests and native auto-merge intentionally use different +identities. The base-owned `pull_request_target` workflow may use its built-in +`GITHUB_TOKEN` to request reviewers, but it must mint a dedicated GitHub App +installation token before enabling auto-merge. GitHub suppresses most workflow +events created by the built-in token; using it for auto-merge prevents the +merge commit's `push` workflows from running and leaves the exact-SHA Coverage +baseline without a trusted main-scoped producer. + +Configure the dedicated App before merging a workflow revision that requires +it: + +- install it only on `DingTalk-Real-AI/dingtalk-workspace-cli`; +- grant only `Contents: read and write` and `Pull requests: read and write`; +- set repository variable `REVIEWER_ROUTER_APP_CLIENT_ID` to its client ID; +- set `REVIEWER_ROUTER_APP_SLUG` to its exact lowercase slug; +- set repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY` to its private key; +- create one active repository branch ruleset named `main-merge-writers`, + targeting only `refs/heads/main`, with exactly one `Restrict updates` rule + (`update_allows_fetch_and_merge: false`). GitHub may project that strict + value through the read APIs as `{type: "update"}` with `parameters` omitted; + consumers accept only that exact omission or a one-field `parameters` object + containing explicit boolean `false`, and reject every other present shape or + value. They then bind the same ruleset node through GraphQL and require its + non-null `updateAllowsFetchAndMerge` value to be exactly `false`; +- give that ruleset exactly two bypass actors: the Reviewer Router App as an + `Integration` in `pull_request` mode, and `PeterGuy326` (ID `47820304`) in + `always` mode for Formula publication and break-glass recovery; +- never give the App bypass on `main-protection`, `main-quality`, or any other + ruleset, and never reuse `HOMEBREW_PR_TOKEN`, + `RELEASE_GOVERNANCE_TOKEN`, or a personal token for Reviewer Router. + +The workflow limits each minted token to the current repository, requests the +two permissions explicitly, and lets the token action revoke it at job end. +It also requires the minted App slug to equal the reviewed repository variable; +there is no `GITHUB_TOKEN` fallback. Before reading App credentials, the +base-owned workflow revalidates the event's exact base/head and uses its +built-in token only to disable an existing request owned by +`github-actions[bot]` or one whose title or merge metadata requests that GitHub +skip workflows. A mint or permission failure therefore leaves that PR +manual-merge only. The built-in token's `Contents: write` permission is +isolated to this trusted cleanup job and is never used to enable auto-merge; +review routing keeps `Contents: read`. Existing requests owned by a human or +another non-built-in identity are replaced with the exact dedicated-App +request after token minting. Only an already App-owned request with the fixed +headline/body is preserved. The required `Test` context reads the live +repository settings and applied rulesets, verifies the exact writer-rule +shape, and requires its own built-in Actions identity to report +`current_user_can_bypass: never`. Before enabling or reconciling auto-merge, +the minted App independently requires `pull_requests_only` on that writer rule +and `never` on every other active main ruleset. These identity-relative checks +remain available to low-privilege tokens; GitHub deliberately hides the full +`bypass_actors` list from callers without ruleset-write access. Operators must +therefore inspect that list during rollout and keep it at the exact two actors +above. The required `Test` context then briefly waits for the concurrent +router takeover and accepts only a null request or the configured App owner +with exact fixed metadata. A null request is safe for this failure mode because +the built-in Actions identity cannot pass the writer rule; other permitted +identities emit either a protected-main push or the trusted closed-PR repair. +Draft PRs skip this identity check; the explicit `ready_for_review` trigger +reruns admission when they become merge-eligible, +while `edited` and `auto_merge_enabled` rerun both workflows when the PR title +or merge request changes. A human `auto_merge_disabled` event reruns CI without +silently re-enabling the request, leaving it available only to the designated +break-glass identity. The required `Test` context rejects GitHub workflow-skip +directives in the PR title or an existing auto-merge request and verifies the +repository's reviewed `MERGE_MESSAGE` title plus `PR_TITLE` or `BLANK` body +defaults. GitHub does not expose those merge-related settings to the read-only +admission token: the classifier accepts only both exact reviewed values or the +complete omission of both properties, and rejects partial omission, `null`, or +any other value. Before any enable or reconcile mutation, the dedicated App's +current-repository token (which has `Contents: write`) must observe both exact +reviewed values. The dedicated App binds the mutation to the exact head OID and +supplies a fixed safe headline and body, so GitHub cannot copy an unsafe PR +title into its merge commit. +After enabling, the workflow requires the owner to equal the token action's +exact `[bot]` output. If the event base/head changes during the +mutation window, it removes only that App-owned request and fails the run. +The break-glass publisher must preserve a safe final commit message; +`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, +`[actions skip]`, and a `skip-checks: true` trailer are forbidden outside the +release-controlled Formula-only path below. + +GitHub may suppress `pull_request_target` entirely for security-sensitive head +branch names, including names that look like commit SHAs. Such a PR receives +neither App takeover nor the closed-event repair. Rename the head branch for +the normal path; if break-glass merge is unavoidable, preserve a safe final +message so the protected-main push CI remains the authoritative producer. + +After installing the App, the protected-main push that deploys this workflow +runs reconciliation automatically. The job enumerates open, ready `main` PRs +with any non-App owner, unsafe App metadata, or workflow-skip metadata. It +revalidates each base/head, converges a safe request to the exact dedicated-App +owner and fixed message, and leaves a workflow-skipping request disabled for +manual correction. It never enables auto-merge where the request was already +null. A mid-migration failure leaves the affected PR disabled for a fresh +routing event or break-glass merge. One PR failure is recorded +without preventing later legacy owners from being attempted; the batch ends +red with a per-PR summary. Manually dispatch `Reviewer routing` from `main` +until the failed count is zero. + +A PR that introduces or rotates this identity still runs the old base-owned +router. Install/configure the App and activate the exact writer ruleset first; +this blocks its legacy `github-actions[bot]` request from writing `main`. After +the governance PR's final push, disable that old request, confirm the live +settings/ruleset contract and all required checks are green for the exact head, +then have only `PeterGuy326` merge that head with the repository-generated safe +merge message. Verify the resulting merge SHA has a `CI` run with `event=push`, +a successful `Coverage` context, and an exact-SHA baseline cache under +`refs/heads/main`. Confirm automatic reconciliation reports zero failures and +zero non-App owners. Finally use a normal canary PR to verify that the dedicated +App is both `enabledBy` and `mergedBy`, and that the same post-merge chain +repeats before declaring the rollout complete. + ## Homebrew Formula Delivery -Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update +Official releases use the designated `HOMEBREW_PR_TOKEN` identity to update exactly one tracked Formula after the immutable GitHub assets and their checksums have passed verification. The publisher validates the rendered Ruby, commits only the configured Formula path, never force-pushes `main`, and retries @@ -72,11 +187,60 @@ from a fresh clone up to three times when `main` advances concurrently. Normal stable and beta releases do not create a Formula PR or run a permission canary. The workflow uses the existing repository-scoped `HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in -Actions App to bypass this repository's rulesets. That identity is the sole -user bypass actor on the two default-branch rulesets. The workflow creates the +Actions App to bypass this repository's rulesets. Its owner is the designated +always-bypass actor for controlled Formula publication and break-glass recovery, +including on `main-merge-writers`. The workflow creates the nine Code Admission checks for the Formula-only commit only after proving its sole parent already has all nine successful checks and the committed Formula -exactly matches this release's verified bytes. +exactly matches this release's verified bytes. Formula commits retain +`[skip ci]`, so the sealing step exposes only the reviewed commit identity to +an independent confirmation job. That job creates the +`Coverage Baseline Cache` acknowledgement and emits the reviewed +`coverage-baseline-promote` repository dispatch. The default-branch +`Coverage Baseline Promotion` workflow independently verifies the exact +single-parent Formula commit, both parent and target admission contexts, and +default-branch containment before checking out the target. It restores only +the exact parent profile, recomputes the complete profile if that cache is +absent, and saves the Formula SHA under the `main` cache scope. Because the +cache save action treats upload errors as warnings, a second lookup must report +`cache-hit=true` for the exact target key before the producer succeeds. The +promotion completes the unique acknowledgement, and the confirmation job +waits for that exact check-run ID. npm and mirror publication depend only on +the immutable release job, so a transient +cache-service failure cannot strand an otherwise valid release between +channels; the final release-delivery gate still fails until the exact cache is +confirmed. Once Formula sealing exposes the target SHA, the confirmation job +also runs when a later immutable-package recheck fails, so a post-push failure +cannot orphan the producer. Rerun the failed promotion/confirmation path after +repairing the producer. Never add a prefix `restore-keys` fallback to this path. + +`Coverage Baseline Repair` is the independent safety net for every merged PR. +Its base-owned `pull_request_target: closed` job never checks out or executes PR +content: it binds the closed event's PR number and stable head SHA to the +current merged-PR facts (`merged_at`, `base.ref`, and `merge_commit_sha`) and +proves that merge commit is contained in `main`. It deliberately does not +compare REST `base.sha`, because that field follows the live base branch and +can move after the merge. Only then does it emit a +`coverage-baseline-repair` repository dispatch. Workflow-skip directives alone +do not suppress `pull_request_target`, subject to GitHub's separate +security-sensitive branch-name restriction described above. The low-trust +trigger is forbidden from writing the default-branch cache directly. Before +dispatching, it gives Actions event delivery one minute to expose a run from +the exact protected `.github/workflows/ci.yml` workflow and exits if that normal producer already +owns the SHA, avoiding a duplicate full-suite run. A successful CI producer +must hard-verify its exact cache key. If that run instead completes with any +non-success conclusion, a separate base-owned `workflow_run` dispatcher binds +the exact workflow ID/path, run ID/attempt, conclusion, repository, branch, and +head SHA before requesting repair. `workflow_run` also has read-only +default-branch cache access, so both dispatchers use the reviewed +`repository_dispatch` exception. The dispatched default-branch producer +revalidates the corresponding merged-PR or failed-CI identity before checkout, +restores only the exact target key, recomputes the complete profile on a miss, +and verifies `cache-hit=true` after saving. An hourly schedule refreshes the +event-time `main` SHA after direct break-glass pushes or cache eviction; +`workflow_dispatch` provides the same current-main repair on demand. The +dedicated App identity remains mandatory because events created by the built-in +`GITHUB_TOKEN` can suppress both the main push and the closed-PR event. Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and `Pull requests: write` (the latter remains necessary for withdrawal rollback), diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 8016833d..00d043a7 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -147,13 +147,68 @@ maintainer pool. A current-head approval or change request is preserved; after a new push, stale activity does not suppress a fresh request, and an outstanding change requester is preferred for continuity. -The branch ruleset keeps one human approval and all nine strict required -contexts, and requires someone other than the latest pusher to approve after -the most recent head update. Repository auto-merge is enabled for ready PRs, -so a PR merges after that approval and the current revision's nine checks are -green. If `main` advances, strict checks rerun before merge. The reviewer -router is orchestration, not a quality context, and must not be added to the -ruleset. +The branch rulesets keep one human approval and all nine strict required +contexts, require someone other than the latest pusher to approve after the +most recent head update, and restrict `main` updates to the dedicated Reviewer +Router App in pull-request mode plus the designated Formula publisher in +always/break-glass mode. Repository auto-merge is enabled for ready PRs, so the +App-owned request merges after that approval and the current revision's nine +checks are green. If `main` advances, strict checks rerun before merge. The +reviewer routing job uses the built-in `GITHUB_TOKEN` to request reviewers with +`Contents: read` and `Pull requests: write`. A separate base-owned cleanup job +isolates the merge-authority permissions (`Contents: write` and `Pull +requests: write`), revalidates the exact event base/head, and uses the built-in +token only to disable an existing request owned by `github-actions[bot]` or one +whose title or merge metadata requests that GitHub skip workflows; it never +enables auto-merge. The job then mints a current-repository installation token +for the dedicated Reviewer Router GitHub App, proves its emitted slug matches +the reviewed `REVIEWER_ROUTER_APP_SLUG`, replaces every non-App request, and +enables native auto-merge with fixed metadata. This +identity boundary is required because GitHub suppresses +most workflow events created by the built-in token; using it for auto-merge would +silently skip the merge commit's protected-main CI and baseline-cache +producer. Token minting or takeover fails closed without falling back to +`GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and +the required `Test` context live-verifies the exact `main-merge-writers` update +rule. GitHub's read APIs may omit `parameters` for the strict +`update_allows_fetch_and_merge: false` value, so the gate accepts only that +exact omission or a one-field `parameters` object containing explicit boolean +`false`; every other present shape or value fails closed. The gate then binds +the same ruleset node through GraphQL and requires its non-null +`updateAllowsFetchAndMerge` value to be exactly `false`. It also requires its +own built-in token to report +`current_user_can_bypass: never`. The minted App separately requires +`pull_requests_only` on that writer rule and `never` on every other active main +ruleset before it can enable or reconcile auto-merge. The read-only `Test` +token may receive a repository projection with both merge-default properties +omitted; it accepts only that complete omission or exact `MERGE_MESSAGE` plus +`PR_TITLE`/`BLANK`, while partial or malformed projections fail closed. The +minted App's `Contents: write` token must observe the exact reviewed defaults +before either mutation path proceeds. GitHub hides the complete +`bypass_actors` list from low-privilege callers, so the rollout audit must still +keep the writer list at exactly the Reviewer App plus `PeterGuy326` (ID +`47820304`). The required check finally accepts a null or exact App-owned +request after a short takeover grace period. Null is safe from the suppressed +event path because the built-in Actions identity cannot update `main`; other +permitted identities produce either a main push or the trusted closed-PR +repair. Drafts skip the identity step, while `ready_for_review`, `edited`, +`auto_merge_enabled`, and `auto_merge_disabled` explicitly start fresh admission +for readiness, title, and merge-request changes. Router does not react to +`auto_merge_disabled`, so a +human can deliberately leave the PR manual-only for break-glass handling. +Reviewer routing remains available. The protected-main push that deploys the +workflow automatically migrates every open, ready non-App request and repairs +unsafe App metadata; it disables workflow-skipping requests for correction. A +manual `workflow_dispatch` from `main` is the idempotent retry path. +Reconciliation never enables an originally null request. The reviewer router +is orchestration, not a quality context, and +must not be added to the ruleset. + +GitHub may omit `pull_request_target` for security-sensitive head branch names, +including names that look like commit SHAs. Those PRs cannot use Router App +takeover or the closed-event repair: rename the branch for the supported path, +or use the designated break-glass identity with a safe final message so main +push CI remains the exact-SHA producer. ## Running focused gates locally @@ -213,7 +268,75 @@ the same dedicated cache profile path because GitHub includes that path in the cache version; the runtime-facing candidate and baseline filenames remain separate. Near-miss reuse is forbidden — the caches carry no prefix restore keys, because a neighbouring commit's profile would compare the candidate -against the wrong baseline. Supporting and (when +against the wrong baseline. CI concurrency is keyed by PR number plus exact +event base/head SHA. Duplicate runs for that exact revision may cancel each +other, but a later revision cannot kill an earlier cold-cache producer. Main +runs use the pushed SHA, so a newer main push cannot cancel a predecessor's +producer. + +Every supported main advancement path has an exact-SHA producer. The required +`Test` context rejects GitHub workflow-skip directives in PR and auto-merge +metadata, reruns when that metadata is enabled, disabled, or edited, and +verifies the live App/writer-ruleset identity contract. Reviewer Router +additionally binds auto-merge to the exact head OID and writes a fixed safe +merge headline/body. The sole break-glass publisher must retain a safe final +message; the release-controlled Formula-only path +is the sole supported use of `[skip ci]`. A full source push +saves the assembled profile after the aggregate gate passes. A trusted +documentation or release-seal push independently verifies that the complete +`before...after` diff contains only the reviewed metadata allowlist, restores +only the exact `before` cache, recomputes the full profile if the chain is +cold, and makes that helper a dependency of the required `Coverage` context. +Release-generated Formula commits intentionally retain `[skip ci]`; after +their nine synthetic contexts are sealed, an independent release-governance +job creates an acknowledgement and emits a `coverage-baseline-promote` +repository dispatch. The default-branch promotion +workflow revalidates the exact single-parent Formula identity, successful +parent and target contexts, and main containment before it promotes the exact +parent cache or performs the same full fallback. Every target-main producer +follows its save with a lookup-only restore and requires +`cache-hit=true` for the exact key; this turns the cache action's otherwise +warning-only upload failure or prefix match into a hard failure. Formula +promotion additionally updates one release-created `Coverage Baseline Cache` +check. A separate confirmation job waits for that exact check-run ID while npm +and mirrors remain dependent only on the immutable publication job; cache +failure therefore makes the final delivery gate red without creating a +partially published release. Once Formula sealing exposes its SHA, a later +publication verification failure cannot suppress that confirmation job. + +A separate base-owned `pull_request_target: closed` safety net covers the final +merged SHA even if a human or integration changes the merge message after PR +checks finish. Skip directives alone do not suppress `pull_request_target`, +subject to GitHub's separate security-sensitive branch-name restriction above. +That job executes no PR code and only dispatches after binding the exact +closed-event PR number and stable head SHA to merged-PR facts +(`merged_at`, `base.ref`, and `merge_commit_sha`) and proving `main` +containment. It does not compare the later REST `base.sha`, which follows the +live base branch after merge. Because GitHub makes default-branch caches +read-only to `pull_request_target`, the dispatcher first waits up to one minute +for a run from the exact protected +`.github/workflows/ci.yml` workflow and exits when that normal producer exists. +A successful main CI hard-verifies the exact key itself. A completed +non-success run starts a separate base-owned `workflow_run` dispatcher, which +binds the exact CI workflow ID/path, run ID/attempt, conclusion, upstream +repository, `main` branch, and head SHA. That trigger is also cache-read-only, +so either trusted dispatcher uses `repository_dispatch`; its producer +revalidates the merged-PR or failed-CI identity, checks out the contained SHA, +and produces/verifies the exact full cache. +An hourly schedule and a main-only manual dispatch repair the event-time main +SHA after a direct break-glass push or cache eviction. The dispatch exception +is intentional: unlike an ordinary event created by `GITHUB_TOKEN`, GitHub +allows `repository_dispatch` to start another workflow. A legacy built-in-token +merge can suppress the closed event too, which is why the required `Test` +identity gate and dedicated Reviewer Router App are still mandatory. + +A cold miss can still occur during a producer race or after cache eviction, +but it remains fail-safe: the PR recomputes the authoritative baseline with a +30-minute job budget and saves a PR-scoped copy for same-PR reruns. It is no +longer possible for a supported main-advance path to omit its producer +silently. That PR-scoped fallback save remains a best-effort acceleration and +does not replace the normal push, metadata, Formula, and merged-PR repair +producers. Supporting and (when platform-selected) native profiles are generated before the aggregate `Coverage` context evaluates them. The aggregate and native gates require 100% coverage for changed executable Go @@ -265,4 +388,11 @@ unproducible required context. The branch ruleset also requires one approval after the latest push. Enable repository auto-merge and automatic head-branch deletion; keep the base-owned -reviewer router outside the required-context list. +reviewer router outside the required-context list. Install its dedicated +GitHub App only on this repository with `Contents: read and write` and `Pull +requests: read and write`; do not grant Actions, Workflows, or Administration. +Give it pull-request-only bypass on `main-merge-writers` and no bypass on any +other ruleset. Store the App client ID and lowercase slug in repository +variables `REVIEWER_ROUTER_APP_CLIENT_ID` and `REVIEWER_ROUTER_APP_SLUG`, and +its private key in repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY`. Do not +reuse release, Homebrew, or personal tokens for this boundary. diff --git a/docs/datasource-usage-guide.md b/docs/datasource-usage-guide.md new file mode 100644 index 00000000..557dfe4c --- /dev/null +++ b/docs/datasource-usage-guide.md @@ -0,0 +1,454 @@ +# AI 表格数据源指令使用指南 + +## 概述 + +dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。 + +所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。 + +## 指令速览 + +| 指令 | 用途 | 读写 | 风险 | +|------|------|------|------| +| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low | +| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low | +| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium | +| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium | +| `+datasource-sync` | 手动触发一次同步 | 写 | medium | +| `+datasource-sync-status` | 查询同步任务状态 | 读 | low | +| `+datasource-get-config` | 查看数据源表配置 | 读 | low | + +## 前置条件 + +1. **登录认证**:执行 `dws auth login` 确保已登录 +2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID + +--- + +## 1. 列出数据源可用来源 + +``` +dws aitable +datasource-list-sources [flags] +``` + +列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID | +| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) | + +### 示例 + +```bash +# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode) +dws aitable +datasource-list-sources \ + --base-id BASE123 \ + --datasource-type OA +``` + +### 返回值 + +返回 `sources` 数组,每个条目包含: + +| 字段 | 说明 | +|------|------| +| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` | +| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) | +| `sourceUrl` | 数据源访问链接,可选 | + +`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。 + +--- + +## 2. 获取数据源可同步字段 + +``` +dws aitable +datasource-get-fields [flags] +``` + +获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID | +| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) | +| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` | + +### 示例 + +```bash +# 获取某审批模板的可同步字段 +dws aitable +datasource-get-fields \ + --base-id BASE123 \ + --datasource-type OA \ + --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' +``` + +### 返回值 + +返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。 + +--- + +## 3. 创建数据源表 + +``` +dws aitable +datasource-create [flags] +``` + +为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) | +| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) | +| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) | +| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 | +| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 | +| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 | + +### source-config 格式(审批类) + +审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` | +| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 | +| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 | +| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 | +| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` | +| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` | +| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` | +| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` | +| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false | + +> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。 + +按 `dataType` 选择对应的时间参数组合: + +| dataType | 需要的时间字段 | 说明 | +|----------|----------------|------| +| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) | +| `start_time` | `startDate` | 同步从某日期至今的数据 | +| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 | + +### auto-sync-setting 格式 + +`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) | +| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 | +| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` | +| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` | +| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 | +| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 | +| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false | + +示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` + +### 示例 + +```bash +# 基本创建——同步近 30 天审批数据 +dws aitable +datasource-create \ + --base-id BASE123 \ + --datasource-type OA \ + --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' + +# 指定日期范围创建并开启自动同步 +dws aitable +datasource-create \ + --base-id BASE123 \ + --datasource-type OA \ + --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \ + --auto + +# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取) +dws aitable +datasource-create \ + --base-id BASE123 \ + --datasource-type OA \ + --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \ + --field-ids fldAAA,fldBBB,fldCCC +``` + +### 返回值 + +创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。 + +--- + +## 4. 更新数据源配置 + +``` +dws aitable +datasource-update [flags] +``` + +更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID | +| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) | +| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` | +| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 | +| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 | +| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 | + +### 示例 + +```bash +# 更换审批模板并调整时间范围 +dws aitable +datasource-update \ + --base-id BASE123 \ + --table-id TBL456 \ + --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' + +# 开启自动同步 +dws aitable +datasource-update \ + --base-id BASE123 \ + --table-id TBL456 \ + --auto + +# 更新同步字段范围 +dws aitable +datasource-update \ + --base-id BASE123 \ + --table-id TBL456 \ + --field-ids fldAAA,fldDDD +``` + +> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。 + +--- + +## 5. 触发手动同步 + +``` +dws aitable +datasource-sync [flags] +``` + +对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID | +| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) | + +### 示例 + +```bash +# 同步单张表 +dws aitable +datasource-sync \ + --base-id BASE123 \ + --table-ids TBL1 + +# 批量同步多张表(逗号分隔,最多 5 个) +dws aitable +datasource-sync \ + --base-id BASE123 \ + --table-ids TBL1,TBL2,TBL3 +``` + +### 返回值 + +返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。 + +--- + +## 6. 查询同步状态 + +``` +dws aitable +datasource-sync-status [flags] +``` + +按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID | +| `--table-id` | string | 是 | 数据源表 ID | +| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) | + +### 示例 + +```bash +# 按任务 ID 查询(批量,最多 5 个) +dws aitable +datasource-sync-status \ + --base-id BASE123 \ + --table-id TBL456 \ + --task-ids TASK1,TASK2 +``` + +--- + +## 7. 获取数据源配置 + +``` +dws aitable +datasource-get-config [flags] +``` + +获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。 + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `--base-id` | string | 是 | 目标 Base ID | +| `--table-id` | string | 是 | 数据源表 ID | + +### 示例 + +```bash +dws aitable +datasource-get-config \ + --base-id BASE123 \ + --table-id TBL456 +``` + +--- + +## 典型工作流 + +### 场景一:从零接入审批数据 + +```bash +# 0. 获取 Base ID +dws aitable +base-search --query "我的项目表" + +# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode +dws aitable +datasource-list-sources \ + --base-id BASE123 \ + --datasource-type OA +# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX + +# 2. 查看可同步字段(可选,用于指定 field-ids) +dws aitable +datasource-get-fields \ + --base-id BASE123 \ + --datasource-type OA \ + --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' + +# 3. 创建数据源表(创建后自动触发首次同步) +dws aitable +datasource-create \ + --base-id BASE123 \ + --datasource-type OA \ + --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' +# → 返回 tableId=TBL456, taskId=TASK001 + +# 4. 查询首次同步是否完成 +dws aitable +datasource-sync-status \ + --base-id BASE123 \ + --table-id TBL456 \ + --task-ids TASK001 + +# 5. 确认配置 +dws aitable +datasource-get-config \ + --base-id BASE123 \ + --table-id TBL456 +``` + +### 场景二:更换审批模板后重新同步 + +```bash +# 1. 更新源配置(更新后自动触发一次同步) +dws aitable +datasource-update \ + --base-id BASE123 \ + --table-id TBL456 \ + --source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' + +# 2. 查询同步状态(更新后会返回新的 taskId) +dws aitable +datasource-sync-status \ + --base-id BASE123 \ + --table-id TBL456 \ + --task-ids TASK002 +``` + +### 场景三:手动触发日常同步 + +```bash +# 仅触发同步,不修改配置 +dws aitable +datasource-sync \ + --base-id BASE123 \ + --table-ids TBL456 + +# 查询结果(sync 会返回 taskId) +dws aitable +datasource-sync-status \ + --base-id BASE123 \ + --table-id TBL456 \ + --task-ids TASK001 +``` + +### 场景四:开启自动同步后确认 + +```bash +# 1. 更新配置,开启自动同步 +dws aitable +datasource-update \ + --base-id BASE123 \ + --table-id TBL456 \ + --auto + +# 2. 确认配置已更新 +dws aitable +datasource-get-config \ + --base-id BASE123 \ + --table-id TBL456 +# → 返回中应显示 auto=true +``` + +--- + +## 通用选项 + +以下全局选项可在所有指令中使用: + +| 选项 | 说明 | +|------|------| +| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv | +| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) | +| `--fields` | 筛选输出字段(逗号分隔) | +| `--dry-run` | 预览操作内容,不实际执行 | +| `--profile` | 指定组织或账号 | +| `--timeout` | HTTP 请求超时时间(秒,默认 30) | +| `--debug` | 显示调试日志 | +| `-v, --verbose` | 显示详细日志 | + +### 输出过滤示例 + +```bash +# 只取 tableId +dws aitable +datasource-create ... --jq '.tableId' + +# 只取同步状态 +dws aitable +datasource-sync-status ... --jq '.status' + +# table 格式查看 +dws aitable +datasource-get-config ... -f table +``` + +--- + +## 注意事项 + +1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。 + +2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。 + +3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。 + +4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。 + +5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。 + +6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。 + +7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。 diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index cb12c50b..e98f7914 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -274,6 +274,7 @@ Definition(仅声明;不可编译) | 层 | 含义 | 今日落点 | |---|---|---| | **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) | +| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 | | **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) | | **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) | | **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) | @@ -310,7 +311,25 @@ Definition(仅声明;不可编译) 3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格; 4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。 -#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3) +#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`) + +`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`: + +| 轴 | 允许值 | 语义 | +|---|---|---| +| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 | +| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 | +| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 | + +硬规则: + +1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。 +2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。 +3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。 +4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。 +5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。 + +#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3) 当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定): diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md deleted file mode 100644 index 064abb6b..00000000 --- a/docs/shortcut-downstream-requirements/attendance.md +++ /dev/null @@ -1,310 +0,0 @@ -# Attendance Shortcut 下游业务能力需求规格 - -> 日期:2026-08-18 -> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 -> 对比基线:Lark CLI 1.0.87 -> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 - -## 1. 执行摘要 - -- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 -- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 -- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。 -- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 -- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 - -| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | -|---|---|---|---|---|---|---| -| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` | -| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | -| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | -| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | -| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | -| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | -| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | -| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` | - -## 2. 用户任务与能力缺口总览 - -| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | -|---|---|---|---|---|---| -| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | -| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | -| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | -| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 | -| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | -| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | -| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | -| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | -| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog | -| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | - -## 3. 下游需求明细 - -### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取 - -#### A. 用户任务与现状 - -- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。 -- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。 -- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。 -- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。 -- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。 -- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。 -- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。 - -#### B. 需要下游提供的合同 - -- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。 -- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。 -- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。 -- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。 -- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。 - -#### C. 验收标准 - -1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。 -2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。 -3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。 -4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。 - -#### D. 临时处置 - -`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。 - -### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 - -#### A. 用户任务与现状 - -- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。 -- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。 -- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。 -- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。 -- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。 -- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。 -- 安全证据句柄:`ATT-REPORT-NULL-01`。 - -#### B. 需要下游提供的合同 - -- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。 -- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。 -- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。 -- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。 -- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。 -- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。 - -#### C. 验收标准 - -1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。 -2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。 -3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。 -4. 假期报表至少覆盖已知非空、合法空和未知假期类型。 -5. 分页/partial 分支和远端零残留通过。 - -#### D. 临时处置 - -三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 - -### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 - -#### A. 用户任务与现状 - -- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 -- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。 -- 安全证据句柄:`ATT-CHECK-PAGE-01`。 - -#### B. 需要下游提供的合同 - -- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。 -- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。 -- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。 -- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。 - -#### C. 验收标准与临时处置 - -- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 -- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。 - -### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 - -#### A. 用户任务与现状 - -- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。 -- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。 -- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。 -- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。 -- 安全证据句柄:`ATT-FIXTURE-GAP-01`。 - -#### B. 需要的测试基础设施与合同 - -- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。 -- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。 -- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。 -- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。 -- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。 - -#### C. 验收标准与临时处置 - -1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。 -2. create→get→update→restore/delete 覆盖班次、考勤组与排班。 -3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。 -4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。 -5. 测试结束远端和本地均零残留。 - -在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 - -### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture - -#### A. 用户任务与现状 - -- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。 -- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。 -- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。 -- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。 -- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。 - -#### B. 需要下游提供的合同与 fixture - -- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。 -- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。 -- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。 -- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。 - -#### C. 验收标准与临时处置 - -1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。 -2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 -3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 - -在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。 - -### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 - -#### A. 用户任务与现状 - -- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。 -- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。 -- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。 -- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。 -- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。 - -#### B. 需要下游提供的合同 - -- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。 -- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。 -- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。 -- 改动需 additive/versioned;现有详情业务字段保持兼容。 - -#### C. 验收标准与临时处置 - -1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。 -2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。 -3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。 - -下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。 - -### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture - -#### A. 用户任务与现状 - -- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。 -- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。 -- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。 -- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。 - -#### B. 需要下游提供的合同与 fixture - -- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。 -- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。 -- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。 -- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。 - -#### C. 验收标准与临时处置 - -1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。 -2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。 -3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 -4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 - -能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 - -### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误 - -#### A. 用户任务与现状 - -- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。 -- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。 -- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。 -- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。 -- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。 - -#### B. 需要下游提供的合同 - -- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。 -- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。 -- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。 -- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。 - -#### C. 验收标准与临时处置 - -1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。 -2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。 -3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。 -4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。 - -下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。 - -## 4. Lark 对齐与平台差异 - -| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | -|---|---|---|---|---| -| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 | - -无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。 - -## 5. 超越 Lark 的产品机会 - -| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | -|---|---|---|---|---| -| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 | -| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 | -| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 | - -## 6. 无需下游变更的上游修复 - -| Shortcut | 上游根因 | 已完成修复 | 回归证据 | -|---|---|---|---| -| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | -| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | -| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | -| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | -| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | -| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | -| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | -| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | - -### 6.1 clean-HEAD live 发布门状态 - -| 叶子 | clean executable HEAD 双层证据 | 发布状态 | -|---|---|---| -| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | -| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | -| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | - -pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 - -上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。 - -## 7. 安全与脱敏声明 - -- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 -- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。 -- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。 -- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md deleted file mode 100644 index a9fede56..00000000 --- a/docs/shortcut-downstream-requirements/mail.md +++ /dev/null @@ -1,198 +0,0 @@ -# Mail Shortcut 下游业务能力需求规格 - -> 日期:2026-08-18 -> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 -> 对比基线:lark-cli 1.0.87 -> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 -> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 - -## 1. 执行摘要 - -本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 - -上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。 - -仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 - -| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | -|---|---|---|---|---|---|---| -| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` | -| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` | -| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` | -| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` | -| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` | -| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` | -| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | -| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | -| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | -| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` | - -## 2. 用户任务与能力缺口总览 - -| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | -|---|---|---|---|---|---| -| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 | -| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable | -| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | -| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable | -| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable | -| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | -| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | -| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | -| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 | -| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 | -| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | -| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | -| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | -| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable | - -## 3. 下游需求明细 - -### `DS-Mail-001` — 可验证的发送生命周期 - -- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。 -- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。 -- 所需接口合同: - - 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。 - - 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。 - - 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。 - - 支持幂等键,或明确 unknown commit 不可自动重试。 - - 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。 -- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。 - -### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真 - -- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。 -- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。 -- 所需接口合同: - - 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。 - - 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。 - - 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。 -- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。 - -### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态 - -- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。 -- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。 -- 所需接口合同: - - 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。 - - 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。 - - 软删除和永久删除使用不同操作,危险级别与确认要求可声明。 - - 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。 -- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。 - -### `DS-Mail-004` — 已读回执与签名 - -- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。 -- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。 -- 所需接口合同: - - 消息详情公开稳定回执请求状态和请求者身份类型。 - - 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。 - - 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。 -- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。 - -### `DS-Mail-005` — 新邮件监听的订阅生命周期 - -- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。 -- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。 -- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。 -- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。 - -### `DS-Mail-006` — 模板附件与内联图片事务 - -- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。 -- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。 -- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。 -- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。 - -### `DS-Mail-007` — 联系人写操作的稳定身份 - -- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。 -- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。 -- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。 -- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。 - -### `DS-Mail-008` — 统一成功、空结果与分页协议 - -- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 -- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。 -- 所需接口合同: - - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 - - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 - - 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。 - - 保留兼容期,但提供 capability/version 让上游安全切换。 -- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。 - -### `DS-Mail-009` — 安全租户与真实 E2E fixture - -- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。 -- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。 -- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 -- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 - -### `DS-Mail-010` — 草稿/模板可证明的清理终态 - -- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。 -- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。 -- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。 -- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。 -- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。 - -## 4. Lark 对齐与平台差异 - -| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | -|---|---|---|---| -| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | -| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable | -| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable | -| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | -| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | -| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | -| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable | -| `+signature` | no | 缺签名读取资源 | platform unavailable | -| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked | -| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw | -| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed | - -## 5. 超越 Lark 的产品机会 - -| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | -|---|---|---|---| -| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 | -| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 | -| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 | -| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 | -| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 | - -## 6. 无需下游变更的上游修复 - -| Shortcut | 上游根因 | 已完成修复 | 回归证据 | -|---|---|---|---| -| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | -| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 | -| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 | -| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | -| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` | -| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | -| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | -| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | - -### 6.1 clean executable HEAD 双层证据 - -| 公开入口 | exact Shortcut + owning raw 证据 | 状态 | -|---|---|---| -| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 | -| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | - -8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 - -## 7. 安全与脱敏声明 - -- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 -- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 -- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 -- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。 -- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。 -- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 6e6904cb..3a93cdfb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,7 +1,67 @@ { - "generated_at": "2026-08-20T11:51:44.156046", - "count": 416, + "generated_at": "2026-08-24T20:14:49.172788", + "count": 437, "results": [ + { + "suite": "semantic", + "service": "agoal", + "command": "+contract-fields", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+obj-template-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+report-statistics-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+report-submit-detail", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+user-rules", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aisearch", + "command": "+search-person", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。", + "availability": "available" + }, { "suite": "semantic", "service": "aitable", @@ -272,6 +332,76 @@ "semantic_delta": "更新仪表盘配置的一对一入口。", "availability": "available" }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-get-config", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-get-fields", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-list-sources", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-sync", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-sync-status", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+datasource-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。", + "availability": "available" + }, { "suite": "semantic", "service": "aitable", @@ -2263,235 +2393,384 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+by-mobile", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+dept-members", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+list-dept-members", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+list-followings", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+list-role-members", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。", + "availability": "available" }, { - "suite": "read", - "service": "contact", - "command": "+list-roles", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+list-sub-depts", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+lookup", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+me", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+org", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+resolve-dept", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+search-mobile", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+search-user", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "contact", "command": "+team", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+create", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "devapp", + "command": "+credentials-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+delete", "risk": "high-risk-write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+disable", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+enable", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+event-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+event-subscribe", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+member-add", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+member-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+member-remove", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+permission-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+robot-config", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "devapp", + "command": "+robot-disable", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "devapp", + "command": "+robot-enable", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+robot-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+update", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-check-approval", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+version-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+version-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-status", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+webapp-config", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+webapp-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { "suite": "semantic", @@ -3593,6 +3872,16 @@ "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", "availability": "available" }, + { + "suite": "semantic", + "service": "pat", + "command": "+browser-policy", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。", + "availability": "available" + }, { "suite": "semantic", "service": "report", @@ -3634,18 +3923,24 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "sheet", "command": "+list-sheets", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "sheet", "command": "+read", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。", + "availability": "available" }, { "suite": "semantic", @@ -3857,6 +4152,26 @@ "semantic_delta": "更新指定字段后读取详情逐字段核验。", "availability": "available" }, + { + "suite": "semantic", + "service": "whiteboard", + "command": "+query", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "whiteboard", + "command": "+update", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。", + "availability": "available" + }, { "suite": "semantic", "service": "wiki", diff --git a/go.mod b/go.mod index 62bec438..09622232 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.24 github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 20ff6207..64ca5d46 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error } func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error { + hasTarget := false + for _, id := range subscribeIDs { + if strings.TrimSpace(id) != "" { + hasTarget = true + break + } + } + if !hasTarget { + return nil + } + if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil { return nil } else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) { diff --git a/internal/app/event_personal_many_test.go b/internal/app/event_personal_many_test.go index bd8c968e..3ee0f1b8 100644 --- a/internal/app/event_personal_many_test.go +++ b/internal/app/event_personal_many_test.go @@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t }) } -func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) { +func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) { oldStop := personalStopConsumers oldQuery := personalQueryStatus oldFind := personalFindProcess @@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) { personalSignalProcess = oldSignal }() + personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) { + t.Fatal("targeted stop called without a subscribe_id") + return transport.ConsumerStopResp{}, nil + } + personalQueryStatus = func(string) (*transport.StatusResp, error) { + t.Fatal("legacy status queried without a subscribe_id") + return nil, nil + } + if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil { + t.Fatalf("empty target stop = %v", err) + } + personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) { return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil } diff --git a/internal/app/group_policy_tree_test.go b/internal/app/group_policy_tree_test.go new file mode 100644 index 00000000..eadabcd9 --- /dev/null +++ b/internal/app/group_policy_tree_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the +// old helpers-only AST scan with an invariant over the two real assembly +// products: the deterministic distribution tree and a runtime tree after a +// nested plugin overlay has been merged. +func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) { + distribution := NewSchemaSourceRootCommand() + for _, path := range []string{ + "sheet range read", + "pat chmod", + "plugin list", + "chat +chat-messages", + } { + requireFinalCommandPath(t, distribution, path) + } + if err := cobracmd.ValidateGroupTree(distribution); err != nil { + t.Fatalf("distribution command tree GroupPolicy invariant: %v", err) + } + + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command { + descriptor := conferencePluginDescriptor() + return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root) + }) + runtime := NewRootCommand() + requireFinalCommandPath(t, runtime, "conference camera open") + if err := cobracmd.ValidateGroupTree(runtime); err != nil { + t.Fatalf("runtime command tree GroupPolicy invariant: %v", err) + } +} + +func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command { + t.Helper() + command, remaining, err := root.Find(strings.Fields(path)) + if err != nil || command == nil || len(remaining) != 0 || command == root { + t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err) + } + return command +} diff --git a/internal/app/markdown_shortcut_boundary_test.go b/internal/app/markdown_shortcut_boundary_test.go new file mode 100644 index 00000000..74be3dc4 --- /dev/null +++ b/internal/app/markdown_shortcut_boundary_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) { + root := NewRootCommand() + tools := deliverySchemaAllToolsForHelpFlagTest(t, root) + assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools) + assertMarkdownDriveRoutesStayCrossProduct(t, root, tools) + assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools) +} + +func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) { + t.Helper() + registered := 0 + for _, item := range shortcut.All() { + if item.Service == "markdown" { + registered++ + } + } + if registered != 0 { + t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered) + } + + type route struct { + canonical string + confirmation string + flags []string + } + routes := map[string]route{ + "create": { + canonical: "markdown.create", confirmation: "not_required", + flags: []string{"content", "file", "folder", "name", "space-id", "workspace"}, + }, + "fetch": { + canonical: "markdown.fetch", confirmation: "not_required", + flags: []string{"node", "output", "space-id", "workspace"}, + }, + "overwrite": { + canonical: "markdown.overwrite", confirmation: "user_required", + flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"}, + }, + "patch": { + canonical: "markdown.patch", confirmation: "user_required", + flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"}, + }, + "diff": { + canonical: "markdown.diff", confirmation: "not_required", + flags: []string{"context", "file", "node", "version", "version2"}, + }, + } + + group := mustFindCommand(t, root, "markdown") + children := map[string]bool{} + for _, child := range group.Commands() { + children[child.Name()] = true + } + if len(children) != len(routes) { + t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children) + } + + for name, want := range routes { + leaf := mustFindCommand(t, root, "markdown", name) + if leaf.Hidden || !leaf.Runnable() { + t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable()) + } + if !children[name] { + t.Errorf("markdown %s is not mounted on the ordinary product group", name) + } + for _, flag := range want.flags { + if leaf.Flags().Lookup(flag) == nil { + t.Errorf("markdown %s is missing routed flag --%s", name, flag) + } + } + if shortcut.InPublicCatalog("markdown", "+"+name) { + t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name) + } + + meta, ok := cli.ResolveMeta("markdown " + name) + if !ok { + t.Errorf("markdown %s missing from assembled Schema", name) + continue + } + if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name { + t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name) + } + if meta.Safety.Confirmation != want.confirmation { + t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation) + } + + tool := tools[want.canonical] + if tool == nil { + t.Errorf("markdown %s missing from full delivery Schema", name) + continue + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Errorf("markdown %s availability=%q, want available", name, got) + } + if got := schemaContractString(tool["interface_mode"]); got != "composite" { + t.Errorf("markdown %s interface_mode=%q, want composite", name, got) + } + if got := schemaContractString(tool["interface_reason"]); got == "" { + t.Errorf("markdown %s is missing the reviewed composite routing reason", name) + } + } +} + +func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) { + t.Helper() + driveShortcuts := map[string]string{ + "+copy": "drive.shortcut_copy", + "+delete": "drive.shortcut_delete", + "+find-file": "drive.shortcut_find_file", + "+list": "drive.shortcut_list", + "+move": "drive.shortcut_move", + "+publish-get": "drive.shortcut_publish_get", + "+recycle-restore": "drive.shortcut_recycle_restore", + "+rename": "drive.shortcut_rename", + "+version-download": "drive.shortcut_version_download", + "+version-get": "drive.shortcut_version_get", + "+version-history": "drive.shortcut_version_history", + "+version-revert": "drive.shortcut_version_revert", + } + for name, canonical := range driveShortcuts { + leaf := mustFindCommand(t, root, "drive", name) + if leaf.Hidden || !leaf.Runnable() { + t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable()) + } + if !shortcut.InPublicCatalog("drive", name) { + t.Errorf("drive %s is not in the public Shortcut catalog", name) + } + assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical) + } + + ordinaryRoutes := map[string]string{ + "drive permission list": "drive.list_permission", + "drive pull": "drive.folder_pull", + "drive push": "drive.folder_push", + "drive status": "drive.folder_status", + "drive sync": "drive.folder_sync", + "wiki node list": "wiki.list_nodes", + } + for cliPath, canonical := range ordinaryRoutes { + assertMarkdownCrossProductRoute(t, tools, cliPath, canonical) + } +} + +func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) { + t.Helper() + meta, ok := cli.ResolveMeta(cliPath) + if !ok { + t.Errorf("cross-product route %q is missing from assembled Schema", cliPath) + return + } + if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath { + t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical) + } + tool := tools[canonical] + if tool == nil { + t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath) + return + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Errorf("cross-product route %q availability=%q, want available", cliPath, got) + } +} diff --git a/internal/app/mcp_url_command.go b/internal/app/mcp_url_command.go index 70a02102..2f958726 100644 --- a/internal/app/mcp_url_command.go +++ b/internal/app/mcp_url_command.go @@ -21,6 +21,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" @@ -43,6 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command { return cmd.Help() }, } + corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) group.AddCommand(newMCPURLGetCommand(caller)) return group } diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index cfd54910..30961b40 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{ "contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"}, "contact +search-user": {"contact", "+search-user", "--query", "Fixture User"}, "contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"}, - "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"}, + "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"}, "dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"}, "devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"}, "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"}, + "ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"}, "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"}, @@ -221,8 +221,8 @@ var paramAliasCompleteCommands = map[string][]string{ "drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"}, "drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"}, "drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"}, - "mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"}, - "mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"}, + "mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"}, + "mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"}, "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, @@ -231,12 +231,113 @@ var paramAliasCompleteCommands = map[string][]string{ "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, } -// paramAliasCandidateCompleteCommands contains complete invocations for the -// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a -// separate map lets this test file land before the draft replaces the formal +// paramAliasCandidateCompleteCommands contains complete invocations for +// reviewed parameter-concept product drafts. Keeping candidate-only commands +// in a separate map lets a test change land before a draft replaces the formal // param_concepts.json: inactive candidate templates are ignored, while every // command becomes mandatory as soon as one of its reviewed aliases is active. var paramAliasCandidateCompleteCommands = map[string][]string{ + "aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"}, + "aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"}, + "aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"}, + "aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"}, + "aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"}, + "contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"}, + "contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"}, + "contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"}, + "contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"}, + "contact +lookup": {"contact", "+lookup", "--name", "Fixture User"}, + "contact +org": {"contact", "+org", "--name", "Fixture User"}, + "contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"}, + "contact +team": {"contact", "+team", "--name", "Fixture User"}, + "contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"}, + "contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"}, + "contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"}, + "contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"}, + "contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"}, + "contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"}, + "contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"}, + "contact label get": {"contact", "label", "get", "--names", "Fixture Role"}, + "contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"}, + "contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"}, + "contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"}, + "contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`}, + "contact user search": {"contact", "user", "search", "--query", "Fixture User"}, + "contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"}, + "contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"}, + "contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"}, + "contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"}, + "devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"}, + "hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"}, + "hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"}, + "hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"}, + "hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"}, + "hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"}, + "hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"}, + "hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`}, + "hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"}, + "hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"}, + "hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"}, + "hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"}, + "hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"}, + "hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`}, + "hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"}, + "hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"}, + "hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"}, + "pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"}, + "pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"}, + "pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"}, + "attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"}, + "attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"}, + "attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"}, + "attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"}, + "attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"}, + "attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"}, + "attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"}, + "attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"}, + "attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"}, + "attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"}, + "attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"}, + "attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"}, + "attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"}, + "attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"}, + "attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"}, + "ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"}, + "ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"}, + "ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"}, + "mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"}, + "mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"}, + "mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"}, + "mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"}, + "mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"}, + "mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"}, + "mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"}, + "mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"}, + "mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"}, + "mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"}, + "mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"}, + "mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"}, + "markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"}, + "markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"}, + "markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"}, + "markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"}, + "markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"}, + "oa +list-cc": {"oa", "+list-cc", "--page", "2"}, + "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"}, + "oa +list-forms": {"oa", "+list-forms", "--cursor", "2"}, + "oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"}, + "oa +list-submitted": {"oa", "+list-submitted", "--page", "2"}, + "oa +my-initiated": {"oa", "+my-initiated", "--page", "2"}, + "report +outbox-list": {"report", "+outbox-list", "--size", "7"}, + "report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"}, + "report +template-search": {"report", "+template-search", "--query", "fixture"}, + "sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"}, + "sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"}, + "minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"}, "minutes +latest": {"minutes", "+latest", "--keyword", "fixture"}, "minutes +list-all": {"minutes", "+list-all", "--limit", "7"}, @@ -296,6 +397,20 @@ var paramAliasCandidateCompleteCommands = 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{ + "markdown create": { + "file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"}, + }, + "markdown diff": { + "file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"}, + }, + "markdown overwrite": { + "file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"}, + "dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"}, + }, + "markdown patch": { + "dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"}, + }, + "doc +copy": { "folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"}, "workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"}, @@ -969,6 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) } } +// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation +// exercises every distinct reviewed mutating complete-command template in the +// reviewed product expansions. The fixture gate already proves every +// alias resolves through PreParse; this gate removes the confirmation flag +// from one active alias invocation per distinct template and requires the +// runtime boundary to stop it before the first transport call. An explicit +// --dry-run is a reviewed preview path and must not carry a bypass flag. +func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) { + concepts, err := cli.LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + + requiredTemplates := make(map[string]bool) + coveredTemplates := make(map[string]bool) + for _, fixture := range concepts.Fixture { + if strings.HasPrefix(fixture.Expect, "did-you-mean:") { + continue + } + product, _, _ := strings.Cut(fixture.Command, " ") + switch product { + case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown", + "aisearch", "contact", "live", "devdoc", "hrbrain", "pat": + default: + continue + } + complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect) + if !ok { + continue + } + _, yesCount := removeExactArg(complete, "--yes") + _, userSayYesCount := removeExactArg(complete, "--user-say-yes") + confirmationCount := yesCount + userSayYesCount + confirmationArg := "--yes" + if userSayYesCount == 1 { + confirmationArg = "--user-say-yes" + } + _, dryRunCount := removeExactArg(complete, "--dry-run") + if dryRunCount > 1 { + t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete) + continue + } + if meta, exists := cli.ResolveMeta(fixture.Command); exists { + switch meta.Safety.Confirmation { + case "user_required": + if dryRunCount == 1 { + if confirmationCount != 0 { + t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete) + } + continue + } + if confirmationCount != 1 { + t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete) + continue + } + case "not_required": + if confirmationCount != 0 { + t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete) + continue + } + } + } + if confirmationCount == 0 { + continue + } + if confirmationCount != 1 { + t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete) + continue + } + + templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00") + requiredTemplates[templateKey] = true + if coveredTemplates[templateKey] { + continue + } + aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted) + if replacements != 1 { + t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete) + continue + } + coveredTemplates[templateKey] = true + t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { + assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs) + }) + } + + if len(requiredTemplates) == 0 { + t.Fatal("reviewed complete-command templates contain no confirmation cases") + } + if len(coveredTemplates) != len(requiredTemplates) { + t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates)) + } +} + +func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) { + t.Helper() + unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg) + if removals != 1 { + t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs) + } + + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...) + if ctx == nil { + t.Fatal("unconfirmed alias command skipped PreParse") + } + var appErr *apperrors.Error + if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" { + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls) + } + return + } + t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls) +} + func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) { t.Helper() canonicalCaller := ¶mAliasCaptureCaller{} diff --git a/internal/app/plugin_commands.go b/internal/app/plugin_commands.go index 73b0db79..94c851f7 100644 --- a/internal/app/plugin_commands.go +++ b/internal/app/plugin_commands.go @@ -24,6 +24,7 @@ import ( "unicode" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" 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/output" @@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) { } for _, child := range append([]*cobra.Command(nil), parent.Commands()...) { pruneEmptyPluginGroups(child) - if cmdutil.IsGroup(child) && len(child.Commands()) == 0 { + _, group, err := corecmd.GroupPolicyFor(child) + if err != nil { + panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err)) + } + if group && len(child.Commands()) == 0 { parent.RemoveCommand(child) } } diff --git a/internal/app/plugin_commands_coverage_test.go b/internal/app/plugin_commands_coverage_test.go index ca09b268..da211e4b 100644 --- a/internal/app/plugin_commands_coverage_test.go +++ b/internal/app/plugin_commands_coverage_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" @@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" } func (pluginWrongFlagValue) Set(string) error { return nil } func (pluginWrongFlagValue) Type() string { return "wrong" } +func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) { + emptyParent := &cobra.Command{Use: "plugin"} + emptyGroup := &cobra.Command{Use: "empty"} + corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + emptyParent.AddCommand(emptyGroup) + pruneEmptyPluginGroups(emptyParent) + if len(emptyParent.Commands()) != 0 { + t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands()) + } + + parent := &cobra.Command{Use: "plugin"} + child := &cobra.Command{Use: "group"} + corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + for key := range child.Annotations { + child.Annotations[key] = "malformed" + } + parent.AddCommand(child) + + defer func() { + got := recover() + message, ok := got.(string) + if !ok || !strings.Contains(message, "prune plugin group") { + t.Fatalf("pruneEmptyPluginGroups panic = %v", got) + } + }() + pruneEmptyPluginGroups(parent) +} + func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) { invalidRoot := conferencePluginDescriptor() invalidRoot.CLI.Command = "Invalid Root" @@ -507,7 +544,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) { mergePluginRoot(nil, root) mergePluginRoot(root, nil) destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}} - source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}} + source := cobracmd.NewGroupCommand("plugin", "plugin") + source.Aliases = []string{"one", "two"} source.AddCommand(&cobra.Command{Use: "leaf"}) mergePluginRoot(destination, source) if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil { diff --git a/internal/app/preparse_presentation_test.go b/internal/app/preparse_presentation_test.go index 68370d0b..1da970ce 100644 --- a/internal/app/preparse_presentation_test.go +++ b/internal/app/preparse_presentation_test.go @@ -17,6 +17,7 @@ import ( "bytes" stderrors "errors" "io" + "slices" "strings" "testing" @@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t } } +func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + args := []string{ + "aisearch", "--query", "Alice", "--yess", "enterprise", + "--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help", + } + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", args, err) + } + if ctx == nil || ctx.Command != "dws aisearch enterprise" || + !slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") { + t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx) + } + if err := root.Execute(); err != nil { + t.Fatalf("corrected group-middle persistent flag failed: %v", err) + } +} + +func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) { + for _, args := range [][]string{ + {"aisearch", "--types", "enterprise"}, + {"aisearch", "--types", "false", "enterprise"}, + {"aisearch", "--types=false", "enterprise"}, + } { + root := NewSchemaSourceRootCommand() + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", args, err) + } + if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) { + t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx) + } + } +} + func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) { root := NewSchemaSourceRootCommand() args := []string{ @@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre args []string wantReason string wantCommand string + wantHint string }{ { name: "unknown shortcut", @@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre wantReason: "unknown_subcommand", wantCommand: "dws dev app", }, + { + name: "unknown aisearch subcommand before protected flag", + args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"}, + wantReason: "unknown_subcommand", + wantCommand: "dws aisearch", + wantHint: "dws aisearch enterprise", + }, } for _, test := range tests { @@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre if structured.Reason != test.wantReason || structured.ExitCode() != 3 { t.Fatalf("structured error = %#v", structured) } + if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) { + t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint) + } if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") { t.Fatalf("command error leaked flag classification: %#v", structured) } diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index de0950b1..32e3b428 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -23,6 +23,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" @@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) { if err == nil { t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String()) } - if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) { - t.Fatalf("error = %v, want auth switch unknown command", err) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" || + structured.Message != `unknown subcommand "switch" for "dws auth"` || + structured.Hint != "Run 'dws auth --help' for the full list" { + t.Fatalf("error = %#v, want bounded auth subcommand guidance", err) } } diff --git a/internal/app/retired_edu_vendor_extensions_test.go b/internal/app/retired_edu_vendor_extensions_test.go new file mode 100644 index 00000000..683758e2 --- /dev/null +++ b/internal/app/retired_edu_vendor_extensions_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "strings" + "testing" +) + +func TestRetiredEduVendorExtensionsAreAbsentFromRuntimeAndSchema(t *testing.T) { + products := []string{ + "college-contact", + "edu-app", + "edu-contact", + "edu-familygroup", + "edu-group", + } + + root := NewRootCommand() + for _, product := range products { + for _, command := range root.Commands() { + if command.Name() == product { + t.Fatalf("retired product command %q remains mounted", product) + } + } + } + + retiredProducts := make(map[string]bool, len(products)) + for _, product := range products { + retiredProducts[product] = true + } + + snapshot := fullSchemaSnapshotForTest(t) + for _, product := range snapshot.Catalog["products"].([]map[string]any) { + productID, _ := product["id"].(string) + if retiredProducts[productID] { + t.Errorf("retired product %q remains in the Schema catalog", productID) + } + } + for canonicalPath := range snapshot.Tools { + for product := range retiredProducts { + if canonicalPath == product || strings.HasPrefix(canonicalPath, product+".") { + t.Errorf("retired Schema tool %q remains under product %q", canonicalPath, product) + } + } + } +} diff --git a/internal/app/root.go b/internal/app/root.go index c35a2979..0827c5c2 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -30,6 +30,7 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" 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" @@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo return nil }, } + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) bindPersistentFlags(root, flags) @@ -935,25 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags)) mcpCmd.AddCommand(newMCPURLGroup(patCaller)) + navigationGroup := func(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + return command + } + hybridGroup := func(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + return command + } + utilityCommands := []*cobra.Command{ - newAuthCommand(patCaller), - newProfileCommand(), + navigationGroup(newAuthCommand(patCaller)), + navigationGroup(newProfileCommand()), newAPICommand(flags), - newSkillCommand(), - newCacheCommand(), + navigationGroup(newSkillCommand()), + hybridGroup(newCacheCommand()), newCatalogCommand(), - newConfigCommand(), + navigationGroup(newConfigCommand()), newDoctorCommand(), - newRecoveryCommand(), - newEventCommand(flags), - newAuditCommand(), + hybridGroup(newRecoveryCommand()), + navigationGroup(newEventCommand(flags)), + navigationGroup(newAuditCommand()), newCompletionCommand(root), newUpgradeCommand(), newVersionCommand(), newPluginCommand(), - usage.NewShortcutCommand(), + navigationGroup(usage.NewShortcutCommand()), schemaCmd, - mcpCmd, + navigationGroup(mcpCmd), } root.AddCommand(utilityCommands...) diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index f688c984..00900197 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { command.SilenceUsage = true command.SetArgs(tc.args) err := command.Execute() - if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) { - t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint) + var structured *apperrors.Error + if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation || + structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) { + t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint) } } diff --git a/internal/app/runner.go b/internal/app/runner.go index 36e78c1f..ed220d3c 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, "mcp_tool_error", "MCP tool returned a business error; check tool parameters and refer to skill documentation.", invocation.CanonicalProduct, + invocation.Tool, diag, ) logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag) @@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, "business_error", "The API returned a business-level error. Check required parameters and values.", invocation.CanonicalProduct, + invocation.Tool, diag, ) logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag) diff --git a/internal/app/schema_sheet_float_image_file_contract_test.go b/internal/app/schema_sheet_float_image_file_contract_test.go new file mode 100644 index 00000000..67fe086f --- /dev/null +++ b/internal/app/schema_sheet_float_image_file_contract_test.go @@ -0,0 +1,70 @@ +package app + +import ( + "testing" +) + +func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) { + payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), + "sheet.create_float_image", + "sheet.update_float_image", + ) + + create := payload.Tools["sheet.create_float_image"] + if create == nil { + t.Fatal("missing sheet.create_float_image") + } + if create["interface_mode"] != "mcp" { + t.Fatalf("create interface mode = %#v", create["interface_mode"]) + } + createRef, _ := create["interface_ref"].(map[string]any) + if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" { + t.Fatalf("create interface ref = %#v", createRef) + } + createDryRun, _ := create["dry_run"].(map[string]any) + if createDryRun["preview_kind"] != "request" { + t.Fatalf("create dry-run = %#v", createDryRun) + } + if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false { + t.Fatalf("create dry-run remote_reads = %#v", remoteReads) + } + createParameters, _ := create["parameters"].(map[string]any) + file, _ := createParameters["file"].(map[string]any) + src, _ := createParameters["src"].(map[string]any) + if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" { + t.Fatalf("create --file metadata = %#v", file) + } + if schemaContractString(file["property"]) != "" { + t.Fatalf("create --file leaked an RPC property: %#v", file["property"]) + } + if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" { + t.Fatalf("create --src compatibility metadata = %#v", src) + } + assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"}) + assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"}) + + update := payload.Tools["sheet.update_float_image"] + if update == nil { + t.Fatal("missing sheet.update_float_image") + } + updateDryRun, _ := update["dry_run"].(map[string]any) + if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" { + t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun) + } + updateParameters, _ := update["parameters"].(map[string]any) + updateFile, _ := updateParameters["file"].(map[string]any) + if schemaContractString(updateFile["property"]) != "" { + t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"]) + } + assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"}) + assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"}) + + root := NewRootCommand() + for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} { + command := exactCommandForTest(root, cliPath) + if command == nil || command.Flags().Lookup("file") == nil { + t.Fatalf("%s has no executable --file flag", cliPath) + } + } + +} diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index ed89cc80..13452541 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 415 + publicShortcutCount = 436 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 453 + schemaPublishedShortcutCount = 493 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 415 + publiclyDeliveredShortcutCount = 436 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { @@ -271,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) { } } +func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) { + tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand()) + requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"} + checked := 0 + for _, declared := range shortcut.All() { + if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) { + continue + } + if !strings.HasPrefix(declared.Command, "+datasource-") { + continue + } + if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" { + continue + } + checked++ + canonical := shortcutSchemaCanonical(declared) + tool := tools[canonical] + if tool == nil { + t.Fatalf("delivery schema --all is missing %s", canonical) + } + examples := schemaContractStringSlice(tool["examples"]) + if len(examples) == 0 { + t.Fatalf("%s has no delivered examples", canonical) + } + for _, example := range examples { + if !strings.Contains(example, "--source-config") { + continue + } + argv, err := cli.ParseAgentExampleArgv(example) + if err != nil { + t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err) + } + sourceConfig := schemaExampleFlagValue(argv, "source-config") + if sourceConfig == "" { + t.Errorf("%s example %q contains --source-config but has no value", canonical, example) + continue + } + var cfg map[string]any + if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil { + t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err) + continue + } + for _, member := range requiredSourceConfigMembers { + if _, ok := cfg[member]; !ok { + t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member) + } + } + } + } + if checked != 3 { + t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked) + } +} + func schemaExampleHasLongFlag(argv []string, names ...string) bool { for _, argument := range argv { for _, name := range names { @@ -282,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool { return false } +func schemaExampleFlagValue(argv []string, name string) string { + prefix := "--" + name + "=" + for _, argument := range argv { + if argument == "--"+name { + continue + } + if strings.HasPrefix(argument, prefix) { + return strings.TrimPrefix(argument, prefix) + } + } + // Value may be in the next argv entry: `--flag value` form. + for i := 0; i < len(argv)-1; i++ { + if argv[i] == "--"+name { + return argv[i+1] + } + } + return "" +} + func assertSchemaSummarySafety( t testing.TB, summaries map[string]map[string]any, diff --git a/internal/app/server_failure_classifier.go b/internal/app/server_failure_classifier.go index 978a06c1..673e4cb9 100644 --- a/internal/app/server_failure_classifier.go +++ b/internal/app/server_failure_classifier.go @@ -20,18 +20,50 @@ import ( ) type serverFailureClass struct { - message string - reason string - origin string - stage string - hint string - actions []string + message string + reason string + origin string + stage string + hint string + actions []string + operation string + retryable *bool } -func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) { +func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) { code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode)) detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail)) text := strings.ToLower(strings.TrimSpace(message)) + combined := text + " " + detail + + if code == "999" && + (strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) { + classified := serverFailureClass{ + message: message, + reason: "upstream_internal_error", + origin: "dingtalk_api", + stage: "upstream_execution", + hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。", + actions: []string{ + "检查目标资源的当前状态,确认本次操作是否已经生效", + "状态未确认前不要直接重试写操作", + "持续失败时携带 Trace ID 和 Server Code 联系服务端排查", + }, + } + if strings.EqualFold(strings.TrimSpace(serverKey), "todo") && + strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") { + retryable := false + classified.operation = "todo/create_personal_todo" + classified.retryable = &retryable + classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。" + classified.actions = []string{ + "查询近期由自己创建的待办,核对标题、执行人和截止时间", + "确认没有创建成功后再重新提交", + "持续失败时携带 Trace ID 和 Server Code 联系服务端排查", + } + } + return classified, true + } if code == "NETWORK_ERROR" || strings.Contains(detail, "statuscode.unavailable") || @@ -74,6 +106,7 @@ func newServerFailureAPIError( fallbackReason string, fallbackHint string, serverKey string, + tool string, diag apperrors.ServerDiagnostics, ) error { opts := []apperrors.Option{ @@ -84,7 +117,7 @@ func newServerFailureAPIError( apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"), apperrors.WithServerDiag(diag), } - if classified, ok := classifyServerFailure(message, diag); ok { + if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok { message = classified.message opts = append(opts, apperrors.WithReason(classified.reason), @@ -93,6 +126,12 @@ func newServerFailureAPIError( apperrors.WithHint(classified.hint), apperrors.WithActions(classified.actions...), ) + if classified.operation != "" { + opts = append(opts, apperrors.WithOperation(classified.operation)) + } + if classified.retryable != nil { + opts = append(opts, apperrors.WithRetryable(*classified.retryable)) + } } return apperrors.NewAPI(message, opts...) } diff --git a/internal/app/server_failure_classifier_test.go b/internal/app/server_failure_classifier_test.go index df1a104d..d6e0aabf 100644 --- a/internal/app/server_failure_classifier_test.go +++ b/internal/app/server_failure_classifier_test.go @@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable( "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{ TraceID: "trace-local", ServerErrorCode: "NETWORK_ERROR", @@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t "business_error", "check parameters", "chat", + "send_message", apperrors.ServerDiagnostics{ServerErrorCode: "1001"}, ) var typed *apperrors.Error @@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t } } +func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) { + serverSaysRetryable := true + err := newServerFailureAPIError( + "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)", + "business_error", + "The API returned a business-level error. Check required parameters and values.", + "todo", + "create_personal_todo", + apperrors.ServerDiagnostics{ + TraceID: "trace-todo-create", + ServerErrorCode: "999", + ServerRetryable: &serverSaysRetryable, + }, + ) + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" { + t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage) + } + if typed.Operation != "todo/create_personal_todo" { + t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted) + } + if !typed.RetryableSet || typed.Retryable { + t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable) + } + if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" { + t.Fatalf("diagnostics = %#v", typed.ServerDiag) + } + if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") { + t.Fatalf("hint = %q", typed.Hint) + } + for _, action := range typed.Actions { + if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") { + t.Fatalf("misleading action = %q", action) + } + } + + payload := multiProfileErrorPayload(err) + for key, want := range map[string]any{ + "reason": "upstream_internal_error", + "origin": "dingtalk_api", + "stage": "upstream_execution", + "retryable": false, + "trace_id": "trace-todo-create", + "server_error_code": "999", + } { + if got := payload[key]; got != want { + t.Errorf("payload[%q] = %#v, want %#v", key, got, want) + } + } + if _, ok := payload["execution_started"]; ok { + t.Fatalf("payload must keep execution_started unknown: %#v", payload) + } +} + func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) { err := newServerFailureAPIError( "business error: success=false", "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{}, ) var typed *apperrors.Error @@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"}, ) if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" { @@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics( "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{ TraceID: "trace-multi", ServerErrorCode: "NETWORK_ERROR", @@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted) } } + +func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID int `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": map[string]any{ + "structuredContent": map[string]any{ + "success": false, + "code": "999", + "trace_id": "trace-todo-replay", + "errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)", + }, + }, + }) + })) + defer server.Close() + + client := transport.NewClient(server.Client()) + client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")} + runner := &runtimeRunner{ + transport: client, + globalFlags: &GlobalFlags{Token: "local-test-token"}, + } + _, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{ + CanonicalProduct: "todo", + Tool: "create_personal_todo", + CanonicalPath: "todo.create_personal_todo", + Params: map[string]any{ + "PersonalTodoCreateVO": map[string]any{ + "subject": "fixture", + "executorIds": []string{"user-1"}, + }, + }, + }) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err) + } + if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" { + t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation) + } + if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil { + t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted) + } + if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" { + t.Fatalf("diagnostics = %#v", typed.ServerDiag) + } +} diff --git a/internal/app/whiteboard_shortcut_availability_test.go b/internal/app/whiteboard_shortcut_availability_test.go new file mode 100644 index 00000000..28256c8e --- /dev/null +++ b/internal/app/whiteboard_shortcut_availability_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) { + t.Helper() + for canonical, command := range map[string]string{ + "whiteboard.shortcut_query": "+query", + "whiteboard.shortcut_update": "+update", + } { + leaf, _, err := root.Find([]string{"whiteboard", command}) + if err != nil || leaf == nil || leaf.Name() != command { + t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err) + } else if leaf.Hidden || !leaf.Runnable() { + t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable()) + } + tool := tools[canonical] + if tool == nil { + t.Errorf("public %s missing from delivery Schema surface", canonical) + continue + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Errorf("%s availability=%q, want available", canonical, got) + } + if got := schemaContractString(tool["interface_mode"]); got != "composite" { + t.Errorf("%s interface_mode=%q, want composite", canonical, got) + } + if got := schemaContractString(tool["interface_reason"]); got == "" { + t.Errorf("%s missing composite adapter reason", canonical) + } + if tool["interface_ref"] != nil { + t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"]) + } + } +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 02ad2380..be9ae37b 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { fallback, fErr := p.refreshFromOrgSlot(ctx, data) if fErr != nil { logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr) + // The organization mirror may be absent for long-lived local logins + // that predate mirror publication. Recover from the legacy global + // slot before giving up. + if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil { + return recovered, nil + } return nil, rErr } if p.logger != nil { @@ -891,6 +897,123 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa return refreshed, nil } +func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) { + var exchangeErr *MCPTokenExchangeError + if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() { + return nil, refreshErr + } + if selected == nil { + return nil, refreshErr + } + logging.AuthDebug("auth.refresh.legacy_recovery.triggered", + "corp_id", strings.TrimSpace(selected.CorpID), + "user_id", strings.TrimSpace(selected.UserID), + "refresh_error_code", exchangeErr.Code, + ) + legacy, loadErr := tokenLoadKeychain() + if loadErr != nil { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) + return nil, refreshErr + } + if legacy == nil { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") + return nil, refreshErr + } + if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", + "step", "candidate_mismatch", + "legacy_corp_id", strings.TrimSpace(legacy.CorpID), + "legacy_user_id", strings.TrimSpace(legacy.UserID), + ) + return nil, refreshErr + } + recovered := *legacy + if strings.TrimSpace(recovered.UserID) == "" { + recovered.UserID = strings.TrimSpace(selected.UserID) + } + if strings.TrimSpace(recovered.UserName) == "" { + recovered.UserName = strings.TrimSpace(selected.UserName) + } + if recovered.IsAccessTokenValid() { + if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) + return nil, refreshErr + } + logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") + return &recovered, nil + } + if !recovered.IsRefreshTokenValid() { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") + return nil, refreshErr + } + if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") + return nil, refreshErr + } + if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) + return nil, refreshErr + } + refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered) + if recoverErr != nil { + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) + return nil, refreshErr + } + logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh") + return refreshed, nil +} + +func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool { + if selected == nil || legacy == nil { + return false + } + selectedCorpID := strings.TrimSpace(selected.CorpID) + legacyCorpID := strings.TrimSpace(legacy.CorpID) + if selectedCorpID == "" || legacyCorpID != selectedCorpID { + return false + } + selectedUserID := strings.TrimSpace(selected.UserID) + legacyUserID := strings.TrimSpace(legacy.UserID) + if legacyUserID != "" { + return legacyUserID == selectedUserID + } + return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID) +} + +func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool { + if strings.TrimSpace(corpID) == "" { + return false + } + cfg, err := tokenLoadProfiles(configDir) + if err != nil || cfg == nil { + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) + return false + } + profiles := profilesForCorpID(cfg, corpID) + if len(profiles) != 1 { + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", + "reason", "multi_profile", + "corp_id", strings.TrimSpace(corpID), + "profile_count", len(profiles), + ) + return false + } + profile := profiles[0] + if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) { + return true + } + profileUserID := "" + if profile != nil { + profileUserID = strings.TrimSpace(profile.UserID) + } + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", + "reason", "identity_mismatch", + "selected_user_id", strings.TrimSpace(userID), + "profile_user_id", profileUserID, + ) + return false +} + // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an // external host, exchanges it for tokens, and persists them. func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) { diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 5281270f..62bc7ab7 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR } } +func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039", + UserID: "user-v1039", + UserName: "V1039 User", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{ + Name: "V1039 User", + CorpID: selected.CorpID, + UserID: selected.UserID, + UserName: selected.UserName, + }}}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + var saved *TokenData + testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error { + copy := *data + saved = © + return nil + }) + + recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken { + t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy) + } + if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName { + t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName) + } + if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID { + t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered) + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-multi", + UserID: "user-v1039-a", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{ + {Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID}, + {Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"}, + }}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-blank-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{ + {Name: "Blank A", CorpID: selected.CorpID, UserID: ""}, + {Name: "Blank B", CorpID: selected.CorpID, UserID: ""}, + }}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-user-mismatch", + UserID: "user-v1039-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: "user-v1039-other", + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a token owned by a different user") + } +} + func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) { fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) expired := *fixture.blankToken @@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola } } } + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-single-mismatch", + UserID: "user-v1039-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{ + Name: "Other User", + CorpID: selected.CorpID, + UserID: "user-v1039-other", + }}}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-legacy-refresh", + UserID: "user-v1039", + UserName: "V1039 User", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "expired-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + refreshed := &TokenData{ + AccessToken: "refreshed-legacy-access", + RefreshToken: "rotated-legacy-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + refreshCalls := 0 + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + refreshCalls++ + if refreshCalls == 1 { + return nil, rejection + } + return refreshed, nil + }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + + recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if refreshCalls != 2 { + t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls) + } + if recovered.AccessToken != refreshed.AccessToken { + t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken) + } + if recovered.RefreshToken != refreshed.RefreshToken { + t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken) + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-profiles-error", + UserID: "user-v1039", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "shared-rejected-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-same-refresh", + UserID: "user-v1039", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "expired-legacy-global-access", + RefreshToken: selected.RefreshToken, + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) { + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + provider := NewOAuthProvider(t.TempDir(), nil) + + // nil selected must be rejected before any dereference. + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) { + t.Fatalf("nil selected error = %v, want original rejection", err) + } + + // A keychain load that returns (nil, nil) must be rejected before any dereference. + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil }) + selected := &TokenData{ + CorpID: "corp-v1039-nil-legacy", + UserID: "user-v1039", + Source: "mcp", + } + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("nil legacy error = %v, want original rejection", err) + } +} + +func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) { + provider := NewOAuthProvider(t.TempDir(), nil) + selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"} + + plainErr := errors.New("plain refresh failure") + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) { + t.Fatalf("plain error = %v, want original plain failure", err) + } +} + +func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) { + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + selected := &TokenData{ + CorpID: "corp-v1039-recovery-steps", + UserID: "user-v1039", + Source: "mcp", + } + + t.Run("save_failure", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "valid-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("save failure error = %v, want original rejection", err) + } + }) + + t.Run("refresh_expired", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "expired-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(-time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("expired refresh error = %v, want original rejection", err) + } + }) + + t.Run("refresh_error", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, errors.New("legacy refresh failed") + }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("legacy refresh error = %v, want original rejection", err) + } + }) + + t.Run("preflight_error", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("preflight error = %v, want original rejection", err) + } + }) +} + +func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) { + configDir := t.TempDir() + selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"} + + if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) { + t.Fatal("nil legacy accepted") + } + if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) { + t.Fatal("different corp accepted") + } + if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) { + t.Fatal("blank selected corp accepted") + } + + blankSelected := &TokenData{CorpID: selected.CorpID} + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil + }) + if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) { + t.Fatal("both blank user IDs should match only through the single-profile guard") + } + if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) { + t.Fatal("blank selected with non-blank legacy accepted") + } + + if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) { + t.Fatal("blank corp accepted by single-profile check") + } +} diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index b7bf4f50..fe2a7b65 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -10,6 +10,102 @@ package cli // generatedParamAliases is the per-command parameter-alias table reduced from // the reviewed concept dictionary. Each entry binds one runnable Cobra leaf. var generatedParamAliases = []ParamAliasEntry{ + { + CLIPath: "aisearch", + Aliases: map[string]string{ + "dimensions": "dimension", + "person-dimension": "dimension", + "person-name": "query", + "person-query": "query", + "search-by": "dimension", + "search-dimension": "dimension", + "search-term": "query", + }, + Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "job-number", "mobile", "open-dingtalk-id", "phone-number", "queries", "search-types", "time-range", "types", "user-id"}, + Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"}, + }, + { + CLIPath: "aisearch +search-person", + Aliases: map[string]string{ + "dimension": "dimensions", + "keyword": "query", + "keywords": "query", + "person-dimension": "dimensions", + "person-name": "query", + "person-query": "query", + "q": "query", + "search-by": "dimensions", + "search-dimension": "dimensions", + "search-term": "query", + "search-word": "query", + }, + Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "ids", "job-number", "mobile", "name", "open-dingtalk-id", "phone-number", "position-id", "queries", "search-types", "subject", "text", "time-range", "title", "type", "types", "user-id"}, + Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"}, + }, + { + CLIPath: "aisearch behavior", + Aliases: map[string]string{ + "action-type": "behavior-type", + "activity-type": "behavior-type", + "behavior-action": "behavior-type", + "chat-name": "chat-scope", + "content-queries": "queries", + "content-query": "queries", + "content-types": "types", + "conversation-name": "chat-scope", + "date-range": "time-range", + "group-name": "chat-scope", + "im-scope": "chat-scope", + "interaction-direction": "direction", + "interaction-flow": "direction", + "message-flow": "direction", + "period": "time-range", + "resource-types": "types", + "search-categories": "types", + "search-period": "time-range", + "search-terms": "queries", + "search-type-list": "types", + "time-window": "time-range", + "topic-queries": "queries", + "topics": "queries", + }, + Blocked: []string{"chat-id", "conversation-id", "created-at", "dimension", "dimensions", "document-id", "end", "file-id", "from", "group-id", "natural-language", "node-id", "open-conversation-id", "open-dingtalk-id", "question", "receiver", "sender", "since", "start", "to", "until", "update", "updated-at", "user-id", "write"}, + Ambiguous: []string{"action", "actor", "category", "chat", "conversation", "date", "destination", "group", "range", "scope", "source", "target", "time", "type"}, + }, + { + CLIPath: "aisearch enterprise", + Aliases: map[string]string{ + "content-queries": "queries", + "content-query": "queries", + "content-types": "types", + "date-range": "time-range", + "period": "time-range", + "resource-types": "types", + "search-categories": "types", + "search-period": "time-range", + "search-terms": "queries", + "search-type-list": "types", + "time-window": "time-range", + "topic-queries": "queries", + "topics": "queries", + }, + Blocked: []string{"action-type", "behavior-type", "chat-scope", "created-at", "dimension", "dimensions", "direction", "document-id", "end", "file-id", "from", "group-name", "node-id", "open-dingtalk-id", "receiver", "sender", "since", "start", "to", "until", "update", "updated-at", "user-id", "write"}, + Ambiguous: []string{"category", "date", "natural-language", "question", "range", "scope", "source", "time", "type"}, + }, + { + CLIPath: "aisearch person", + Aliases: map[string]string{ + "dimensions": "dimension", + "person-dimension": "dimension", + "person-name": "query", + "person-query": "query", + "search-by": "dimension", + "search-dimension": "dimension", + "search-term": "query", + }, + Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "ids", "job-number", "mobile", "open-dingtalk-id", "phone-number", "position-id", "queries", "search-types", "time-range", "types", "user-id"}, + Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"}, + }, { CLIPath: "aitable +advperm-disable", Aliases: map[string]string{ @@ -1759,12 +1855,326 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"id", "task-id"}, }, + { + CLIPath: "attendance +check-record", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "since": "start", + "staff-ids": "users", + "start-date": "start", + "start-time": "start", + "target-user-ids": "users", + "time-max": "end", + "time-min": "start", + "to": "end", + "until": "end", + "user-id-list": "users", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "date", "staff-id", "time", "uid", "user", "user-id", "userid"}, + }, { CLIPath: "attendance +check-result", Aliases: map[string]string{ - "user-ids": "users", + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "staff-ids": "users", + "start-date": "start", + "start-time": "start", + "take": "limit", + "target-user-ids": "users", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + "until": "end", + "user-id-list": "users", + "user-ids": "users", }, - Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"}, + Blocked: []string{"at-user-ids", "count", "cursor", "date", "page", "page-token", "staff-id", "time", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "attendance +get-adjustment-rule", + Aliases: map[string]string{ + "adjustment-rule-id": "adjustment-id", + "rule-id": "adjustment-id", + }, + Blocked: []string{"class-id", "group-id", "id", "overtime-id", "plan-id", "result-id"}, + }, + { + CLIPath: "attendance +get-approve-template", + Aliases: map[string]string{ + "approval-type": "type", + "biz-type": "type", + }, + Blocked: []string{"approval-types", "biz-types", "types"}, + }, + { + CLIPath: "attendance +get-checkin-record", + Aliases: map[string]string{ + "begin": "start", + "corp-id": "operator-corp-id", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "operator-company-id": "operator-corp-id", + "operator-id": "operator-staff-id", + "operator-user-id": "operator-staff-id", + "since": "start", + "start-date": "start", + "start-time": "start", + "target-user-ids": "staff-ids", + "time-max": "end", + "time-min": "start", + "to": "end", + "until": "end", + "user-id-list": "staff-ids", + "user-ids": "staff-ids", + "users": "staff-ids", + }, + Blocked: []string{"at-user-ids", "date", "name", "time", "to-user"}, + Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "attendance +get-leave-records", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "since": "start", + "staff-id": "user", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + "uid": "user", + "until": "end", + "user-id": "user", + "userid": "user", + "vacation-type-code": "leave-code", + }, + Blocked: []string{"at-user-ids", "date", "id", "leave-name", "leave-names", "name", "rule-id", "time", "to-user", "user-ids", "users", "vacation-name"}, + }, + { + CLIPath: "attendance +get-overtime-rule", + Aliases: map[string]string{ + "overtime-rule-id": "overtime-id", + "rule-id": "overtime-id", + }, + Blocked: []string{"adjustment-id", "class-id", "group-id", "id", "plan-id", "result-id"}, + }, + { + CLIPath: "attendance +get-schedule", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "since": "start", + "staff-ids": "users", + "start-date": "start", + "start-time": "start", + "target-user-ids": "users", + "time-max": "end", + "time-min": "start", + "to": "end", + "until": "end", + "user-id-list": "users", + "user-ids": "users", + "work-date-begin": "start", + "work-date-end": "end", + }, + Blocked: []string{"at-user-ids", "date", "staff-id", "time", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "attendance +get-self-setting", + Aliases: map[string]string{ + "scene": "setting-scene", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "name", "setting-item", "setting-type", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "attendance +get-summary", + Aliases: map[string]string{ + "attendance-date": "date", + "period": "stats-type", + "staff-id": "user", + "statistics-type": "stats-type", + "summary-type": "stats-type", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "end", "end-date", "name", "start", "start-date", "time", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "attendance +list-approve", + Aliases: map[string]string{ + "approval-types": "types", + "begin": "start", + "biz-types": "types", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "since": "start", + "staff-ids": "users", + "start-date": "start", + "start-time": "start", + "target-user-ids": "users", + "time-max": "end", + "time-min": "start", + "to": "end", + "until": "end", + "user-id-list": "users", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "date", "staff-id", "time", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "attendance +query-report-data", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "report-column-ids": "columns", + "since": "start", + "staff-ids": "users", + "start-date": "start", + "start-time": "start", + "target-user-ids": "users", + "time-max": "end", + "time-min": "start", + "to": "end", + "until": "end", + "user-id-list": "users", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "column-names", "date", "leave-names", "staff-id", "time", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "attendance +search-adjustment-rule", + Aliases: map[string]string{ + "current-page": "page", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, + }, + { + CLIPath: "attendance +search-class", + Aliases: map[string]string{ + "class-filter-type": "filter-type", + "class-scope": "filter-type", + "current-page": "page", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, + }, + { + CLIPath: "attendance +search-group", + Aliases: map[string]string{ + "attendance-group-type": "type", + "current-page": "page", + "group-type": "type", + "include-ble": "query-ble", + "include-position": "query-position", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, + }, + { + CLIPath: "attendance +search-overtime-rule", + Aliases: map[string]string{ + "current-page": "page", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, }, { CLIPath: "attendance check result", @@ -3814,6 +4224,13 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, + { + CLIPath: "contact +by-mobile", + Aliases: map[string]string{ + "org-user-mobile": "mobile", + }, + Blocked: []string{"email", "login-id", "user-id"}, + }, { CLIPath: "contact +dept-members", Aliases: map[string]string{ @@ -3834,6 +4251,23 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"department-id", "dept", "dept-id", "name", "query"}, }, + { + CLIPath: "contact +list-followings", + Aliases: map[string]string{ + "open-dingtalk-id": "open-id", + "open-dingtalkid": "open-id", + }, + Blocked: []string{"email", "mobile", "name", "open-dingtalk-ids", "phone", "query", "staff-id", "user-id", "userid"}, + Ambiguous: []string{"id", "person", "user"}, + }, + { + CLIPath: "contact +list-role-members", + Aliases: map[string]string{ + "label-id": "id", + "role-id": "id", + }, + Blocked: []string{"dept", "dept-id", "ids", "names", "staff-id", "user-id"}, + }, { CLIPath: "contact +list-sub-depts", Aliases: map[string]string{ @@ -3845,6 +4279,28 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"department-ids", "dept-ids", "depts", "name", "query"}, }, + { + CLIPath: "contact +lookup", + Aliases: map[string]string{ + "employee-name": "name", + "flower-name": "name", + "person-name": "name", + "staff-name": "name", + "user-name": "name", + }, + Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"}, + }, + { + CLIPath: "contact +org", + Aliases: map[string]string{ + "employee-name": "name", + "flower-name": "name", + "person-name": "name", + "staff-name": "name", + "user-name": "name", + }, + Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"}, + }, { CLIPath: "contact +resolve-dept", Aliases: map[string]string{ @@ -3856,6 +4312,13 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"subject", "text", "title"}, }, + { + CLIPath: "contact +search-mobile", + Aliases: map[string]string{ + "org-user-mobile": "mobile", + }, + Blocked: []string{"email", "login-id", "user-id"}, + }, { CLIPath: "contact +search-user", Aliases: map[string]string{ @@ -3866,6 +4329,65 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"name", "subject", "text", "title"}, }, + { + CLIPath: "contact +team", + Aliases: map[string]string{ + "employee-name": "name", + "flower-name": "name", + "person-name": "name", + "staff-name": "name", + "user-name": "name", + }, + Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"}, + }, + { + CLIPath: "contact account create", + Aliases: map[string]string{ + "account-login-id": "login-id", + "department-ids": "dept-ids", + "depts": "dept-ids", + "email-address": "email", + "employee-name": "org-user-name", + "login-account": "login-id", + "mail-address": "email", + "mobile": "org-user-mobile", + "organization-user-name": "org-user-name", + "staff-name": "org-user-name", + }, + Blocked: []string{"creator-username", "department-id", "department-memberships-json", "dept", "dept-id", "depts-json", "name", "nick", "org-name", "query", "staff-id", "user-id"}, + }, + { + CLIPath: "contact account update", + Aliases: map[string]string{ + "department-assignments-json": "depts", + "department-memberships-json": "depts", + "employee-name": "org-user-name", + "organization-user-name": "org-user-name", + "profile-image-file-id": "avatar-file-id", + "staff-id": "user-id", + "staff-name": "org-user-name", + "uid": "user-id", + "user": "user-id", + }, + Blocked: []string{"at-user-ids", "creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "file", "name", "node-id", "org-name", "ownness-text", "parent", "parent-id", "to-user", "user-ids", "users"}, + Ambiguous: []string{"manager", "owner-id"}, + }, + { + CLIPath: "contact dept create", + Aliases: map[string]string{ + "new-parent-dept-id": "parent", + "parent-department-id": "parent", + "parent-dept-id": "parent", + }, + Blocked: []string{"department-ids", "dept", "dept-id", "dept-ids", "depts"}, + }, + { + CLIPath: "contact dept get-info", + Aliases: map[string]string{ + "department-id": "dept", + }, + Blocked: []string{"department", "department-ids", "depts", "parent", "parent-id"}, + }, { CLIPath: "contact dept list-children", Aliases: map[string]string{ @@ -3876,18 +4398,180 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"department-ids", "depts", "name", "query"}, }, + { + CLIPath: "contact dept list-members", + Aliases: map[string]string{ + "department-ids": "depts", + }, + Blocked: []string{"department-id", "dept", "name", "query"}, + }, + { + CLIPath: "contact dept search", + Aliases: map[string]string{ + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "contact dept update", + Aliases: map[string]string{ + "new-parent-dept-id": "parent", + "parent-department-id": "parent", + "parent-dept-id": "parent", + "target-department-id": "dept", + "target-dept-id": "dept", + }, + Blocked: []string{"department-ids", "depts"}, + Ambiguous: []string{"department", "target-id"}, + }, + { + CLIPath: "contact label get", + Aliases: map[string]string{ + "label-names": "names", + "role-names": "names", + }, + Blocked: []string{"dept-id", "id", "label-id", "role-id", "user-id"}, + }, + { + CLIPath: "contact label list-members", + Blocked: []string{"dept", "dept-id", "ids", "names", "staff-id", "user-id"}, + }, + { + CLIPath: "contact org create", + Aliases: map[string]string{ + "company-name": "org-name", + "corp-name": "org-name", + "creator-name": "creator-username", + "founder-name": "creator-username", + "organization-name": "org-name", + }, + Blocked: []string{"login-id", "user-id"}, + Ambiguous: []string{"name"}, + }, + { + CLIPath: "contact user dismission search", + Aliases: map[string]string{ + "begin": "start", + "current-page": "page", + "department-ids": "depts", + "dept-ids": "depts", + "employee-name": "name", + "end-date": "end", + "end-time": "end", + "flower-name": "name", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "person-name": "name", + "since": "start", + "size": "limit", + "staff-name": "name", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + "user-name": "name", + }, + Blocked: []string{"count", "cursor", "date", "department-id", "department-memberships-json", "dept", "dept-id", "dept-list-json", "offset", "page-index", "page-token", "query", "time"}, + }, + { + CLIPath: "contact user get", + Aliases: map[string]string{ + "member-user-ids": "ids", + "staff-ids": "ids", + "users": "ids", + }, + Blocked: []string{"at-user-ids", "staff-id", "uid", "user"}, + }, + { + CLIPath: "contact user invite", + Aliases: map[string]string{ + "department-assignments-json": "depts", + "department-memberships-json": "depts", + "employee-name": "org-user-name", + "mobile": "org-user-mobile", + "organization-user-name": "org-user-name", + "staff-name": "org-user-name", + }, + Blocked: []string{"creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "email", "login-id", "nick", "org-name", "parent", "parent-id", "user-id"}, + }, { CLIPath: "contact user profile get", Aliases: map[string]string{ - "id": "staff-id", - "ids": "staff-id", - "uid": "staff-id", - "user": "staff-id", - "user-id": "staff-id", - "userid": "staff-id", + "field-code-list": "fields", + "field-codes": "fields", + "id": "staff-id", + "ids": "staff-id", + "profile-fields": "fields", + "roster-fields": "fields", + "uid": "staff-id", + "user": "staff-id", + "user-id": "staff-id", + "userid": "staff-id", + }, + Blocked: []string{"at-user-ids", "name", "output-fields", "staff-ids", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "contact user search", + Aliases: map[string]string{ + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "contact user search-mobile", + Aliases: map[string]string{ + "org-user-mobile": "mobile", + }, + Blocked: []string{"email", "login-id", "user-id"}, + }, + { + CLIPath: "contact user update", + Aliases: map[string]string{ + "department-assignments-json": "depts", + "department-memberships-json": "depts", + "employee-name": "org-user-name", + "organization-user-name": "org-user-name", + "staff-id": "user-id", + "staff-name": "org-user-name", + "uid": "user-id", + "user": "user-id", + }, + Blocked: []string{"at-user-ids", "creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "name", "nick", "org-name", "parent", "parent-id", "to-user", "user-ids", "users"}, + Ambiguous: []string{"manager", "owner-id"}, + }, + { + CLIPath: "contact user update-ownness", + Aliases: map[string]string{ + "personal-status": "ownness-text", + "staff-id": "user-id", + "status": "ownness-text", + "status-text": "ownness-text", + "uid": "user-id", + "user": "user-id", }, Blocked: []string{"at-user-ids", "name", "to-user", "user-ids", "users"}, }, + { + CLIPath: "contact user update-self", + Aliases: map[string]string{ + "profile-image-file-id": "avatar-file-id", + }, + Blocked: []string{"file", "name", "node-id", "org-user-name", "ownness-text", "user-id"}, + }, { CLIPath: "dev app get", Aliases: map[string]string{ @@ -3899,24 +4583,65 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "devdoc article search", Aliases: map[string]string{ - "current-page": "page", - "limit": "size", - "max-result": "size", - "max-results": "size", - "page-no": "page", - "page-num": "page", - "page-size": "size", - "per-page": "size", - "take": "size", - "top": "size", + "current-page": "page", + "keywords": "query", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "q": "query", + "query-text": "query", + "results-per-page": "size", + "search-term": "query", + "search-word": "query", + "take": "size", + "top": "size", }, - Blocked: []string{"count", "cursor", "page-index", "page-token"}, + Blocked: []string{"context", "count", "cursor", "doc-id", "document-id", "error-code", "error-message", "file-id", "name", "next-cursor", "next-token", "node-id", "offset", "page-index", "page-token", "request-id", "space-id", "subject", "text", "title", "total", "trace-id", "workspace-id"}, + Ambiguous: []string{"id", "type"}, + }, + { + CLIPath: "ding +list", + Aliases: map[string]string{ + "message-type": "type", + "next-cursor": "cursor", + }, + Blocked: []string{"next-token", "offset", "page", "page-token", "remind-type", "status"}, + }, + { + CLIPath: "ding +recall-personal", + Aliases: map[string]string{ + "ding-id": "id", + "open-ding-id": "id", + }, + Blocked: []string{"message-id", "request-id", "robot", "robot-code", "task-id", "uuid"}, }, { CLIPath: "ding +receiver-status", Aliases: map[string]string{ - "id": "ding-id", + "id": "ding-id", + "open-ding-id": "ding-id", }, + Blocked: []string{"message-id", "request-id", "task-id", "uuid"}, + }, + { + CLIPath: "ding +send-personal", + Aliases: map[string]string{ + "body": "content", + "idempotency-key": "uuid", + "message-content": "content", + "open-dingtalk-ids": "users", + "receiver-open-dingtalk-ids": "users", + "recipient-open-dingtalk-ids": "users", + "remind-type": "type", + "request-id": "uuid", + "text": "content", + }, + Blocked: []string{"ding-id", "id", "message-type", "name", "open-ding-id", "open-dingtalk-id", "receiver-user-ids", "recipient-user-ids", "robot", "robot-code", "staff-id", "staff-ids", "status", "title", "user", "user-id", "user-ids"}, }, { CLIPath: "ding message receiver-status", @@ -4243,7 +4968,7 @@ var generatedParamAliases = []ParamAliasEntry{ "start-block": "start-block-id", "url": "node", }, - Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"}, Ambiguous: []string{"block-id"}, }, { @@ -4859,7 +5584,7 @@ var generatedParamAliases = []ParamAliasEntry{ "output-path": "output", "save-path": "output", }, - Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, + Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"}, Ambiguous: []string{"block-id"}, }, { @@ -5723,7 +6448,7 @@ var generatedParamAliases = []ParamAliasEntry{ "top": "limit", "wiki-workspace-id": "workspace", }, - Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"}, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"}, }, { CLIPath: "drive permission remove", @@ -6005,15 +6730,371 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"knowledge-base-id", "limit", "page-size", "part-size", "size", "space", "wiki-workspace-id", "workspace", "workspace-id"}, }, + { + CLIPath: "hrbrain profile career", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "staff-no": "work-no", + }, + Blocked: []string{"employee-id", "end", "end-date", "job-level", "position", "staff-id", "staff-ids", "start", "start-date", "user-id", "work-nos", "year"}, + Ambiguous: []string{"date", "employee", "id", "name", "time", "user"}, + }, + { + CLIPath: "hrbrain profile labels", + Aliases: map[string]string{ + "all-labels": "all-label", + "employee-nos": "staff-ids", + "staff-nos": "staff-ids", + "work-nos": "staff-ids", + }, + Blocked: []string{"employee-id", "employee-ids", "employee-no", "staff-id", "staff-no", "user-id", "user-ids", "work-no"}, + Ambiguous: []string{"employee", "id", "label", "name", "tag", "user"}, + }, + { + CLIPath: "hrbrain profile metadata", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "staff-no": "work-no", + }, + Blocked: []string{"data-queries", "employee-id", "model-code", "module", "section", "staff-id", "staff-ids", "user-id", "work-nos"}, + Ambiguous: []string{"employee", "field", "id", "name", "user"}, + }, + { + CLIPath: "hrbrain profile performance", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "staff-no": "work-no", + }, + Blocked: []string{"cycle", "employee-id", "end", "end-date", "rating", "score", "staff-id", "staff-ids", "start", "start-date", "user-id", "work-nos", "year"}, + Ambiguous: []string{"date", "employee", "id", "level", "name", "time", "user"}, + }, + { + CLIPath: "hrbrain profile query", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "module-queries": "data-queries", + "profile-queries": "data-queries", + "staff-no": "work-no", + }, + Blocked: []string{"data-query", "employee-id", "model-code", "module", "section", "staff-id", "staff-ids", "user-id", "work-nos"}, + Ambiguous: []string{"employee", "field", "id", "name", "query", "user"}, + }, + { + CLIPath: "hrbrain search employees", + Aliases: map[string]string{ + "current-page": "page", + "department-name": "dept-name", + "job-title": "position-name", + "keywords": "keyword", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "position-level": "job-level", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "size": "page-size", + "take": "page-size", + "talent-pool-code": "pool-code", + "top": "page-size", + }, + Blocked: []string{"count", "cursor", "data-queries", "department-id", "dept-id", "employee-id", "order-by", "origin-json", "page-index", "page-token", "pool-id", "staff-id", "staff-ids", "subject", "talent-pool-id", "text", "user-id", "work-no"}, + Ambiguous: []string{"code", "department", "grade", "id", "level", "name", "pool", "position", "title", "user"}, + }, + { + CLIPath: "hrbrain search employees-structured", + Aliases: map[string]string{ + "criteria-json": "origin-json", + "current-page": "page", + "filter-json": "origin-json", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "result-fields-json": "fields", + "return-fields-json": "fields", + "search-json": "origin-json", + "size": "page-size", + "sort-by": "order-by", + "sort-fields": "order-by", + "take": "page-size", + "top": "page-size", + }, + Blocked: []string{"count", "cursor", "department-name", "dept-name", "field-name", "job-level", "keyword", "operator", "operators", "page-index", "page-token", "pool-code", "position-name", "query", "staff-ids", "work-no"}, + Ambiguous: []string{"criteria", "field", "filter", "id", "name", "order", "sort", "type"}, + }, + { + CLIPath: "hrbrain search fields", + Blocked: []string{"dept-name", "field-name", "job-level", "keyword", "operator", "operators", "order-by", "origin-json", "page", "page-size", "pool-code", "position-name", "query", "staff-ids", "work-no"}, + Ambiguous: []string{"criteria", "field", "filter", "id", "name", "type"}, + }, + { + CLIPath: "hrbrain talent-pool detail", + Aliases: map[string]string{ + "talent-pool-code": "pool-code", + }, + Blocked: []string{"creator", "employee-id", "keyword", "labels", "page", "page-size", "pool-id", "query", "staff-ids", "talent-pool-id", "work-no"}, + Ambiguous: []string{"code", "id", "name", "pool", "type"}, + }, + { + CLIPath: "hrbrain talent-pool employees", + Aliases: map[string]string{ + "current-page": "page", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "size": "page-size", + "take": "page-size", + "talent-pool-code": "pool-code", + "top": "page-size", + }, + Blocked: []string{"count", "creator", "cursor", "dept-name", "employee-id", "job-level", "keyword", "labels", "page-index", "page-token", "pool-id", "position-name", "query", "staff-ids", "talent-pool-id", "work-no"}, + Ambiguous: []string{"code", "id", "name", "pool", "status"}, + }, + { + CLIPath: "hrbrain talent-pool list", + Aliases: map[string]string{ + "current-page": "page", + "keywords": "keyword", + "label-list": "labels", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "size": "page-size", + "take": "page-size", + "top": "page-size", + }, + Blocked: []string{"count", "cursor", "employee-id", "name", "page-index", "page-token", "pool-code", "pool-id", "staff-ids", "subject", "talent-pool-id", "text", "title", "user-id", "work-no"}, + Ambiguous: []string{"code", "creator-id", "id", "label-name", "pool", "type", "user"}, + }, + { + CLIPath: "live stream list", + Blocked: []string{"anchor-id", "conversation-id", "cover-url", "create", "creator-id", "cursor", "end", "end-live", "end-time", "from", "group-id", "keyword", "limit", "live-id", "offset", "order", "page", "page-size", "page-token", "playback", "query", "record", "room-id", "size", "sort", "start", "start-live", "start-time", "state", "status", "stream-id", "title", "to", "user-id"}, + Ambiguous: []string{"date", "id", "owner", "time", "type", "user"}, + }, + { + CLIPath: "mail +contact-list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + }, { CLIPath: "mail +find-mail-user", Aliases: map[string]string{ - "keyword": "query", - "keywords": "query", - "q": "query", - "search-word": "query", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", }, - Blocked: []string{"name", "subject", "text", "title"}, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "mail +folder-list", + Aliases: map[string]string{ + "folder-id": "folder", + }, + Blocked: []string{"calendar-id", "name", "node-id"}, + }, + { + CLIPath: "mail +message", + Aliases: map[string]string{ + "mail-id": "id", + "message-id": "id", + }, + Blocked: []string{"attachment-id", "conversation-id", "internet-message-id", "thread-id"}, + }, + { + CLIPath: "mail +messages", + Aliases: map[string]string{ + "mail-ids": "ids", + "message-ids": "ids", + }, + Blocked: []string{"conversation-id", "conversation-ids", "mail-id", "message-id", "thread-id", "thread-ids"}, + }, + { + CLIPath: "mail +recent-mail", + Aliases: map[string]string{ + "folder-id": "folder", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"calendar-id", "count", "name", "node-id", "offset", "page"}, + }, + { + CLIPath: "mail +search-mail", + Aliases: map[string]string{ + "limit": "size", + "max-result": "size", + "max-results": "size", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "size", + "page-token": "cursor", + "per-page": "size", + "take": "size", + "top": "size", + }, + Blocked: []string{"count", "date", "folder", "folder-id", "from", "from-address", "has-attachments", "offset", "page", "sender", "subject", "to-address"}, + }, + { + CLIPath: "mail +template-list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + }, + { + CLIPath: "mail +thread", + Aliases: map[string]string{ + "conversation-id": "id", + "thread-id": "id", + }, + Blocked: []string{"mail-id", "mail-ids", "message-id", "message-ids"}, + }, + { + CLIPath: "mail +thread-list", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "folder-id": "folder", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"calendar-id", "count", "date", "name", "node-id", "offset", "page", "time"}, + }, + { + CLIPath: "mail +triage", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "date", "folder", "folder-id", "from", "from-address", "has-attachments", "offset", "page", "sender", "subject", "to-address"}, + }, + { + CLIPath: "mail +unread-mail", + Aliases: map[string]string{ + "limit": "size", + "max-result": "size", + "max-results": "size", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "size", + "page-token": "cursor", + "per-page": "size", + "take": "size", + "top": "size", + }, + Blocked: []string{"count", "keyword", "offset", "page", "query", "sender", "subject"}, + }, + { + CLIPath: "mail +user-search", + Aliases: map[string]string{ + "keywords": "keyword", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, }, { CLIPath: "mail folder update", @@ -6052,6 +7133,123 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"name", "subject", "text", "title"}, }, + { + CLIPath: "markdown create", + Aliases: map[string]string{ + "body": "content", + "dingdrive-space-id": "space-id", + "drive-space-id": "space-id", + "file-name": "name", + "filename": "name", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "local-file": "file", + "markdown-content": "content", + "plan-only": "dry-run", + "preview-only": "dry-run", + "source-file": "file", + "storage-space-id": "space-id", + "text": "content", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "doc-id", "document-id", "file-id", "node", "node-id", "output-path", "pattern", "regex", "replacement", "title", "url", "version", "version2"}, + Ambiguous: []string{"content-file", "destination", "id", "path", "space"}, + }, + { + CLIPath: "markdown diff", + Aliases: map[string]string{ + "base-version": "version", + "context-lines": "context", + "dentry-uuid": "node", + "diff-context": "context", + "from-version": "version", + "left-version": "version", + "local-file": "file", + "plan-only": "dry-run", + "preview-only": "dry-run", + "right-version": "version2", + "source-file": "file", + "surrounding-lines": "context", + "target-version": "version2", + "to-version": "version2", + "version-no": "version", + "version-number": "version", + }, + Blocked: []string{"body", "content", "content-file", "folder", "folder-id", "name", "output-path", "pattern", "regex", "revision", "space-id", "version-id", "workspace", "workspace-id"}, + Ambiguous: []string{"destination", "left", "path", "right"}, + }, + { + CLIPath: "markdown fetch", + Aliases: map[string]string{ + "dentry-uuid": "node", + "destination-path": "output", + "dingdrive-space-id": "space-id", + "document-id": "node", + "download-to": "output", + "drive-space-id": "space-id", + "knowledge-base-id": "workspace", + "output-file": "output", + "output-path": "output", + "save-path": "output", + "save-to": "output", + "storage-space-id": "space-id", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"content", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "name", "parent-id", "pattern", "regex", "url", "version", "version2"}, + Ambiguous: []string{"destination", "path", "space"}, + }, + { + CLIPath: "markdown overwrite", + Aliases: map[string]string{ + "body": "content", + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "document-id": "node", + "drive-space-id": "space-id", + "file-name": "name", + "filename": "name", + "knowledge-base-id": "workspace", + "local-file": "file", + "markdown-content": "content", + "no-write": "dry-run", + "preview-only": "dry-run", + "show-diff": "dry-run", + "source-file": "file", + "storage-space-id": "space-id", + "text": "content", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"content-file", "dentry-id", "folder", "folder-id", "output-path", "parent-id", "pattern", "regex", "replacement", "title", "url", "version", "version2"}, + Ambiguous: []string{"destination", "id", "path", "space"}, + }, + { + CLIPath: "markdown patch", + Aliases: map[string]string{ + "dentry-uuid": "node", + "dingdrive-space-id": "space-id", + "document-id": "node", + "drive-space-id": "space-id", + "find-text": "pattern", + "knowledge-base-id": "workspace", + "match-pattern": "pattern", + "new-text": "content", + "no-write": "dry-run", + "old-text": "pattern", + "preview-only": "dry-run", + "regexp": "regex", + "regular-expression": "regex", + "replace-with": "content", + "replacement": "content", + "replacement-text": "content", + "search-pattern": "pattern", + "show-diff": "dry-run", + "storage-space-id": "space-id", + "use-regex": "regex", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "name", "output-path", "parent-id", "query", "replacement-file", "url", "version", "version2"}, + Ambiguous: []string{"body", "destination", "id", "path", "space", "text", "value"}, + }, { CLIPath: "minutes +action-items", Aliases: map[string]string{ @@ -6655,6 +7853,128 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "minutes upload create", Blocked: []string{"file", "file-path", "path", "template-ids"}, }, + { + CLIPath: "oa +list-cc", + Aliases: map[string]string{ + "current-page": "page", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, + }, + { + CLIPath: "oa +list-executed", + Aliases: map[string]string{ + "current-page": "page", + "keyword": "query", + "keywords": "query", + "page-no": "page", + "page-num": "page", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-size", "page-token", "subject", "text", "title"}, + }, + { + CLIPath: "oa +list-forms", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "offset", "page"}, + }, + { + CLIPath: "oa +list-pending", + Aliases: map[string]string{ + "begin": "start", + "current-page": "page", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "date", "name", "offset", "page-index", "page-token", "subject", "text", "time", "title"}, + }, + { + CLIPath: "oa +list-submitted", + Aliases: map[string]string{ + "current-page": "page", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, + }, + { + CLIPath: "oa +my-initiated", + Aliases: map[string]string{ + "current-page": "page", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "name", "page-index", "page-token", "subject", "text", "title"}, + }, { CLIPath: "oa +search-forms", Aliases: map[string]string{ @@ -6675,6 +7995,136 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"name", "subject", "text", "title"}, }, + { + CLIPath: "pat +browser-policy", + Aliases: map[string]string{ + "allow-browser": "enabled", + "browser-enabled": "enabled", + "open-browser": "enabled", + }, + Blocked: []string{"api-scope", "app-id", "app-key", "app-secret", "block-browser", "browser-disabled", "deny-browser", "disable-browser", "domain", "domains", "grant-mode", "grant-type", "oauth-scope", "permission", "permissions", "product", "products", "recommend", "recommended", "scope", "scopes", "session-id"}, + Ambiguous: []string{"agent", "agent-id", "browser", "mode", "policy", "state", "value"}, + }, + { + CLIPath: "pat browser-policy", + Aliases: map[string]string{ + "allow-browser": "enabled", + "browser-enabled": "enabled", + "open-browser": "enabled", + }, + Blocked: []string{"api-scope", "app-id", "app-key", "app-secret", "block-browser", "browser-disabled", "deny-browser", "disable-browser", "domain", "domains", "grant-mode", "grant-type", "oauth-scope", "permission", "permissions", "product", "products", "recommend", "recommended", "scope", "scopes", "session-id"}, + Ambiguous: []string{"agent", "agent-id", "browser", "mode", "policy", "state", "value"}, + }, + { + CLIPath: "pat chmod", + Aliases: map[string]string{ + "authorization-mode": "grant-type", + "authorization-session-id": "session-id", + "domain-code": "domain", + "domain-codes": "domains", + "grant-mode": "grant-type", + "pat-session-id": "session-id", + "product-code": "product", + "product-codes": "products", + "recommended": "recommend", + }, + Blocked: []string{"all", "all-scopes", "allow-browser", "api-scope", "app-id", "app-key", "app-secret", "browser-enabled", "browser-policy", "confirm", "confirmed", "disable-browser", "force", "oauth-scope", "open-browser", "permission", "permission-scope", "permission-scopes", "permissions", "scope", "scopes"}, + Ambiguous: []string{"action", "agent", "agent-id", "id", "mode", "resource", "service", "services", "session", "type"}, + }, + { + CLIPath: "report +inbox-list", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "from-staff-ids": "sender-user-ids", + "limit": "size", + "max-result": "size", + "max-results": "size", + "max-time": "end", + "min-time": "start", + "page-size": "size", + "per-page": "size", + "sender-staff-ids": "sender-user-ids", + "since": "start", + "start-date": "start", + "start-time": "start", + "take": "size", + "time-max": "end", + "time-min": "start", + "top": "size", + }, + Blocked: []string{"count", "date", "page", "sender-id", "sender-staff-id", "sender-user-id", "staff-id", "staff-ids", "time", "user-id", "user-ids"}, + }, + { + CLIPath: "report +outbox-list", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "limit": "size", + "max-result": "size", + "max-results": "size", + "max-time": "end", + "min-time": "start", + "modified-from": "modified-start", + "modified-to": "modified-end", + "name": "template-name", + "page-size": "size", + "per-page": "size", + "report-template-name": "template-name", + "since": "start", + "start-date": "start", + "start-time": "start", + "take": "size", + "time-max": "end", + "time-min": "start", + "top": "size", + "update-end": "modified-end", + "update-start": "modified-start", + "updated-end": "modified-end", + "updated-start": "modified-start", + }, + Blocked: []string{"count", "date", "page", "report-template-id", "template-id", "template-type", "time"}, + Ambiguous: []string{"template"}, + }, + { + CLIPath: "report +report-latest", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "name": "keyword", + "report-template-name": "keyword", + "since": "start", + "start-date": "start", + "start-time": "start", + "template-name": "keyword", + "time-max": "end", + "time-min": "start", + }, + Blocked: []string{"date", "modified-end", "modified-start", "report-template-id", "template-id", "time"}, + Ambiguous: []string{"template"}, + }, + { + CLIPath: "report +template-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"report-template-id", "subject", "template-id", "text", "title"}, + Ambiguous: []string{"name", "template"}, + }, { CLIPath: "report list", Aliases: map[string]string{ @@ -6693,6 +8143,33 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "report outbox list", Blocked: []string{"template-type"}, }, + { + CLIPath: "sheet +list-sheets", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "node-id": "node", + "url": "node", + }, + Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"}, + }, + { + CLIPath: "sheet +read", + Aliases: map[string]string{ + "dentry-uuid": "node", + "doc": "node", + "doc-id": "node", + "document-id": "node", + "file-id": "node", + "node-id": "node", + "url": "node", + "worksheet-id": "sheet-id", + }, + Blocked: []string{"a1-ranges", "block-id", "cell-ranges", "comment-id", "comment-key", "dentry-id", "destination-range", "folder", "folder-id", "id", "job-id", "name", "parent-id", "ranges", "revision", "role", "source-range", "source-sheet-id", "space-id", "start-cell", "target-range", "target-sheet-id", "task-id", "template-id", "title", "version", "workspace", "workspace-id"}, + }, { CLIPath: "todo +assign", Aliases: map[string]string{ @@ -7423,7 +8900,7 @@ var generatedParamAliases = []ParamAliasEntry{ "top": "limit", "wiki-workspace-id": "workspace", }, - Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"}, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"}, Ambiguous: []string{"id"}, }, { diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 0563dead..e434a422 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -3,58 +3,58 @@ "version": 1, "morphological_rules": { - "kebab_camel_equivalence": {"desc": "--page-size == --pageSize", "enabled": true}, - "separator_normalization": {"desc": "-, _, . are equivalent separators", "enabled": true}, - "trailing_id_tolerance": {"desc": "--base tolerates --base-id when only one is a real flag on the command", "enabled": true, "guard": "the two must not both be real flags with different semantics"}, - "pluralization": {"desc": "--id<->--ids, --user<->--users", "enabled": false, "reason": "singular/list semantics can differ; handled by concept+intersection or command override instead"} + "kebab_camel_equivalence": {"desc":"--page-size == --pageSize","enabled":true}, + "separator_normalization": {"desc":"-, _, . are equivalent separators","enabled":true}, + "trailing_id_tolerance": {"desc":"--base tolerates --base-id when only one is a real flag on the command","enabled":true,"guard":"the two must not both be real flags with different semantics"}, + "pluralization": {"desc":"--id<->--ids, --user<->--users","enabled":false,"reason":"singular/list semantics can differ; handled by concept+intersection or command override instead"} }, "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","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template 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","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"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 +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","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","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"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","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"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":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","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","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space 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","minutes +summary","minutes update summary","todo +comment","todo comment add"],"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","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"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","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"}, + "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","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template 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","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","mail +user-search","oa +list-cc","oa +list-executed","oa +list-pending","oa +list-submitted","oa +my-initiated","report +template-search","aisearch +search-person","contact dept search","contact user search","devdoc article search","hrbrain talent-pool list","hrbrain search employees"],"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 +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","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","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search","attendance +check-result","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","mail +contact-list","mail +find-mail-user","mail +recent-mail","mail +search-mail","mail +template-list","mail +thread-list","mail +unread-mail","mail +user-search","mail +triage","oa +list-cc","oa +list-forms","oa +list-pending","oa +list-submitted","oa +my-initiated","report +inbox-list","report +outbox-list","contact user dismission search","hrbrain talent-pool list","hrbrain talent-pool employees","hrbrain search employees","hrbrain search employees-structured"],"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","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","oa +list-cc","oa +list-executed","oa +list-pending","oa +list-submitted","oa +my-initiated","contact user dismission search","hrbrain talent-pool list","hrbrain talent-pool employees","hrbrain search employees","hrbrain search employees-structured"],"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":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","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","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list","mail +contact-list","mail +template-list","mail +thread-list","mail +user-search","mail +find-mail-user","mail +recent-mail","mail +search-mail","mail +triage","mail +unread-mail","oa +list-forms"],"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","minutes +summary","minutes update summary","todo +comment","todo comment add","ding +send-personal","markdown create","markdown overwrite"],"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","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared","attendance +check-record","attendance +check-result","attendance +get-checkin-record","attendance +get-leave-records","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","mail +thread-list","oa +list-pending","report +inbox-list","report +outbox-list","report +report-latest","contact user dismission search"],"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","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared","attendance +check-record","attendance +check-result","attendance +get-checkin-record","attendance +get-leave-records","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","mail +thread-list","oa +list-pending","report +inbox-list","report +outbox-list","report +report-latest","contact user dismission search"],"risk":"yellow"}, "base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow list","aitable workflow run","aitable workflow update"],"risk":"green"}, "table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"}, "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update","wiki +space-create","wiki space create"],"risk":"green"}, "workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"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 +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 +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 +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","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update"],"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 +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"}, - "drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-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", "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", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "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 document or Drive ordinary-file historical version number with unchanged positive 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", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive 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"}, - "drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"}, - "drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"}, - "drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"}, - "drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"}, - "drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"}, - "workspace_id": {"denotes":"single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID","canonical_hint":"workspace","members":["workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"excludes":["space","space-id","drive-space-id","storage-space-id","dingdrive-space-id","folder","folder-id","node","node-id","dentry-id"],"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 +copy","drive +create-shortcut","drive +move","drive copy","drive list","drive move","drive permission add","drive permission list","drive permission remove","drive permission transfer-owner","drive permission update","drive shortcut","drive upload","wiki +delete-space","wiki +feed-list","wiki +member-add","wiki +member-list","wiki +member-remove","wiki +member-update","wiki +move","wiki +node-copy","wiki +node-create","wiki +node-delete","wiki +node-list","wiki +node-search","wiki +space-get","wiki feed list","wiki member add","wiki member list","wiki member remove","wiki member update","wiki node copy","wiki node create","wiki node delete","wiki node list","wiki node move","wiki node search","wiki space delete","wiki space get"],"risk":"yellow"}, - "created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, - "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, - "document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"}, - "creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, - "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "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","contact account create","contact dept list-members","contact user dismission search"],"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 +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","attendance +get-leave-records","attendance +get-self-setting","attendance +get-summary","contact account update","contact user update","contact user update-ownness"],"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","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update","attendance +check-record","attendance +get-checkin-record","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","contact user get"],"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","ding +send-personal"],"risk":"yellow"}, + "ding_id": {"denotes":"DING id","canonical_hint":"ding-id","members":["ding-id","open-ding-id"],"excludes":["id"],"commands":["ding message receiver-status","ding +recall-personal","ding +receiver-status"],"risk":"yellow"}, + "folder_id": {"denotes":"drive folder id","canonical_hint":"folder","members":["folder","folder-id"],"excludes":["space-id"],"commands":["drive +copy","drive +create-folder","drive +create-shortcut","drive +list","drive +move","drive +upload","drive commit","drive copy","drive list","drive mkdir","drive move","drive shortcut","drive upload","drive upload-info","mail folder update","markdown create"],"risk":"green"}, + "drive_storage_space_id": {"denotes":"single numeric DingDrive storage space ID with unchanged value","canonical_hint":"space-id","members":["space-id","drive-space-id","storage-space-id","dingdrive-space-id"],"excludes":["space","workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"commands":["drive +create-folder","drive +download","drive +info","drive +inspect","drive +list","drive +recycle-list","drive +upload","drive commit","drive download","drive info","drive list","drive mkdir","drive recycle list","drive upload","drive upload-info","markdown fetch","markdown create","markdown overwrite","markdown patch"],"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","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","dentry-uuid"],"excludes":["id","folder","folder-id","parent-id","workspace","workspace-id","block-id","comment-id","comment-key","job-id","task-id","template-id","version","revision","dentry-id","space-id","name","role"],"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","sheet +list-sheets","sheet +read"],"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 document or Drive ordinary-file historical version number with unchanged positive 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","drive +version-download","drive +version-get","drive +version-revert","drive download","drive download-version","drive revert","markdown diff"],"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"}, + "drive_recycle_item_id": {"denotes":"single Drive recycle-bin item ID returned by recycle list","canonical_hint":"id","members":["id","recycle-item-id","trash-item-id","deleted-item-id"],"excludes":["node","node-id","file-id","folder-id","space-id","workspace-id"],"commands":["drive +recycle-restore","drive recycle restore"],"risk":"yellow"}, + "drive_modified_time_start": {"denotes":"Drive search modified-time lower bound in unchanged millisecond timestamp unit","canonical_hint":"modified-from","members":["modified-from","modified-after","modify-time-from","modified-time-start"],"excludes":["modified-to","created-from","created-to","start","from"],"commands":["drive +search","drive search"],"risk":"yellow"}, + "drive_modified_time_end": {"denotes":"Drive search modified-time upper bound in unchanged millisecond timestamp unit","canonical_hint":"modified-to","members":["modified-to","modified-before","modify-time-to","modified-time-end"],"excludes":["modified-from","created-from","created-to","end","to"],"commands":["drive +search","drive search"],"risk":"yellow"}, + "drive_file_size_bytes": {"denotes":"file size in bytes passed unchanged","canonical_hint":"file-size","members":["file-size","file-size-bytes","size-bytes","content-length"],"excludes":["part-size","page-size","limit","size"],"commands":["drive commit","drive upload-info"],"risk":"yellow"}, + "drive_sort_direction": {"denotes":"Drive result sort direction with unchanged asc/desc enum","canonical_hint":"order","members":["order","sort","sort-direction","order-direction"],"excludes":["order-by","sort-by","order-field"],"commands":["drive +list","drive list","drive star list"],"risk":"green"}, + "workspace_id": {"denotes":"single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID","canonical_hint":"workspace","members":["workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"excludes":["space","space-id","drive-space-id","storage-space-id","dingdrive-space-id","folder","folder-id","node","node-id","dentry-id"],"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 +copy","drive +create-shortcut","drive +move","drive copy","drive list","drive move","drive permission add","drive permission list","drive permission remove","drive permission transfer-owner","drive permission update","drive shortcut","drive upload","wiki +delete-space","wiki +feed-list","wiki +member-add","wiki +member-list","wiki +member-remove","wiki +member-update","wiki +move","wiki +node-copy","wiki +node-create","wiki +node-delete","wiki +node-list","wiki +node-search","wiki +space-get","wiki feed list","wiki member add","wiki member list","wiki member remove","wiki member update","wiki node copy","wiki node create","wiki node delete","wiki node list","wiki node move","wiki node search","wiki space delete","wiki space get","markdown fetch","markdown create","markdown overwrite","markdown patch"],"risk":"yellow"}, + "created_time_start": {"denotes":"Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit","canonical_hint":"created-from","members":["created-from","created-after","create-time-from","created-time-start","create-time-start"],"excludes":["created-to","modified-from","modified-to","start","from"],"commands":["doc +search","drive +search","drive search"],"risk":"yellow"}, + "created_time_end": {"denotes":"Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit","canonical_hint":"created-to","members":["created-to","created-before","create-time-to","created-time-end","create-time-end"],"excludes":["created-from","modified-from","modified-to","end","to"],"commands":["doc +search","drive +search","drive search"],"risk":"yellow"}, + "document_permission_role": {"denotes":"direct document-space permission role on grant/apply/update, passed unchanged","canonical_hint":"role","members":["role","permission-role","access-role","member-role"],"excludes":["filter-role","reserve-role","permission","public-permission"],"commands":["doc +access-change","doc +access-grant","doc +grant-and-share","drive permission add","drive permission apply","drive permission update"],"risk":"yellow"}, + "creator_user_ids": {"denotes":"creator userId list used to filter Doc/Drive search results, passed unchanged","canonical_hint":"creator-uids","members":["creator-uids","creator-user-ids","creator-ids","created-by-user-ids"],"excludes":["user","user-id","user-ids","users","owner-id","modifier-uids"],"commands":["doc +search","drive +search","drive search"],"risk":"yellow"}, + "local_output_path": {"denotes":"local destination file or directory path for a download/export result, passed unchanged","canonical_hint":"output","members":["output","output-path","destination-path","save-path"],"excludes":["file","file-path","folder","folder-id","content-file"],"commands":["doc +export","doc +export-get","doc +media-download","doc +resource-download","doc read","drive +download","drive +version-download","drive download","drive download-version","markdown fetch"],"risk":"green"}, "calendar_event_id": {"denotes":"single calendar eventId in the target-event role","canonical_hint":"event","members":["event","event-id","calendar-event-id"],"excludes":["id","calendar-id","calendar-book-id","acl-id","room-id"],"commands":["calendar +attendee-list","calendar +cancel-event","calendar +invite","calendar +reschedule","calendar +get","calendar +rsvp","calendar +update"],"risk":"yellow"}, "calendar_book_id": {"denotes":"single calendar container calendarId, not an eventId","canonical_hint":"calendar-id","members":["calendar-id","calendar","calendar-book-id"],"excludes":["id","event","event-id","acl-id","room-id"],"commands":["calendar +agenda","calendar +attendee-list","calendar +create","calendar +get","calendar +rsvp","calendar +search-event","calendar +update"],"risk":"yellow"}, "calendar_event_title": {"denotes":"calendar event title text","canonical_hint":"title","members":["title","summary","subject"],"excludes":["name","room-name","calendar-name"],"commands":["calendar +book","calendar +create","calendar +update"],"risk":"green"}, @@ -71,34 +71,50 @@ "todo_executor_ids": {"denotes":"todo executor userId list with unchanged comma-separated value","canonical_hint":"executors","members":["executors","executor-ids"],"excludes":["executor","executor-id","participant","participant-id","participants","participant-ids","to","user","user-id","user-ids","users","name"],"commands":["todo +create","todo task add-executor","todo task create","todo task create-sub","todo task remove-executor"],"risk":"yellow"}, "todo_participant_ids": {"denotes":"todo participant userId list with unchanged comma-separated value","canonical_hint":"participants","members":["participants","participant-ids"],"excludes":["participant","participant-id","executor","executor-id","executors","executor-ids","to","user","user-id","user-ids","users","name"],"commands":["todo task add-participant","todo task remove-participant"],"risk":"yellow"}, "todo_completion_state": {"denotes":"todo completion boolean string true/false with unchanged value","canonical_hint":"status","members":["status","done","is-done","todo-status"],"excludes":["state","task-status"],"commands":["todo +get-my-tasks","todo +search","todo task done","todo task list","todo task update"],"risk":"yellow"}, - "todo_role_types": {"denotes":"todo creator/executor/participant role filter list with unchanged comma-separated value","canonical_hint":"role-types","members":["role-types","roles"],"excludes":["role","role-id","role-ids"],"commands":["todo +due-today","todo +get-my-tasks","todo +get-related-tasks","todo task list"],"risk":"yellow"} + "todo_role_types": {"denotes":"todo creator/executor/participant role filter list with unchanged comma-separated value","canonical_hint":"role-types","members":["role-types","roles"],"excludes":["role","role-id","role-ids"],"commands":["todo +due-today","todo +get-my-tasks","todo +get-related-tasks","todo task list"],"risk":"yellow"}, + "attendance_leave_code": {"denotes":"single attendance vacation or leave rule code","canonical_hint":"leave-code","members":["leave-code","vacation-type-code"],"excludes":["leave-name","leave-names","vacation-name","id","rule-id"],"commands":["attendance +get-leave-records"],"risk":"yellow"}, + "attendance_query_date": {"denotes":"single attendance query date whose supplied format is preserved","canonical_hint":"date","members":["date","attendance-date"],"excludes":["start","end","start-date","end-date","time"],"commands":["attendance +get-summary"],"risk":"yellow"}, + "sheet_a1_range": {"denotes":"single A1 worksheet range with unchanged text","canonical_hint":"range","members":["range"],"excludes":["ranges","a1-ranges","cell-ranges","source-range","target-range","destination-range","start-cell"],"commands":["sheet +read"],"risk":"yellow"}, + "sheet_tab_reference": {"denotes":"single worksheet ID accepted unchanged by a sheet command; never a worksheet display name","canonical_hint":"sheet-id","members":["sheet-id","worksheet-id"],"excludes":["id","node","node-id","document-id","target-sheet-id","source-sheet-id","name","title"],"commands":["sheet +read"],"risk":"yellow"}, + "markdown_file_name": {"denotes":"display filename for one native Markdown file, passed unchanged and required to end in .md where Runtime requires it","canonical_hint":"name","members":["name","file-name","filename"],"excludes":["file","file-path","node","node-id","title","folder","folder-id"],"commands":["markdown create","markdown overwrite"],"risk":"yellow"}, + "markdown_local_file_path": {"denotes":"local native Markdown file path passed unchanged; never a remote node ID or output destination","canonical_hint":"file","members":["file","file-path","local-file"],"excludes":["file-id","node","node-id","output","output-path","folder","folder-id","content-file"],"commands":["markdown create","markdown diff","markdown overwrite"],"risk":"yellow"}, + "aisearch_person_dimension": {"denotes":"one or more comma-separated enterprise person search dimensions passed unchanged","canonical_hint":"dimension","members":["dimension","dimensions","search-dimension","person-dimension","search-by"],"excludes":["type","types","category","scope","field","query","queries","department","position","duty","phone","job-number"],"commands":["aisearch","aisearch person","aisearch +search-person"],"risk":"yellow"}, + "aisearch_content_queries": {"denotes":"comma-separated enterprise content topic keywords with time, type, behavior, and conversational filler already removed","canonical_hint":"queries","members":["queries","topics","topic-queries","content-query","content-queries","search-terms"],"excludes":["query","keyword","question","natural-language","types","time-range","behavior-type","direction","chat-scope"],"commands":["aisearch enterprise","aisearch behavior"],"risk":"yellow"}, + "aisearch_content_types": {"denotes":"comma-separated aisearch content type enum values passed unchanged","canonical_hint":"types","members":["types","content-types","resource-types","search-categories","search-type-list"],"excludes":["type","category","source","query","queries","dimension","behavior-type"],"commands":["aisearch enterprise","aisearch behavior"],"risk":"yellow"}, + "aisearch_time_range": {"denotes":"explicit user-provided natural-language or textual time range passed unchanged","canonical_hint":"time-range","members":["time-range","time-window","date-range","search-period","period"],"excludes":["time","date","range","start","end","since","until","created-at","updated-at"],"commands":["aisearch enterprise","aisearch behavior"],"risk":"yellow"}, + "contact_mobile_number": {"denotes":"one person's mobile phone number with unchanged formatting","canonical_hint":"mobile","members":["mobile","org-user-mobile"],"excludes":["user-id","login-id","email"],"commands":["contact +by-mobile","contact +search-mobile","contact account create","contact user invite","contact user search-mobile"],"risk":"yellow"}, + "contact_org_user_name": {"denotes":"employee display name inside an organization","canonical_hint":"org-user-name","members":["org-user-name","organization-user-name"],"excludes":["org-name","creator-username","nick"],"commands":["contact account create","contact account update","contact user invite","contact user update"],"risk":"yellow"}, + "contact_dept_json_assignments": {"denotes":"department membership JSON array without value transformation","canonical_hint":"depts","members":["department-memberships-json","department-assignments-json"],"excludes":["dept","dept-id","dept-ids","department-ids"],"commands":["contact account update","contact user invite","contact user update"],"risk":"yellow"}, + "hrbrain_pool_code": {"denotes":"single Hrbrain talent-pool code with unchanged value","canonical_hint":"pool-code","members":["pool-code","talent-pool-code"],"excludes":["pool","pool-id","talent-pool-id","code","id","name"],"commands":["hrbrain talent-pool detail","hrbrain talent-pool employees","hrbrain search employees"],"risk":"yellow"}, + "hrbrain_work_no": {"denotes":"single Hrbrain employee work number with unchanged value","canonical_hint":"work-no","members":["work-no","employee-no","employee-number","staff-no"],"excludes":["staff-ids","work-nos","employee-id","staff-id","user-id","id","name"],"commands":["hrbrain profile metadata","hrbrain profile query","hrbrain profile career","hrbrain profile performance"],"risk":"yellow"}, + "hrbrain_work_nos": {"denotes":"Hrbrain employee work-number list with unchanged list encoding","canonical_hint":"staff-ids","members":["staff-ids","work-nos","employee-nos","staff-nos"],"excludes":["work-no","employee-no","staff-no","employee-ids","user-ids","id","name"],"commands":["hrbrain profile labels"],"risk":"yellow"} }, "command_overrides": { - "doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."}, - "chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."}, - "chat group members": {"bind": {"id": "open_conversation_id"}}, - "chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, - "chat group members remove": {"bind": {"id": "open_conversation_id"}}, - "chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, - "chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, - "chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, - "chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, - "chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."}, - "drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]}, - "drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true}, - "ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"}, - "ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}}, - "contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"}, - "mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"}, - "mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"}, - "calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, + "doc +version-list": {"ambiguous":["size","max-results","max-result","take","top","per-page","next-cursor","next-token","next-page-token"],"note":"--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."}, + "chat group rename": {"bind":{"id":"open_conversation_id"},"note":"This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."}, + "chat group members": {"bind":{"id":"open_conversation_id"}}, + "chat group members add": {"bind":{"id":"open_conversation_id"},"block":["user-id","open-dingtalk-id"],"note":"The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, + "chat group members remove": {"bind":{"id":"open_conversation_id"}}, + "chat conversation-info": {"ambiguous":["chat-id","open-conversation-id"],"note":"Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat group-mute": {"ambiguous":["chat-id","open-conversation-id"],"note":"Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat group-mute-member": {"ambiguous":["chat-id","open-conversation-id"],"note":"Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message add-emoji": {"scoped_aliases":{"chat-id":"conversation-id"},"block":["group-id","group-ids","conversation-ids","open-conversation-ids"],"ambiguous":["open-message-id"],"note":"Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message add-text-emotion": {"scoped_aliases":{"chat-id":"conversation-id"},"block":["group-id","group-ids","conversation-ids","open-conversation-ids"],"ambiguous":["open-message-id"],"note":"Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message list-mentions": {"ambiguous":["chat-id","open-conversation-id"],"note":"Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message recall-by-bot": {"ambiguous":["chat-id","open-conversation-id"],"note":"Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message remove-emoji": {"scoped_aliases":{"chat-id":"conversation-id"},"block":["group-id","group-ids","conversation-ids","open-conversation-ids"],"ambiguous":["open-message-id"],"note":"Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message remove-text-emotion": {"scoped_aliases":{"chat-id":"conversation-id"},"block":["group-id","group-ids","conversation-ids","open-conversation-ids"],"ambiguous":["open-message-id"],"note":"Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."}, + "chat message search": {"ambiguous":["chat-id","open-conversation-id"],"note":"Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat mute": {"scoped_aliases":{"chat-id":"conversation-id"},"block":["group-id","group-ids","conversation-ids","open-conversation-ids"],"note":"Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."}, + "drive list": {"ambiguous":["root-id","space"],"note":"Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.","scoped_aliases":{"document-id":"node","dentry-uuid":"node","directory-id":"folder","order-field":"order-by","sort-by":"order-by","sort-field":"order-by"},"scope_strict":true,"block":["dentry-id"]}, + "drive upload": {"ambiguous":["destination-id","space","target-id"],"note":"Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.","scoped_aliases":{"dentry-uuid":"node","directory-id":"folder","overwrite-node-id":"node","target-folder-id":"folder","target-workspace-id":"workspace","source-file":"file","content-type":"mime-type","filename":"file-name","name":"file-name","display-name":"file-name","upload-name":"file-name"},"block":["dentry-id","document-url","output-path"],"scope_strict":true}, + "ding +receiver-status": {"scoped_aliases":{"id":"ding-id"},"note":"The only identifier role is one openDingId. Generic id and open-ding-id reduce to ding-id; other resource and request IDs stop before dispatch.","block":["message-id","task-id","uuid","request-id"],"scope_strict":true}, + "ding message receiver-status": {"scoped_aliases":{"id":"ding-id"}}, + "contact user profile get": {"scoped_aliases":{"id":"staff-id","ids":"staff-id","field-codes":"fields","roster-fields":"fields","profile-fields":"fields","field-code-list":"fields"},"note":"user-id is reduced by the user_id concept; generic id/ids bound explicitly Former single/local-role concept contact_roster_fields is intentionally command-scoped. The leaf-local --fields denotes roster field codes and shadows the root output-projection flag when placed after the leaf path.","bind":{"staff-id":"user_id"},"block":["output-fields","staff-ids","user-ids"],"scope_strict":true}, + "mail folder update": {"bind":{"id":"folder_id"},"note":"this command's --id is the folder id; --folder-id reduces to --id"}, + "mail message search": {"scoped_aliases":{"subject":"query"},"scope_strict":true,"note":"never globalize: mail template create has a real and different --subject"}, + "calendar event list": {"scoped_aliases":{"date":"start"},"note":"reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, "calendar +agenda": {"block":["id"],"note":"calendarId aliases stay role-specific; bare --id could be an eventId and must not be silently treated as a calendarId. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, "calendar +attendee-list": {"ambiguous":["id"],"note":"This command carries both an eventId and an optional calendarId; bare --id cannot safely choose the role."}, "calendar +book": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id","rooms","room-id","room-ids","date","time","start-date","end-date","time-min","time-max"],"note":"--with carries names resolved by the shortcut; identifier spellings and room booking inputs cannot be supplied by name-only normalization. Only explicit event-time spellings are mapped; range-bound or date-only spellings remain blocked.","scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"scope_strict":true}, @@ -133,182 +149,182 @@ "calendar +search-event": {"scoped_aliases":{"to":"end","search":"query"},"block":["id","event","event-id","page","page-index","offset"],"scope_strict":true,"note":"The query searches title, description, and location within one calendar page. This route uses cursor pagination; event identifiers and page-index/offset models are different roles."}, "calendar +suggestion": {"scoped_aliases":{"to":"end"},"block":["who","with","name","names","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","room-id","room-ids","rooms"],"scope_strict":true,"note":"This shortcut accepts stable userId values. Person names belong to +suggest-time, openDingTalkIds are a different value domain, and room availability is a different query."}, "calendar +update": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","add-users":"add-attendees","add-user-ids":"add-attendees","remove-users":"remove-attendees","remove-user-ids":"remove-attendees"},"ambiguous":["id","attendees","users","user-ids"],"block":["who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","rooms","room-id","room-ids","room-name","group-id","rich-text-desc","remind-minutes","reminder-minutes","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"Bare --id cannot choose eventId versus calendarId, and a generic attendee list cannot choose add versus remove. Directional userId aliases are safe; names, openDingTalkIds, room changes, rich text, and reminders require different commands or capabilities."}, - "chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, - "chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."}, - "chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."}, - "chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."}, - "chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, - "chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, - "chat message list-favorites": {"scoped_aliases": {"limit": "size"}, "scope_strict": true, "note": "On this exact command, both names denote the same bounded result count; the numeric value is unchanged."}, - "chat +messages-list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, - "chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, - "chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, - "chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, - "chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."}, - "contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."}, - "contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"}, - "contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."}, - "chat message send": {"scoped_aliases": {"to-user": "user"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "The recipient alias remains exact to this command. --file is the visible Primary and --file-path remains a command-owned typed compatibility alias; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, - "chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."}, - "chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."}, - "chat +category-rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."}, - "chat category rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."}, - "chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, - "chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, - "chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, - "chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, - "chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, - "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, - "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, - "chat group-role remove-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": "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", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "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", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, - "doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, - "doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, - "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": ["stdin", "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. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."}, - "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."}, - "chat group members remove-bot": {"bind": {"id": "open_conversation_id"}}, - "chat +send-to-group": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, - "chat group share-invite": {"scoped_aliases": {"source-conversation-id": "source", "target-conversation-id": "target"}, "block": ["group-id", "group-ids", "user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."}, - "chat message combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-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"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, - "chat message forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-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"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, - "chat message forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "source-conversation-id": "src-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"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Conversation and message source/destination roles are preserved; role-free identifiers are rejected."}, - "chat +conversation-info": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."}, - "chat group create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed identifier domains."}, - "chat +chat-set-admin": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."}, - "chat +messages-read-status": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."}, - "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"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, - "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": ["msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The visible --group carries the openConversationId; --conversation-id remains its command-owned typed compatibility alias, while the reviewed command-scoped concept preserves --chat/--chat-id/--open-conversation-id compatibility. The only message role is the referenced message, plural IDs are not accepted, 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 +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, - "doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, - "doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, - "doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, - "doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, - "doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, - "doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, - "doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, - "doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, - "doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, - "doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "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. --element cannot choose between document content, a block target, or an insertion reference."}, - "doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."}, - "doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."}, - "doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."}, - "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"}, "block": ["after-block-id", "before-block-id"], "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. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."}, - "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", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, - "doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."}, - "drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, - "drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]}, - "drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, - "drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]}, - "drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]}, - "drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]}, - "drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]}, - "drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]}, - "drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]}, - "drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]}, - "drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]}, - "drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]}, - "drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]}, - "drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."}, - "drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]}, - "drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."}, - "drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]}, - "drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."}, - "drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."}, - "drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, - "drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."}, - "drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."}, - "drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]}, - "drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, - "drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."}, - "drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."}, - "drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."}, - "drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."}, - "drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."}, - "drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."}, - "drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, - "drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, - "drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]}, - "drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, - "drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."}, - "drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."}, - "drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, - "drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, - "drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, - "drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."}, - "drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."}, - "drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."}, - "drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."}, - "drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."}, - "drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}}, - "drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."}, - "drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."}, - "drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."}, - "drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}, -"aitable +export-data": {"scoped_aliases":{"export-format":"format"},"scope_strict":true,"note":"The shortcut's local --format is the export file format, unlike the root output-format flag; only the explicit --export-format spelling is mapped here."}, + "chat +bot-find": {"scoped_aliases":{"name":"query"},"scope_strict":true,"note":"On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat +chat-messages": {"scoped_aliases":{"chat":"group"},"scope_strict":true,"note":"Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."}, + "chat +search-msg": {"scoped_aliases":{"chat":"group"},"scope_strict":true,"note":"Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."}, + "chat bot find": {"scoped_aliases":{"name":"query"},"scope_strict":true,"note":"On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat +bot-search": {"scoped_aliases":{"query":"name","current-page":"page"},"block":["cursor"],"scope_strict":true,"note":"Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, + "chat bot search": {"scoped_aliases":{"query":"name","current-page":"page"},"block":["cursor"],"scope_strict":true,"note":"Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, + "chat message list-favorites": {"scoped_aliases":{"limit":"size"},"scope_strict":true,"note":"On this exact command, both names denote the same bounded result count; the numeric value is unchanged."}, + "chat +messages-list-unread-conversations": {"scoped_aliases":{"limit":"count","size":"count"},"scope_strict":true,"note":"On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat +unread-chats": {"scoped_aliases":{"limit":"count","size":"count"},"scope_strict":true,"note":"On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat message list-unread-conversations": {"scoped_aliases":{"limit":"count","size":"count"},"scope_strict":true,"note":"On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat +messages-list-direct": {"scoped_aliases":{"start":"time"},"block":["end"],"scope_strict":true,"note":"This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, + "chat message list": {"scoped_aliases":{"start":"time"},"block":["end"],"ambiguous":["chat-id","open-conversation-id"],"scope_strict":true,"note":"This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat message list-by-sender": {"scoped_aliases":{"user-id":"sender-user-id","open-dingtalk-id":"sender-open-dingtalk-id"},"block":["time"],"scope_strict":true,"note":"Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."}, + "contact +resolve-dept": {"bind":{"name":"search_query"},"note":"The real --name is a department-name search keyword and carries the search_query concept on this shortcut."}, + "contact +list-sub-depts": {"block":["name","query"],"note":"--dept is an integer department id; names and search queries require a separate resolution command"}, + "contact +dept-members": {"bind":{"dept":"search_query"},"scoped_aliases":{"name":"dept"},"note":"The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."}, + "chat message send": {"scoped_aliases":{"to-user":"user"},"ambiguous":["chat-id","open-conversation-id"],"note":"The recipient alias remains exact to this command. --file is the visible Primary and --file-path remains a command-owned typed compatibility alias; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat +group-members": {"bind":{"group":"group_name"},"note":"The real --group is a group-name search keyword on this shortcut, not an identifier."}, + "chat +category-create": {"scoped_aliases":{"name":"title"},"scope_strict":true,"note":"The reviewed name/title mapping preserves the category display-name value on this exact shortcut."}, + "chat category create": {"scoped_aliases":{"name":"title"},"scope_strict":true,"note":"The reviewed name/title mapping preserves the category display-name value on this exact command."}, + "chat +category-rename": {"scoped_aliases":{"name":"title"},"block":["category-ids"],"scope_strict":true,"note":"The display-name alias is exact; a category-id list is not accepted where one category is required."}, + "chat category rename": {"scoped_aliases":{"name":"title"},"block":["category-ids"],"scope_strict":true,"note":"The display-name alias is exact; a category-id list is not accepted where one category is required."}, + "chat +category-delete": {"block":["category-ids"],"note":"This command requires one category id; list cardinality is not reduced automatically."}, + "chat category delete": {"block":["category-ids"],"note":"This command requires one category id; list cardinality is not reduced automatically."}, + "chat category list-conversations": {"block":["category-ids"],"note":"This command requires one category id; list cardinality is not reduced automatically."}, + "chat category add-conv": {"block":["category-id"],"ambiguous":["chat","chat-id","open-conversation-id"],"note":"This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat category remove-conv": {"block":["category-id"],"ambiguous":["chat","chat-id","open-conversation-id"],"note":"This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "chat +chat-role-update": {"block":["role-ids"],"note":"This command requires one role id; list cardinality is not reduced automatically."}, + "chat group-role remove": {"block":["role-ids"],"note":"This command requires one role id; list cardinality is not reduced automatically."}, + "chat group-role update": {"block":["role-ids"],"note":"This command requires one role id; list cardinality is not reduced automatically."}, + "chat +chat-role-set-user": {"block":["role-id"],"note":"This command requires a role-id list; one id is not promoted into a batch input."}, + "chat group-role remove-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":"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","chat-id","open-conversation-id"],"note":"The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "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","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, + "doc +list": {"scoped_aliases":{"folder-id":"folder","parent-folder":"folder","parent-folder-id":"folder","parent-node-id":"folder","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, + "doc +move": {"scoped_aliases":{"folder-id":"folder","parent-folder":"folder","parent-folder-id":"folder","parent-node-id":"folder","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."}, + "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":["stdin","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. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."}, + "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."}, + "chat group members remove-bot": {"bind":{"id":"open_conversation_id"}}, + "chat +send-to-group": {"bind":{"group":"group_name"},"note":"The real --group is a group-name search keyword on this shortcut, not an identifier."}, + "chat group share-invite": {"scoped_aliases":{"source-conversation-id":"source","target-conversation-id":"target"},"block":["group-id","group-ids","user","user-id","userid","uid","staff-id"],"ambiguous":["conversation-id","open-conversation-id","group","chat","chat-id","id"],"note":"A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."}, + "chat message combine-forward": {"scoped_aliases":{"src-open-cid":"src-conversation-id","dest-open-cid":"dest-conversation-id","source-conversation-id":"src-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"],"note":"Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, + "chat message forward": {"scoped_aliases":{"src-open-cid":"src-conversation-id","dest-open-cid":"dest-conversation-id","source-conversation-id":"src-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"],"note":"Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, + "chat message forward-topic": {"scoped_aliases":{"src-open-conversation-id":"src-conversation-id","dest-open-conversation-id":"dest-conversation-id","source-conversation-id":"src-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"],"ambiguous":["conversation-id","open-conversation-id","group","chat","chat-id","id"],"note":"Conversation and message source/destination roles are preserved; role-free identifiers are rejected."}, + "chat +conversation-info": {"block":["user","user-id","userid","uid","staff-id"],"note":"This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."}, + "chat group create": {"block":["user-id","open-dingtalk-id"],"note":"The real --users is a list and may contain mixed identifier domains."}, + "chat +chat-set-admin": {"block":["user-id","open-dingtalk-id"],"note":"The real --users is a mixed userId/openDingTalkId list."}, + "chat +messages-read-status": {"block":["user-id","open-dingtalk-id"],"note":"The real --users is a mixed userId/openDingTalkId list."}, + "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"],"ambiguous":["chat","chat-id","open-conversation-id"],"note":"The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."}, + "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":["msg-ids","message-ids","open-message-ids"],"scope_strict":true,"note":"The visible --group carries the openConversationId; --conversation-id remains its command-owned typed compatibility alias, while the reviewed command-scoped concept preserves --chat/--chat-id/--open-conversation-id compatibility. The only message role is the referenced message, plural IDs are not accepted, 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 +access-change": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"scope_strict":true,"note":"Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc +access-grant": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"block":["target-user-id","target-user-ids","user-id","user-ids"],"ambiguous":["target-user","user","users"],"scope_strict":true,"note":"The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +access-revoke": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"scope_strict":true,"note":"Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc +create": {"scoped_aliases":{"folder-id":"folder","parent-folder":"folder","parent-folder-id":"folder","parent-node-id":"folder","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +create-from-template": {"scoped_aliases":{"folder-id":"folder","parent-folder":"folder","parent-folder-id":"folder","parent-node-id":"folder","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +import": {"scoped_aliases":{"folder-id":"folder","parent-folder":"folder","parent-folder-id":"folder","parent-node-id":"folder","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc create": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"scope_strict":true,"note":"Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc file create": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"scope_strict":true,"note":"Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc import": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"scope_strict":true,"note":"Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc template apply": {"scoped_aliases":{"space":"workspace","space-id":"workspace"},"scope_strict":true,"note":"Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."}, + "doc +update": {"scoped_aliases":{"mode":"command","doc-id":"node","document-id":"node","file-id":"node","node-id":"node","url":"node"},"block":["content-file"],"ambiguous":["element"],"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. --element cannot choose between document content, a block target, or an insertion reference."}, + "doc +inspect": {"scoped_aliases":{"include-access":"include-permissions","include-member":"include-permissions","include-members":"include-permissions","include-versions":"include-history"},"block":["include","include-blocks","include-content","include-info","include-meta","include-metadata"],"scope_strict":true,"note":"Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."}, + "doc +fetch": {"scoped_aliases":{"start-block":"start-block-id","end-block":"end-block-id"},"block":["content-format","doc-format","range"],"ambiguous":["block-id"],"scope_strict":true,"note":"Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."}, + "doc +export": {"block":["wait"],"scope_strict":true,"note":"The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."}, + "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"},"block":["after-block-id","before-block-id"],"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. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."}, + "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","space":"workspace","space-id":"workspace"},"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. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."}, + "doc +version-save": {"block":["message","title"],"scope_strict":true,"note":"The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."}, + "drive copy": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","directory-id":"folder","source-node-id":"node","source-file-id":"node","target-folder-id":"folder","destination-folder-id":"folder","target-workspace-id":"workspace","destination-workspace-id":"workspace"},"scope_strict":true,"note":"Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.","block":["dentry-id","destination-node-id","dingdrive-space-id","drive-space-id","name","source-folder-id","space-id","storage-space-id"],"ambiguous":["destination-id","target-id"]}, + "drive cover": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive download-version": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","chunk-size":"part-size","concurrency":"parallel","parallelism":"parallel"},"scope_strict":true,"note":"Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.","block":["dentry-id","file","file-path","folder","folder-id"]}, + "drive move": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","directory-id":"folder","source-node-id":"node","source-file-id":"node","target-folder-id":"folder","destination-folder-id":"folder","target-workspace-id":"workspace","destination-workspace-id":"workspace"},"scope_strict":true,"note":"Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.","block":["dentry-id","destination-node-id","dingdrive-space-id","drive-space-id","name","source-folder-id","space-id","storage-space-id"],"ambiguous":["destination-id","target-id"]}, + "drive permission add": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","member-user-ids":"users","collaborator-ids":"users","target-user-ids":"users","target-node-id":"node"},"scope_strict":true,"note":"The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.","block":["dentry-id","dingdrive-space-id","drive-space-id","space-id","storage-space-id"]}, + "drive permission apply": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","approver-user-ids":"users","approver-ids":"users","target-node-id":"node","apply-reason":"reason","notification-mode":"notify-mode"},"scope_strict":true,"note":"The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.","block":["dentry-id"]}, + "drive permission apply-info": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive permission list": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","role-filter":"filter-role","permission-role-filter":"filter-role","target-node-id":"node"},"scope_strict":true,"note":"Filtering by a role is not the same as granting/updating a role.","block":["dentry-id","dingdrive-space-id","drive-space-id","permission","role","space-id","storage-space-id"]}, + "drive permission remove": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","member-user-ids":"users","collaborator-ids":"users","target-user-ids":"users","target-node-id":"node"},"scope_strict":true,"note":"The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.","block":["dentry-id","dingdrive-space-id","drive-space-id","space-id","storage-space-id"]}, + "drive permission transfer-owner": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","target-node-id":"node","target-workspace-id":"workspace","old-owner-role":"reserve-role","keep-role":"reserve-role","new-owner-id":"new-owner","new-owner-user-id":"new-owner"},"scope_strict":true,"note":"Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.","block":["current-owner","dentry-id","dingdrive-space-id","drive-space-id","space-id","storage-space-id","user-ids","users"],"ambiguous":["owner","owner-user-id","target-id","user-id"]}, + "drive permission update": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","member-user-ids":"users","collaborator-ids":"users","target-user-ids":"users","target-node-id":"node"},"scope_strict":true,"note":"The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.","block":["dentry-id","dingdrive-space-id","drive-space-id","space-id","storage-space-id"]}, + "drive publish get": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive publish set": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","public-permission":"permission","public-role":"permission"},"scope_strict":true,"note":"Internet-public permission is not the same as a direct collaborator role.","block":["access-role","dentry-id","permission-role","role"]}, + "drive publish unset": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive rename": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","file-name":"name","display-name":"name","new-name":"name"},"scope_strict":true,"note":"The name is this exact folder/node display name; search query and local path are different roles.","block":["dentry-id"]}, + "drive revert": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive shortcut": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node","directory-id":"folder","source-node-id":"node","source-file-id":"node","target-folder-id":"folder","destination-folder-id":"folder","target-workspace-id":"workspace","destination-workspace-id":"workspace"},"scope_strict":true,"note":"Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.","block":["dentry-id","destination-node-id","dingdrive-space-id","drive-space-id","name","source-folder-id","space-id","storage-space-id"],"ambiguous":["destination-id","target-id"]}, + "drive star add": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive star remove": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive stats": {"scoped_aliases":{"document-id":"node","dentry-uuid":"node"},"scope_strict":true,"note":"The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.","block":["dentry-id"]}, + "drive +cover": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node"},"block":["dentry-id","doc","folder","folder-id","name"],"scope_strict":true,"note":"This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."}, + "drive +copy": {"scoped_aliases":{"dentry-uuid":"node","directory-id":"folder","source-node-id":"node","source-file-id":"node","target-folder-id":"folder","destination-folder-id":"folder","target-workspace-id":"workspace","destination-workspace-id":"workspace"},"block":["dentry-id","destination-node-id","dingdrive-space-id","document-url","drive-space-id","name","source-folder-id","space-id","storage-space-id"],"scope_strict":true,"note":"Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.","ambiguous":["destination-id","target-id"]}, + "drive +create-folder": {"scoped_aliases":{"directory-id":"folder","parent-directory-id":"folder","parent-folder-id":"folder","folder-name":"name","display-name":"name","new-name":"name"},"block":["dentry-id","parent-id","knowledge-base-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."}, + "drive +create-shortcut": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","directory-id":"folder","source-node-id":"node","source-file-id":"node","target-folder-id":"folder","destination-folder-id":"folder","target-workspace-id":"workspace","destination-workspace-id":"workspace"},"block":["dentry-id","destination-node-id","dingdrive-space-id","doc","drive-space-id","name","source-folder-id","space-id","storage-space-id"],"scope_strict":true,"note":"Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.","ambiguous":["destination-id","id","target-id"]}, + "drive +delete": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","folder":"node","folder-id":"node"},"block":["dentry-id","doc","doc-id","document-id","document-url","id","name","url"],"scope_strict":true,"note":"Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."}, + "drive +download": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node"},"block":["dentry-id","doc","doc-id","document-id","document-url","file","file-path","folder","folder-id","id","knowledge-base-id","name","url","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."}, + "drive +info": {"scoped_aliases":{"dentry-uuid":"node"},"block":["dentry-id","document-url","knowledge-base-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, + "drive +inspect": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","folder":"node","folder-id":"node","include-statistics":"include-stats","include-publish-status":"include-publish","include-public-status":"include-publish","include-thumbnail":"include-cover"},"block":["dentry-id","doc","document-url","id","include","include-content","include-history","include-permissions","name","url"],"scope_strict":true,"note":"Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."}, + "drive +list": {"ambiguous":["root-id","space"],"scoped_aliases":{"directory-id":"folder","parent-folder-id":"folder","order-field":"order-by","sort-by":"order-by","sort-field":"order-by"},"block":["dentry-id","knowledge-base-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."}, + "drive +move": {"scoped_aliases":{"dentry-uuid":"node","directory-id":"folder","source-node-id":"node","source-file-id":"node","target-folder-id":"folder","destination-folder-id":"folder","target-workspace-id":"workspace","destination-workspace-id":"workspace"},"block":["dentry-id","destination-node-id","dingdrive-space-id","document-url","drive-space-id","name","source-folder-id","space-id","storage-space-id"],"scope_strict":true,"note":"Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.","ambiguous":["destination-id","target-id"]}, + "drive +publish-get": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node"},"block":["dentry-id","doc","folder","folder-id","name"],"scope_strict":true,"note":"The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +publish-unset": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node"},"block":["dentry-id","doc","folder","folder-id","name"],"scope_strict":true,"note":"The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."}, + "drive +recycle-restore": {"bind":{"id":"drive_recycle_item_id"},"block":["file-id","folder-id","node","node-id","space-id","workspace-id"],"scope_strict":true,"note":"The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."}, + "drive +rename": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node","folder":"node","folder-id":"node","file-name":"name","display-name":"name","new-name":"name"},"block":["dentry-id"],"scope_strict":true,"note":"The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."}, + "drive delete": {"scoped_aliases":{"dentry-uuid":"node"},"block":["dentry-id","document-url"],"scope_strict":true,"note":"This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."}, + "drive download": {"scoped_aliases":{"dentry-uuid":"node","chunk-size":"part-size","concurrency":"parallel","parallelism":"parallel"},"block":["dentry-id","document-url","file","file-path","folder","folder-id","knowledge-base-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."}, + "drive info": {"scoped_aliases":{"dentry-uuid":"node","space":"space-id","workspace":"space-id","workspace-id":"space-id"},"block":["dentry-id","document-url","knowledge-base-id","wiki-workspace-id"],"scope_strict":true,"note":"This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."}, + "drive commit": {"scoped_aliases":{"directory-id":"folder","parent-directory-id":"folder","upload-session-id":"upload-id","size-bytes":"file-size","name":"file-name","display-name":"file-name","filename":"file-name","upload-name":"file-name"},"scope_strict":true,"note":"Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.","block":["knowledge-base-id","wiki-workspace-id","workspace","workspace-id"]}, + "drive mkdir": {"scoped_aliases":{"directory-id":"folder","parent-directory-id":"folder","folder-name":"name","display-name":"name","new-name":"name"},"scope_strict":true,"note":"The name is this exact folder/node display name; search query and local path are different roles.","block":["knowledge-base-id","wiki-workspace-id","workspace","workspace-id"]}, + "drive upload-info": {"scoped_aliases":{"directory-id":"folder","parent-directory-id":"folder","content-type":"mime-type","size-bytes":"file-size","name":"file-name","display-name":"file-name","filename":"file-name","upload-name":"file-name"},"scope_strict":true,"note":"File metadata aliases preserve MIME and byte units; file path and upload session are different stages.","block":["knowledge-base-id","wiki-workspace-id","workspace","workspace-id"]}, + "drive recycle list": {"block":["knowledge-base-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."}, + "drive search": {"ambiguous":["end","from","start","time-from","time-to","to","types"],"block":["offset","page"],"scope_strict":true,"note":"Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."}, + "drive +search": {"ambiguous":["end","from","start","time-from","time-to","to","types"],"block":["offset","page"],"scope_strict":true,"note":"Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."}, + "drive +star-add": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node"},"block":["dentry-id","doc","folder","folder-id","name"],"scope_strict":true,"note":"The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +star-remove": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node"},"block":["dentry-id","doc","folder","folder-id","name"],"scope_strict":true,"note":"The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +stats": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","doc-id":"node","document-id":"node","url":"node","document-url":"node","id":"node"},"block":["dentry-id","doc","folder","folder-id","name"],"scope_strict":true,"note":"The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."}, + "drive +version-download": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node"},"block":["dentry-id","doc","doc-id","document-id","document-url","file","file-path","folder","folder-id","id","name","url"],"scope_strict":true,"note":"Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."}, + "drive +version-get": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node"},"block":["dentry-id","doc","doc-id","document-id","document-url","folder","folder-id","id","name","url"],"scope_strict":true,"note":"This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."}, + "drive +version-history": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node"},"block":["dentry-id","doc","doc-id","document-id","document-url","folder","folder-id","id","name","url"],"scope_strict":true,"note":"This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."}, + "drive +version-revert": {"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node"},"block":["dentry-id","doc","doc-id","document-id","document-url","folder","folder-id","id","name","url"],"scope_strict":true,"note":"The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."}, + "drive recycle restore": {"bind":{"id":"drive_recycle_item_id"},"block":["file-id","folder-id","node","node-id","space-id","workspace-id"],"scope_strict":true,"note":"The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."}, + "drive star list": {"ambiguous":["type","types"],"scope_strict":true,"note":"Content types and resource types are separate arrays; a generic type list cannot choose one.","scoped_aliases":{"order-field":"order-by","sort-by":"order-by","sort-field":"order-by"}}, + "drive recent": {"ambiguous":["type","types"],"scope_strict":true,"note":"Creator type, operation type, and file type filters are distinct and keep their enum/list forms."}, + "drive +recent": {"ambiguous":["type","types"],"scope_strict":true,"note":"Creator type, operation type, and file type filters are distinct and keep their enum/list forms."}, + "drive +star-list": {"ambiguous":["type","types"],"scope_strict":true,"note":"The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."}, + "drive +upload": {"ambiguous":["destination-id","space","target-id"],"scoped_aliases":{"dentry-uuid":"node","file-id":"node","node-id":"node","overwrite-node-id":"node","directory-id":"folder","parent-directory-id":"folder","parent-folder-id":"folder","target-folder-id":"folder","source-file":"file","local-file":"file","file-path":"file","content-type":"mime-type","filename":"file-name","name":"file-name","display-name":"file-name","upload-name":"file-name"},"block":["dentry-id","document-url","knowledge-base-id","wiki-workspace-id","workspace","workspace-id","output-path"],"scope_strict":true,"note":"Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}, + "aitable +export-data": {"scoped_aliases":{"export-format":"format"},"scope_strict":true,"note":"The shortcut's local --format is the export file format, unlike the root output-format flag; only the explicit --export-format spelling is mapped here."}, "aitable attachment upload": {"scoped_aliases":{"file-size":"size"},"scope_strict":true,"note":"On this upload command --size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."}, "aitable +attachment-upload": {"scoped_aliases":{"file-size":"size"},"scope_strict":true,"note":"On this upload command --size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."}, "aitable import upload": {"scoped_aliases":{"size":"file-size"},"scope_strict":true,"note":"On this import-upload command --file-size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."}, @@ -423,407 +439,484 @@ "wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, "wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, "wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."}, - "wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."} + "wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace aliases remain native. Pagination is now cursor-based via the real --next-token (auto-aliased --cursor/--page-token) with pageSize max 50; user filters are still unsupported."}, + "attendance +check-record": {"scoped_aliases":{"until":"end","to":"end","target-user-ids":"users","staff-ids":"users","user-id-list":"users"},"scope_strict":true,"note":"These spellings all denote this command's userId list; singular user spellings remain protected by the user_ids concept."}, + "attendance +check-result": {"scoped_aliases":{"until":"end","to":"end","target-user-ids":"users","staff-ids":"users","user-id-list":"users"},"scope_strict":true,"note":"These spellings all denote this command's userId list; singular user spellings remain protected by the user_ids concept. Offset is a command-local pagination role; page-token remains explicitly blocked.","block":["page-token"]}, + "attendance +get-adjustment-rule": {"scoped_aliases":{"rule-id":"adjustment-id","adjustment-rule-id":"adjustment-id"},"scope_strict":true,"note":"On this exact command --rule-id denotes one adjustment-rule ID. The former two-command adjustment-rule concept is represented locally by this exact alias and guard set.","block":["overtime-id","class-id","group-id","plan-id","result-id","id"]}, + "attendance +get-approve-template": {"scoped_aliases":{"approval-type":"type","biz-type":"type"},"block":["approval-types","biz-types","types"],"scope_strict":true,"note":"This command accepts one approval type; list spellings are not collapsed into one value."}, + "attendance +get-checkin-record": {"scoped_aliases":{"until":"end","to":"end","operator-user-id":"operator-staff-id","operator-id":"operator-staff-id","corp-id":"operator-corp-id","operator-company-id":"operator-corp-id","target-user-ids":"staff-ids","user-id-list":"staff-ids"},"block":["name","to-user"],"ambiguous":["user","user-id","userid","uid","staff-id"],"scope_strict":true,"note":"The command has distinct operator-single-user and target-user-list roles. Only role-qualified operator aliases and list-qualified target aliases are accepted; generic singular user spellings stay ambiguous, while unrelated name/to-user spellings stay blocked.","bind":{"staff-ids":"user_ids"}}, + "attendance +get-leave-records": {"scoped_aliases":{"until":"end","to":"end"},"scope_strict":true,"note":"On this exact attendance range command, --to/--until denote the existing end boundary; the value format is preserved."}, + "attendance +get-overtime-rule": {"scoped_aliases":{"rule-id":"overtime-id","overtime-rule-id":"overtime-id"},"scope_strict":true,"note":"On this exact command --rule-id denotes one overtime-rule ID. The former two-command overtime-rule concept is represented locally by this exact alias and guard set.","block":["adjustment-id","class-id","group-id","plan-id","result-id","id"]}, + "attendance +get-schedule": {"scoped_aliases":{"until":"end","to":"end","target-user-ids":"users","staff-ids":"users","user-id-list":"users","work-date-begin":"start","work-date-end":"end"},"scope_strict":true,"note":"Compatibility for old RPC spellings on the Shortcut; the stable schedule get command already keeps these spellings as native hidden flags."}, + "attendance +get-self-setting": {"ambiguous":[],"scope_strict":true,"note":"The latest main leaf publishes --setting-scene and --user. Only scene is a value-preserving spelling; setting type/item names do not prove one of the six accepted scene enum values.","scoped_aliases":{"scene":"setting-scene"},"block":["setting-type","setting-item"]}, + "attendance +get-summary": {"scoped_aliases":{"summary-type":"stats-type","statistics-type":"stats-type","period":"stats-type"},"scope_strict":true,"note":"These aliases preserve the week/month statistics selector value."}, + "attendance +list-approve": {"scoped_aliases":{"until":"end","to":"end","target-user-ids":"users","staff-ids":"users","user-id-list":"users","approval-types":"types","biz-types":"types"},"scope_strict":true,"note":"Former single/local-role concept attendance_approval_types is intentionally command-scoped. These spellings all denote this command's userId list; singular user spellings remain protected by the user_ids concept."}, + "attendance +query-report-data": {"scoped_aliases":{"until":"end","to":"end","target-user-ids":"users","staff-ids":"users","user-id-list":"users","report-column-ids":"columns"},"scope_strict":true,"note":"These spellings all denote this command's userId list; singular user spellings remain protected by the user_ids concept. Report columns are a command-local field-ID list, so its alias and guards stay on these exact commands.","block":["column-names","leave-names"]}, + "attendance +search-class": {"scoped_aliases":{"class-filter-type":"filter-type","class-scope":"filter-type"},"scope_strict":true,"note":"These aliases denote the existing ALL/MINE_OWN class ownership filter."}, + "attendance +search-group": {"scoped_aliases":{"group-type":"type","attendance-group-type":"type","include-ble":"query-ble","include-position":"query-position"},"scope_strict":true,"note":"The type and optional projection switches are exact to attendance-group search."}, + "mail +folder-list": {"scoped_aliases":{"folder-id":"folder"},"block":["name","calendar-id","node-id"],"scope_strict":true,"note":"The real --folder is an optional parent mail folderId. Names and other product folder domains are not converted."}, + "mail +message": {"scoped_aliases":{"message-id":"id","mail-id":"id"},"block":["thread-id","conversation-id","attachment-id","internet-message-id"],"scope_strict":true,"note":"The real --id is one internal mail messageId; thread and Internet Message-ID values are different domains."}, + "mail +messages": {"scoped_aliases":{"message-ids":"ids","mail-ids":"ids"},"block":["message-id","mail-id","thread-id","thread-ids","conversation-id","conversation-ids"],"scope_strict":true,"note":"This command requires a comma-separated mail messageId list; singular IDs and thread IDs are rejected."}, + "mail +recent-mail": {"scoped_aliases":{"folder-id":"folder"},"block":["name","calendar-id","node-id"],"scope_strict":true,"note":"The real --folder is a mail folderId used by the shortcut's KQL query."}, + "mail +search-mail": {"block":["subject","sender","from","from-address","to-address","date","folder","folder-id","has-attachments"],"scope_strict":true,"note":"This shortcut also requires a complete KQL --query value. Separate condition flags cannot be converted without rewriting values."}, + "mail +thread": {"scoped_aliases":{"conversation-id":"id","thread-id":"id"},"block":["message-id","mail-id","message-ids","mail-ids"],"scope_strict":true,"note":"The real --id is a conversationId/threadId, never a mail messageId."}, + "mail +thread-list": {"scoped_aliases":{"folder-id":"folder"},"block":["name","calendar-id","node-id"],"scope_strict":true,"note":"The real --folder is a required mail folderId. Names require a prior folder lookup."}, + "mail +triage": {"block":["subject","sender","from","from-address","to-address","date","folder","folder-id","has-attachments"],"scope_strict":true,"note":"Triage accepts one complete KQL expression in --query. Field fragments are not flags and cannot be assembled by value-preserving argv normalization; pagination aliases remain independent."}, + "mail +unread-mail": {"block":["query","keyword","subject","sender"],"note":"This shortcut has a fixed isRead:false query. Additional search conditions require mail +search-mail or mail message search."}, + "oa +list-executed": {"block":["count"],"scope_strict":true,"note":"The shortcut no longer exposes a limit/take pagination alias; count remains a different parameter and must fail closed."}, + "oa +list-pending": {"block":["cursor","offset","page-token"],"scope_strict":true,"note":"Pending lists use one-based page/limit with an ISO-8601 range, not cursor pagination."}, + "ding +list": {"scoped_aliases":{"message-type":"type","next-cursor":"cursor"},"block":["remind-type","status","page","page-token","next-token","offset"],"scope_strict":true,"note":"List type uses ALL/UNREAD/SEND/NEW_COMMENT/DELETED and cursor is an integer. Reminder enums, status guesses, page indexes and opaque tokens are different domains."}, + "ding +recall-personal": {"bind":{"id":"ding_id"},"block":["robot-code","robot","message-id","task-id","uuid","request-id"],"scope_strict":true,"note":"Personal recall needs one openDingId and must not acquire a robot identity or another message/request identifier."}, + "ding +send-personal": {"bind":{"users":"open_dingtalk_ids"},"scoped_aliases":{"receiver-open-dingtalk-ids":"users","recipient-open-dingtalk-ids":"users","remind-type":"type","message-content":"content","idempotency-key":"uuid","request-id":"uuid"},"block":["robot-code","robot","user-id","user-ids","receiver-user-ids","recipient-user-ids","staff-id","staff-ids","open-dingtalk-id","message-type","status","id","ding-id","open-ding-id"],"scope_strict":true,"note":"Personal DING recipients are an openDingTalkId list. UserId/staffId and singular inputs are not promoted; reminder enum, content and idempotency aliases preserve their values, while output DING identifiers stop before send."}, + "report +inbox-list": {"scoped_aliases":{"sender-staff-ids":"sender-user-ids","from-staff-ids":"sender-user-ids"},"block":["date","sender-id","sender-user-id","sender-staff-id","user-id","user-ids","staff-id","staff-ids"],"scope_strict":true,"note":"The filter is a list of sender staffIds. Explicit plural sender-staff spellings pass through unchanged; date-only inputs, singular IDs and role-free user/staff spellings are not promoted."}, + "report +outbox-list": {"scoped_aliases":{"report-template-name":"template-name","name":"template-name","updated-start":"modified-start","update-start":"modified-start","modified-from":"modified-start","updated-end":"modified-end","update-end":"modified-end","modified-to":"modified-end"},"block":["template-type","template-id","report-template-id","date"],"ambiguous":["template"],"scope_strict":true,"note":"Template name, creation bounds and modification bounds are separate roles. Exact role-preserving spellings map; template IDs/types and date-only inputs stop before dispatch."}, + "report +report-latest": {"scoped_aliases":{"template-name":"keyword","report-template-name":"keyword","name":"keyword"},"block":["template-id","report-template-id","date","modified-start","modified-end"],"ambiguous":["template"],"scope_strict":true,"note":"Latest filters by an exact report template display name and an ISO-8601 creation window. Template IDs, modified-time bounds and date-only values are different contracts."}, + "report +template-search": {"block":["template-id","report-template-id"],"ambiguous":["template","name"],"scope_strict":true,"note":"Template search accepts a free-text name query. Stable template IDs are outputs/lookup inputs elsewhere, while role-free template/name spellings do not prove search intent."}, + "markdown create": {"bind":{"content":"content_text","file":"markdown_local_file_path","name":"markdown_file_name","folder":"folder_id","workspace":"workspace_id","space-id":"drive_storage_space_id"},"scoped_aliases":{"markdown-content":"content","plan-only":"dry-run","preview-only":"dry-run","source-file":"file"},"block":["node","node-id","doc-id","file-id","document-id","url","output-path","version","version2","pattern","regex","replacement"],"ambiguous":["id","path","space","content-file","destination"],"scope_strict":true,"note":"Create needs exactly one content transport: inline/@file/stdin through --content or one local .md through --file. The filename, parent folder, document workspace, and numeric DingDrive space keep separate roles; an existing node target belongs to overwrite."}, + "markdown diff": {"bind":{"version":"doc_version_number","file":"markdown_local_file_path"},"scoped_aliases":{"dentry-uuid":"node","left-version":"version","base-version":"version","from-version":"version","right-version":"version2","target-version":"version2","to-version":"version2","context-lines":"context","diff-context":"context","surrounding-lines":"context","plan-only":"dry-run","preview-only":"dry-run","source-file":"file"},"block":["version-id","revision","content","body","pattern","regex","output-path","workspace","workspace-id","space-id","folder","name"],"ambiguous":["path","left","right","destination"],"scope_strict":true,"note":"Diff compares remote version(s) or one remote side with a local .md. --version and --version2 are positive history numbers; --file is a local path and is mutually exclusive with --version2. Context-line spellings are local aliases for the one diff-only integer option. Hidden node aliases remain executable compatibility assertions."}, + "markdown fetch": {"bind":{"workspace":"workspace_id","space-id":"drive_storage_space_id","output":"local_output_path"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","save-to":"output","download-to":"output","output-file":"output"},"block":["url","content","file","file-path","folder","parent-id","name","version","version2","pattern","regex"],"ambiguous":["path","space","destination"],"scope_strict":true,"note":"Fetch reads one remote native .md node and may save to one local output path. Workspace IDs, numeric DingDrive space IDs, remote node IDs, local destinations, and content/upload inputs remain distinct. Hidden --id compatibility remains executable but is not promoted as a reviewed alias."}, + "markdown overwrite": {"bind":{"content":"content_text","file":"markdown_local_file_path","name":"markdown_file_name","workspace":"workspace_id","space-id":"drive_storage_space_id"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","markdown-content":"content","show-diff":"dry-run","preview-only":"dry-run","no-write":"dry-run","source-file":"file"},"block":["url","folder","folder-id","parent-id","output-path","version","version2","pattern","regex","replacement","content-file"],"ambiguous":["id","path","space","destination"],"scope_strict":true,"note":"Overwrite is destructive and needs one explicit remote node plus exactly one content transport. Local file paths, inline content, display name, workspace/space route, and command-level diff preview are not interchangeable."}, + "markdown patch": {"bind":{"workspace":"workspace_id","space-id":"drive_storage_space_id"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","match-pattern":"pattern","find-text":"pattern","old-text":"pattern","search-pattern":"pattern","regexp":"regex","use-regex":"regex","regular-expression":"regex","replacement":"content","replace-with":"content","new-text":"content","replacement-text":"content","show-diff":"dry-run","preview-only":"dry-run","no-write":"dry-run"},"block":["url","file","file-path","content-file","replacement-file","name","folder","folder-id","parent-id","output-path","version","version2","query"],"ambiguous":["id","path","space","text","body","value","destination"],"scope_strict":true,"note":"Patch downloads and reuploads one remote .md. Pattern and regex spellings are local aliases for this leaf; the match pattern and literal replacement remain distinct, and --regex changes only pattern interpretation, never replacement expansion. File-based replacement and role-free text/path inputs are not inferred."}, + "aisearch": {"bind":{"dimension":"aisearch_person_dimension"},"scoped_aliases":{"search-term":"query","person-query":"query","person-name":"query"},"block":["queries","types","search-types","time-range","behavior-type","action-type","direction","chat-scope","user-id","open-dingtalk-id","mobile","phone-number","job-number","employee-number","department-id"],"ambiguous":["target","id","phone","employee","department","position","duty","scope"],"scope_strict":true,"note":"The runnable compatibility root is person search only when a person query is present. Enterprise/behavior slots, exact IDs, full-mobile lookup, and role-free dimension names must not be interpreted as a person query. Native keyword/name/q/text/type compatibility remains unchanged."}, + "aisearch person": {"bind":{"dimension":"aisearch_person_dimension"},"scoped_aliases":{"search-term":"query","person-query":"query","person-name":"query"},"block":["queries","types","search-types","time-range","behavior-type","action-type","direction","chat-scope","user-id","open-dingtalk-id","ids","mobile","phone-number","job-number","employee-number","department-id","position-id"],"ambiguous":["target","id","phone","employee","department","position","duty","scope"],"scope_strict":true,"note":"Person search requires a complete target string plus an explicit dimension when the user provides one. Full mobile lookup and known user IDs belong to contact; the alias layer cannot set dimension values or turn a role flag into query+dimension. Native keyword/name/q/text/type flags and command aliases remain original compatibility paths."}, + "aisearch enterprise": {"bind":{"queries":"aisearch_content_queries","types":"aisearch_content_types","time-range":"aisearch_time_range"},"block":["dimension","dimensions","behavior-type","action-type","direction","from","to","sender","receiver","chat-scope","group-name","user-id","open-dingtalk-id","node-id","file-id","document-id","write","update"],"ambiguous":["type","category","source","range","date","time","scope","question","natural-language"],"scope_strict":true,"note":"Enterprise search expects already separated topic queries, content type enums, and an explicit time phrase. It finds content but does not read or write a selected resource. Behavior/direction/person slots and generalized natural-language or type/range flags are not inferred. Native query/keyword/search-types/searchTypes/timeRange compatibility remains original."}, + "aisearch behavior": {"bind":{"queries":"aisearch_content_queries","types":"aisearch_content_types","time-range":"aisearch_time_range"},"scoped_aliases":{"action-type":"behavior-type","behavior-action":"behavior-type","activity-type":"behavior-type","group-name":"chat-scope","chat-name":"chat-scope","conversation-name":"chat-scope","im-scope":"chat-scope","interaction-direction":"direction","interaction-flow":"direction","message-flow":"direction"},"block":["dimension","dimensions","user-id","open-dingtalk-id","node-id","file-id","document-id","from","to","sender","receiver","chat-id","group-id","conversation-id","open-conversation-id","write","update"],"ambiguous":["type","category","scope","chat","group","conversation","action","actor","target","range","date","time","source","destination"],"scope_strict":true,"note":"Enterprise behavior search keeps reusable query/type/time concepts. Behavior enum, chat-name scope, and direction are leaf-local roles, so their reviewed synonyms are command-scoped instead of becoming single-command global concepts. Stable IDs and split sender/receiver fields are never converted."}, + "aisearch +search-person": {"bind":{"dimensions":"aisearch_person_dimension"},"scoped_aliases":{"search-term":"query","person-query":"query","person-name":"query"},"block":["queries","types","search-types","time-range","behavior-type","action-type","direction","chat-scope","user-id","open-dingtalk-id","ids","mobile","phone-number","job-number","employee-number","department-id","position-id"],"ambiguous":["target","id","phone","employee","department","position","duty","scope"],"scope_strict":true,"note":"The available shortcut accepts one nonblank person query and an optional validated dimension list. Person-name spellings preserve the query value; full mobile, employee IDs, content/behavior slots, and role-free words are stopped rather than guessed."}, + "contact +lookup": {"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["department-id","dept","dept-id","org-name","role-name"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. The real --name is a person/flower-name lookup string; department, organization, and role names are different entities."}, + "contact +org": {"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["department-id","dept","dept-id","org-name","role-name"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. The real --name is a person/flower-name lookup string; department, organization, and role names are different entities."}, + "contact +team": {"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["department-id","dept","dept-id","org-name","role-name"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. The real --name is a person/flower-name lookup string; department, organization, and role names are different entities."}, + "contact user dismission search": {"bind":{"depts":"dept_ids"},"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["cursor","department-id","department-memberships-json","dept","dept-id","dept-list-json","offset"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. This command uses CSV department IDs, a date range, and one-based page/limit; JSON memberships and cursor/offset pagination are different protocols."}, + "contact +list-role-members": {"block":["dept","dept-id","ids","names","staff-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_role_id is intentionally command-scoped. The real --id is one label/role ID, not a department ID, userId, or list.","scoped_aliases":{"role-id":"id","label-id":"id"}}, + "contact label get": {"block":["dept-id","id","label-id","role-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_role_names is intentionally command-scoped. The real --names is a comma-separated exact role-name list; native name/query/keyword spellings remain compatibility behavior.","scoped_aliases":{"role-names":"names","label-names":"names"}}, + "contact user update-ownness": {"bind":{"user-id":"user_id"},"scope_strict":true,"note":"Former single/local-role concept contact_status_text is intentionally command-scoped. Target userId and the personal status text are separate roles; status values are passed unchanged.","scoped_aliases":{"status-text":"ownness-text","personal-status":"ownness-text","status":"ownness-text"}}, + "contact org create": {"ambiguous":["name"],"block":["login-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_organization_name is intentionally command-scoped. Former single/local-role concept contact_creator_name is intentionally command-scoped. Organization name and creator display name are distinct write roles; a generic name cannot choose one.","scoped_aliases":{"organization-name":"org-name","company-name":"org-name","corp-name":"org-name","creator-name":"creator-username","founder-name":"creator-username"}}, + "contact account create": {"bind":{"dept-ids":"dept_ids"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name","account-login-id":"login-id","login-account":"login-id","email-address":"email","mail-address":"email"},"block":["department-id","department-memberships-json","dept","dept-id","depts-json","staff-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_login_id is intentionally command-scoped. Former single/local-role concept contact_email_address is intentionally command-scoped. Department IDs are a CSV list here; login-id, userId, email, and mobile are distinct account identifiers."}, + "contact user invite": {"bind":{"depts":"contact_dept_json_assignments"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name"},"block":["department-id","department-ids","dept","dept-id","dept-ids","parent","parent-id"],"scope_strict":true,"note":"The real --depts value is a JSON membership array; scalar or CSV department IDs must not be renamed into it."}, + "contact user update": {"bind":{"depts":"contact_dept_json_assignments","user-id":"user_id"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name"},"block":["department-id","department-ids","dept","dept-id","dept-ids","parent","parent-id"],"scope_strict":true,"note":"The employee being changed and the employee's direct manager are distinct userId roles.","ambiguous":["manager","owner-id"]}, + "contact account update": {"bind":{"depts":"contact_dept_json_assignments","user-id":"user_id"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name","profile-image-file-id":"avatar-file-id"},"block":["department-id","department-ids","dept","dept-id","dept-ids","parent","parent-id","ownness-text","file","node-id"],"scope_strict":true,"note":"The employee being changed and the employee's direct manager are distinct userId roles. Nickname and manager are local flag roles, not reusable central concepts. Avatar file synonyms are command-scoped because both avatar commands already share the native --avatar-file-id canonical flag.","ambiguous":["manager","owner-id"]}, + "contact dept update": {"scoped_aliases":{"target-dept-id":"dept","target-department-id":"dept","parent-dept-id":"parent","parent-department-id":"parent","new-parent-dept-id":"parent"},"ambiguous":["department","target-id"],"block":["department-ids","depts"],"scope_strict":true,"note":"The department being updated and its new parent are separate single-department roles."}, + "contact dept get-info": {"scoped_aliases":{"department-id":"dept"},"block":["department","department-ids","depts","parent","parent-id"],"scope_strict":true,"note":"The real target is one department ID. Native dept-id/dept-ids/id/ids spellings remain native compatibility; parent-role names are not rewritten."}, + "contact dept create": {"scoped_aliases":{"parent-dept-id":"parent","parent-department-id":"parent","new-parent-dept-id":"parent"},"block":["department-ids","dept","dept-id","dept-ids","depts"],"scope_strict":true,"note":"Only a parent department is accepted; there is no target department ID on create."}, + "contact user get": {"bind":{"ids":"user_ids"},"scoped_aliases":{"staff-ids":"ids","member-user-ids":"ids"},"block":["staff-id","uid"],"scope_strict":true,"note":"The target is a userId list; native user-id/userid compatibility remains native and can represent a one-item list."}, + "contact label list-members": {"block":["dept","dept-id","ids","names","staff-id","user-id"],"scope_strict":true,"note":"The real --id is one label/role ID, not a department ID, userId, or list."}, + "contact user update-self": {"block":["name","org-user-name","ownness-text","file","node-id","user-id"],"scope_strict":true,"note":"Nickname is local to self-update; employee-name and ownership-text spellings remain guarded. Avatar file synonyms are command-scoped because both avatar commands already share the native --avatar-file-id canonical flag.","scoped_aliases":{"profile-image-file-id":"avatar-file-id"}}, + "contact +list-followings": {"scoped_aliases":{"open-dingtalk-id":"open-id","open-dingtalkid":"open-id"},"block":["user-id","userid","staff-id","mobile","phone","email","name","query","open-dingtalk-ids"],"ambiguous":["id","user","person"],"scope_strict":true,"note":"The optional --open-id is one openDingTalkId used only to filter the current user's followings. It is a single-command role, so it intentionally remains scoped and must not be conflated with userId, staffId, mobile, or a list."}, + "live stream list": {"block":["page","size","limit","cursor","offset","page-token","page-size","status","state","query","keyword","sort","order","live-id","stream-id","room-id","anchor-id","user-id","creator-id","start","end","start-time","end-time","from","to","title","cover-url","group-id","conversation-id","record","playback","create","start-live","end-live"],"ambiguous":["id","type","time","date","user","owner"],"scope_strict":true,"note":"The frozen Live surface has one read-only get_my_lives leaf and no business parameters. It always queries the current user's live list. Pagination, filters, live/stream/user IDs, time ranges, and create/control/playback fields are not accepted inputs. They must stop before MCP dispatch rather than being guessed from returned list fields or other live-management APIs. Global output, profile, timeout, mock, dry-run, and credential flags remain native."}, + "devdoc article search": {"scoped_aliases":{"search-term":"query","query-text":"query","results-per-page":"size","page-number":"page"},"block":["offset","cursor","page-index","page-token","next-token","next-cursor","count","total","doc-id","document-id","file-id","node-id","space-id","workspace-id","request-id","trace-id","error-code","error-message","context"],"ambiguous":["id","type"],"scope_strict":true,"note":"Devdoc search accepts one unchanged query string plus one-based page and per-page size. The raw leaf also owns a positional keyword and hidden native --keyword compatibility flag; both remain native. Cursor/offset/index pagination, document identifiers, diagnostic fields, and fields that require query composition must stop before dispatch."}, + "hrbrain talent-pool list": {"scoped_aliases":{"label-list":"labels"},"block":["pool-code","pool-id","talent-pool-id","employee-id","work-no","staff-ids","user-id"],"ambiguous":["id","type","user","code","creator-id","label-name","pool"],"scope_strict":true,"note":"Talent-pool list accepts a name keyword, pool type, creator value, labels, one-based page, and page-size. Search and pagination aliases preserve values. Unproven creator-id/label-name value domains, pool identifiers, employee identifiers, and generic id/type/code spellings are not promoted."}, + "hrbrain talent-pool detail": {"block":["page","page-size","query","keyword","creator","labels","employee-id","work-no","staff-ids"],"ambiguous":["id","code","name","type","pool"],"scope_strict":true,"note":"Talent-pool detail requires exactly one stable poolCode. Only explicit talent-pool-code preserves that value. Bare pool/id/code/name spellings and list filters cannot prove the same value domain."}, + "hrbrain talent-pool employees": {"block":["query","keyword","dept-name","position-name","job-level","creator","labels","employee-id","work-no","staff-ids"],"ambiguous":["id","code","name","status","pool"],"scope_strict":true,"note":"Pool employee listing requires one poolCode plus one-based page and page-size. It does not expose free search, department/job filters, work-number selectors, or status filtering; bare pool/id/code cannot prove poolCode."}, + "hrbrain profile metadata": {"block":["data-queries","model-code","module","section","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","field"],"scope_strict":true,"note":"Profile metadata accepts one employee work number and returns module/field metadata. Data queries, identifiers from other domains, list cardinality, and returned field roles are not request aliases."}, + "hrbrain profile query": {"scoped_aliases":{"profile-queries":"data-queries","module-queries":"data-queries"},"block":["data-query","model-code","module","section","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","field","query"],"scope_strict":true,"note":"Profile query requires one work number and one non-empty JSON array in data-queries. Reviewed plural query aliases preserve the whole JSON value; singular or nested members cannot be expanded into that structure."}, + "hrbrain profile labels": {"scoped_aliases":{"all-labels":"all-label"},"block":["work-no","employee-no","staff-no","employee-ids","user-ids","employee-id","staff-id","user-id"],"ambiguous":["id","user","employee","name","label","tag"],"scope_strict":true,"note":"Profile labels requires a list of employee work numbers. Plural work-number spellings preserve list encoding and all-labels preserves boolean polarity. Singular values and platform user/staff identifiers differ in cardinality or value domain."}, + "hrbrain profile career": {"block":["start","end","start-date","end-date","year","position","job-level","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","time","date"],"scope_strict":true,"note":"Career accepts one employee work number and returns full company career history. Date, position, and level are return fields rather than request filters."}, + "hrbrain profile performance": {"block":["start","end","start-date","end-date","year","cycle","rating","score","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","time","date","level"],"scope_strict":true,"note":"Performance accepts one employee work number and exposes no cycle/date/rating/score filter. Those returned record roles and other identifier domains must stop before dispatch."}, + "hrbrain search employees": {"scoped_aliases":{"department-name":"dept-name","job-title":"position-name","position-level":"job-level"},"block":["dept-id","department-id","employee-id","staff-id","user-id","work-no","staff-ids","origin-json","data-queries","order-by"],"ambiguous":["id","code","name","department","level","grade","title","user","position","pool"],"scope_strict":true,"note":"Simple employee search accepts unchanged keyword, department name, position name, job level, poolCode, page, and page-size values. Explicit name/level aliases preserve roles; ids, work numbers, structured JSON, bare position/pool, and generic level/title names are not promoted."}, + "hrbrain search employees-structured": {"scoped_aliases":{"filter-json":"origin-json","criteria-json":"origin-json","search-json":"origin-json","return-fields-json":"fields","result-fields-json":"fields","sort-by":"order-by","sort-fields":"order-by"},"block":["keyword","query","dept-name","department-name","position-name","job-level","pool-code","work-no","staff-ids","field-name","operator","operators"],"ambiguous":["id","name","type","filter","criteria","field","sort","order"],"scope_strict":true,"note":"Structured search requires a JSON object in origin-json, a non-empty JSON array in the local business fields flag, and optional order-by values. Reviewed aliases preserve each encoding; simple-search flags and nested members cannot become peers."}, + "hrbrain search fields": {"block":["query","keyword","origin-json","order-by","page","page-size","dept-name","position-name","job-level","pool-code","work-no","staff-ids","field-name","operator","operators"],"ambiguous":["id","name","type","field","filter","criteria"],"scope_strict":true,"note":"Search fields has no business inputs and returns the current operator's allowed structured-search fields/operators. Search criteria, pagination, employee filters, and returned field/operator roles are not request parameters."}, + "pat chmod": {"scoped_aliases":{"product-code":"product","product-codes":"products","domain-code":"domain","domain-codes":"domains","grant-mode":"grant-type","authorization-mode":"grant-type","pat-session-id":"session-id","authorization-session-id":"session-id","recommended":"recommend"},"block":["scope","scopes","permission","permissions","permission-scope","permission-scopes","all","all-scopes","force","confirm","confirmed","app-id","app-key","app-secret","oauth-scope","api-scope","open-browser","browser-enabled","allow-browser","disable-browser","browser-policy"],"ambiguous":["agent","agent-id","type","mode","session","id","service","services","resource","action"],"scope_strict":true,"note":"PAT chmod accepts scopes only as positional argv or through reviewed product/domain/recommend plan selectors. Aliases preserve selector cardinality, grant enum values, and session ids. Scope flags cannot become positionals; app permission, browser policy, generic ids, force/confirm, and all-scope spellings must not be promoted."}, + "pat browser-policy": {"scoped_aliases":{"open-browser":"enabled","browser-enabled":"enabled","allow-browser":"enabled"},"block":["disable-browser","browser-disabled","deny-browser","block-browser","scope","scopes","permission","permissions","product","products","domain","domains","grant-type","grant-mode","session-id","recommend","recommended","app-id","app-key","app-secret","oauth-scope","api-scope"],"ambiguous":["agent","agent-id","policy","browser","state","value","mode"],"scope_strict":true,"note":"Browser policy requires one explicit positive-polarity boolean and optionally one explicit agent code. Positive browser spellings preserve the boolean unchanged. Disable/deny names require inversion; authorization scopes, app permissions, and generic policy/agent/value names are different roles."}, + "pat +browser-policy": {"scoped_aliases":{"open-browser":"enabled","browser-enabled":"enabled","allow-browser":"enabled"},"block":["disable-browser","browser-disabled","deny-browser","block-browser","scope","scopes","permission","permissions","product","products","domain","domains","grant-type","grant-mode","session-id","recommend","recommended","app-id","app-key","app-secret","oauth-scope","api-scope"],"ambiguous":["agent","agent-id","policy","browser","state","value","mode"],"scope_strict":true,"note":"Browser policy requires one explicit positive-polarity boolean and optionally one explicit agent code. Positive browser spellings preserve the boolean unchanged. Disable/deny names require inversion; authorization scopes, app permissions, and generic policy/agent/value names are different roles."} }, "validation_fixture": { "cases": [ - {"command": "oa +search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 28}, - {"command": "aitable +list-tables", "emitted": "base-id", "expect": "base", "via": "concept:base_id+morph", "occ": 26}, - {"command": "mail +find-mail-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 18}, - {"command": "aitable +field-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, - {"command": "aitable +record-query", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, - {"command": "aitable +table-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, - {"command": "doc block update", "emitted": "text", "expect": "content", "via": "native:reviewed-compatibility-alias", "occ": 6}, - {"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "concept:search_query+bind", "occ": 4}, - {"command": "devdoc article search", "emitted": "limit", "expect": "size", "via": "concept:pagination_size", "occ": 2}, - {"command": "devdoc article search", "emitted": "page-size", "expect": "size", "via": "concept:pagination_size", "occ": 2}, - {"command": "devdoc article search", "emitted": "current-page", "expect": "page", "via": "concept:page_number", "occ": 2}, - {"command": "mail message search", "emitted": "subject", "expect": "query", "via": "override:scoped_strict", "occ": 4}, - {"command": "aitable +record-share-url", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 3}, - {"command": "aitable record query", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, - {"command": "calendar event list", "emitted": "date", "expect": "start", "via": "override:scoped(reviewed+payload)", "occ": 2}, - {"command": "calendar event list", "emitted": "start-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "min-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "time-min", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "end-time", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "time-max", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "max-results", "expect": "limit", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "next-cursor", "expect": "cursor", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "calendar event list", "emitted": "calendar", "expect": "calendar-id", "via": "native:reviewed-compatibility-fallback", "occ": 2}, - {"command": "chat message list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, - {"command": "chat message list-by-sender", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-format-boundary", "occ": 2}, - {"command": "doc +template-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, - {"command": "doc block insert", "emitted": "text", "expect": "content", "via": "native:reviewed-compatibility-alias", "occ": 2}, - {"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2}, - {"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, - {"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2}, - {"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, - {"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2}, - {"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, - {"command": "contact +search-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, - {"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 31}, - {"command": "ding +receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 24}, - {"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 8}, - {"command": "contact +list-sub-depts", "emitted": "dept-id", "expect": "dept", "via": "concept:dept_id+morph", "occ": 4}, - {"command": "contact +list-sub-depts", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:name-vs-id", "occ": 2}, - {"command": "contact +list-sub-depts", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:query-vs-id", "occ": 2}, - {"command": "contact user profile get", "emitted": "user-id", "expect": "staff-id", "via": "concept:user_id", "occ": 2}, - {"command": "contact user profile get", "emitted": "id", "expect": "staff-id", "via": "override:scoped", "occ": 2}, - {"command": "contact user profile get", "emitted": "ids", "expect": "staff-id", "via": "override:scoped", "occ": 2}, - {"command": "chat message send-by-bot", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 4}, - {"command": "chat message send-by-bot", "emitted": "to-user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 1}, - {"command": "dev app get", "emitted": "app-id", "expect": "unified-app-id", "via": "concept:app_id", "occ": 5}, - {"command": "chat message list-all", "emitted": "from", "expect": "start", "via": "concept:time_start", "occ": 2}, - {"command": "chat message list-all", "emitted": "start-time", "expect": "start", "via": "concept:time_start", "occ": 2}, - {"command": "chat message search-advanced", "emitted": "group", "expect": "conversation-ids", "via": "native:reviewed-single-to-list", "occ": 4}, - {"command": "chat message send", "emitted": "to-user", "expect": "user", "via": "override:scoped(reviewed+payload)", "occ": 4}, - {"command": "contact +dept-members", "emitted": "name", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2}, - {"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "concept:search_query+bind", "occ": 2}, - {"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id", "occ": 2}, - {"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "concept:dept_id", "occ": 2}, - {"command": "ding message receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 2}, - {"command": "ding message receiver-status", "emitted": "open-ding-id", "expect": "ding-id", "via": "concept:ding_id", "occ": 2}, - {"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 3}, - {"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "concept:user_ids+exclude", "occ": 2}, - {"command": "attendance check result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids", "occ": 2}, - {"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 2}, - {"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2}, - {"command": "ding message send", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code", "occ": 2}, - {"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, - {"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, - {"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2}, - {"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2}, - {"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2}, - {"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, - {"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, - {"command": "chat group get-by-group-id", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-group-id"}, - {"command": "chat group get-by-group-id", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-vs-group-id"}, - {"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"}, - {"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, - {"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"}, - {"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"}, - {"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, - {"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"}, - {"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, - {"command": "chat message search-advanced", "emitted": "open-conversation-ids", "expect": "conversation-ids", "via": "concept:open_conversation_ids"}, - {"command": "chat message search-advanced", "emitted": "group-ids", "expect": "did-you-mean:blocked", "via": "guard:group-id-list-vs-open-conversation-id-list"}, - {"command": "chat message recall", "emitted": "message-id", "expect": "msg-id", "via": "concept:open_message_id"}, - {"command": "chat message add-favorite", "emitted": "msg-id", "expect": "open-message-id", "via": "concept:open_message_id"}, - {"command": "chat message list-by-ids", "emitted": "message-ids", "expect": "msg-ids", "via": "concept:open_message_ids"}, - {"command": "chat message list-by-ids", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, - {"command": "chat message reply", "emitted": "ref-message-id", "expect": "ref-msg-id", "via": "concept:referenced_open_message_id"}, - {"command": "chat message reply", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"}, - {"command": "chat message forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-role"}, - {"command": "chat message forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"}, - {"command": "chat message combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-role"}, - {"command": "chat message forward-topic", "emitted": "dest-open-conversation-id", "expect": "dest-conversation-id", "via": "override:scoped-role"}, - {"command": "chat message forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"}, - {"command": "chat group share-invite", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-target-role"}, - {"command": "chat message send", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, - {"command": "chat message list", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, - {"command": "chat +messages-list-direct", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, - {"command": "attendance +check-result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"}, - {"command": "contact +list-sub-depts", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id"}, - {"command": "chat +conversation-info", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-vs-open-dingtalk-id"}, - {"command": "chat group members remove", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"}, - {"command": "chat group members remove", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, - {"command": "chat group create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, - {"command": "chat +chat-set-admin", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, - {"command": "chat +messages-read-status", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, - {"command": "chat group members list-by-ids", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"}, - {"command": "chat group members remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"}, - {"command": "chat group members remove-bot", "emitted": "robot-code", "expect": "did-you-mean:blocked", "via": "guard:robot-code-vs-open-bot-id"}, - {"command": "chat group members add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"}, - {"command": "chat +bot-find", "emitted": "name", "expect": "query", "via": "override:scoped"}, - {"command": "chat bot find", "emitted": "name", "expect": "query", "via": "override:scoped"}, - {"command": "chat +bot-search", "emitted": "query", "expect": "name", "via": "override:scoped"}, - {"command": "chat bot search", "emitted": "query", "expect": "name", "via": "override:scoped"}, - {"command": "chat message list-favorites", "emitted": "limit", "expect": "size", "via": "override:scoped"}, - {"command": "chat bot search", "emitted": "current-page", "expect": "page", "via": "override:scoped"}, - {"command": "chat bot search", "emitted": "cursor", "expect": "did-you-mean:blocked", "via": "guard:page-number-vs-cursor"}, - {"command": "chat message list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"}, - {"command": "chat +messages-list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}, - {"command": "chat message list", "emitted": "start", "expect": "time", "via": "override:scoped"}, - {"command": "chat message list", "emitted": "end", "expect": "did-you-mean:blocked", "via": "guard:single-time-vs-range"}, - {"command": "chat message list-all", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-range-required"}, - {"command": "chat message list-by-sender", "emitted": "user-id", "expect": "sender-user-id", "via": "override:scoped-role"}, - {"command": "chat message list-by-sender", "emitted": "open-dingtalk-id", "expect": "sender-open-dingtalk-id", "via": "override:scoped-role"}, - {"command": "chat category create-smart", "emitted": "title", "expect": "name", "via": "override:scoped"}, - {"command": "chat category create", "emitted": "name", "expect": "title", "via": "override:scoped"}, - {"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"}, - {"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, - {"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"}, - {"command": "chat message send-by-bot", "emitted": "at-ids", "expect": "did-you-mean:ambiguous", "via": "guard:user-id-vs-open-dingtalk-id"}, - {"command": "chat +bot-search", "emitted": "current-page", "expect": "page", "via": "override:scoped"}, - {"command": "chat +category-create", "emitted": "name", "expect": "title", "via": "override:scoped"}, - {"command": "chat +category-rename", "emitted": "name", "expect": "title", "via": "override:scoped"}, - {"command": "chat +messages-list-direct", "emitted": "start", "expect": "time", "via": "override:scoped"}, - {"command": "chat +messages-list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"}, - {"command": "chat +messages-send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, - {"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"}, - {"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:workspace_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": "text", "expect": "content", "via": "native:reviewed-compatibility-alias"}, - {"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 +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": "conversation-id", "expect": "group", "via": "native:reviewed-compatibility-alias"}, - {"command": "chat +messages-reply", "emitted": "chat", "expect": "group", "via": "concept:open_conversation_id"}, - {"command": "chat +messages-reply", "emitted": "open-conversation-id", "expect": "group", "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 group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"}, - {"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", "occ": 1}, - {"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1}, - {"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5}, - {"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:workspace_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"}, - {"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"}, - {"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"}, - {"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"}, - {"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"}, - {"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"}, - {"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"}, - {"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"}, - {"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"}, - {"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"}, - {"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"}, - {"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"}, - {"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"}, - {"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"}, - {"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"}, - {"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"}, - {"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"}, - {"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"}, - {"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"}, - {"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"}, - {"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1}, - {"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1}, - {"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"}, - {"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"}, - {"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"}, - {"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"}, - {"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, - {"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"}, - {"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"}, - {"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"}, - {"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6}, - {"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1}, - {"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1}, - {"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1}, - {"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"}, - {"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"}, - {"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3}, - {"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1}, - {"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1}, - {"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3}, - {"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1}, - {"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2}, - {"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2}, - {"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1}, - {"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1}, - {"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1}, - {"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1}, - {"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2}, - {"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1}, - {"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1}, - {"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"}, - {"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1}, - {"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1}, - {"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1}, - {"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1}, - {"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1}, - {"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, - {"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1}, - {"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, - {"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, - {"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, - {"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, - {"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"}, - {"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"}, - {"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"}, - {"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"}, - {"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"}, - {"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"}, - {"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"}, - {"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"}, - {"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"}, - {"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"}, - {"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"}, - {"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"}, - {"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"}, - {"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"}, - {"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"}, - {"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"}, - {"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"}, - {"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"}, - {"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"}, - {"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"}, - {"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"}, - {"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"}, - {"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"}, - {"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"}, - {"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"}, - {"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"}, - {"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"}, - {"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"}, - {"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, - {"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"}, - {"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"}, - {"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"}, - {"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"}, - {"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"}, - {"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"}, - {"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"}, - {"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"}, - {"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"}, - {"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"}, - {"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"}, - {"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"}, - {"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"}, + {"command":"oa +search-forms","emitted":"keyword","expect":"query","via":"concept:search_query","occ":28}, + {"command":"aitable +list-tables","emitted":"base-id","expect":"base","via":"concept:base_id+morph","occ":26}, + {"command":"mail +find-mail-user","emitted":"keyword","expect":"query","via":"concept:search_query","occ":18}, + {"command":"aitable +field-get","emitted":"base","expect":"base-id","via":"concept:base_id+morph","occ":8}, + {"command":"aitable +record-query","emitted":"base","expect":"base-id","via":"concept:base_id+morph","occ":8}, + {"command":"aitable +table-get","emitted":"base","expect":"base-id","via":"concept:base_id+morph","occ":8}, + {"command":"doc block update","emitted":"text","expect":"content","via":"native:reviewed-compatibility-alias","occ":6}, + {"command":"contact +resolve-dept","emitted":"query","expect":"name","via":"concept:search_query+bind","occ":4}, + {"command":"devdoc article search","emitted":"limit","expect":"size","via":"concept:pagination_size","occ":2}, + {"command":"devdoc article search","emitted":"page-size","expect":"size","via":"concept:pagination_size","occ":2}, + {"command":"devdoc article search","emitted":"current-page","expect":"page","via":"concept:page_number","occ":2}, + {"command":"mail message search","emitted":"subject","expect":"query","via":"override:scoped_strict","occ":4}, + {"command":"aitable +record-share-url","emitted":"base","expect":"base-id","via":"concept:base_id+morph","occ":3}, + {"command":"aitable record query","emitted":"max-results","expect":"limit","via":"concept:pagination_size","occ":2}, + {"command":"calendar event list","emitted":"date","expect":"start","via":"override:scoped(reviewed+payload)","occ":2}, + {"command":"calendar event list","emitted":"start-time","expect":"start","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"min-time","expect":"start","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"time-min","expect":"start","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"end-time","expect":"end","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"time-max","expect":"end","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"max-results","expect":"limit","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"next-cursor","expect":"cursor","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"calendar event list","emitted":"calendar","expect":"calendar-id","via":"native:reviewed-compatibility-fallback","occ":2}, + {"command":"chat message list","emitted":"max-results","expect":"limit","via":"concept:pagination_size","occ":2}, + {"command":"chat message list-by-sender","emitted":"time","expect":"did-you-mean:blocked","via":"guard:time-format-boundary","occ":2}, + {"command":"doc +template-search","emitted":"keyword","expect":"query","via":"concept:search_query","occ":2}, + {"command":"doc block insert","emitted":"text","expect":"content","via":"native:reviewed-compatibility-alias","occ":2}, + {"command":"drive list","emitted":"folder-id","expect":"folder","via":"concept:folder_id+morph","occ":2}, + {"command":"mail thread list","emitted":"max-results","expect":"limit","via":"concept:pagination_size","occ":2}, + {"command":"mail user search","emitted":"query","expect":"keyword","via":"concept:search_query","occ":2}, + {"command":"oa approval search-forms","emitted":"keyword","expect":"query","via":"concept:search_query","occ":2}, + {"command":"report list","emitted":"from-date","expect":"start","via":"concept:time_start","occ":2}, + {"command":"aitable +base-search","emitted":"keyword","expect":"query","via":"concept:search_query","occ":1}, + {"command":"contact +search-user","emitted":"keyword","expect":"query","via":"concept:search_query","occ":1}, + {"command":"chat group rename","emitted":"group","expect":"id","via":"override:bind(open_conversation_id)","occ":31}, + {"command":"ding +receiver-status","emitted":"id","expect":"ding-id","via":"override:scoped(ding_id)","occ":24}, + {"command":"chat group members","emitted":"group","expect":"id","via":"override:bind(open_conversation_id)","occ":8}, + {"command":"contact +list-sub-depts","emitted":"dept-id","expect":"dept","via":"concept:dept_id+morph","occ":4}, + {"command":"contact +list-sub-depts","emitted":"name","expect":"did-you-mean:blocked","via":"guard:name-vs-id","occ":2}, + {"command":"contact +list-sub-depts","emitted":"query","expect":"did-you-mean:blocked","via":"guard:query-vs-id","occ":2}, + {"command":"contact user profile get","emitted":"user-id","expect":"staff-id","via":"concept:user_id","occ":2}, + {"command":"contact user profile get","emitted":"id","expect":"staff-id","via":"override:scoped","occ":2}, + {"command":"contact user profile get","emitted":"ids","expect":"staff-id","via":"override:scoped","occ":2}, + {"command":"chat message send-by-bot","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list","occ":4}, + {"command":"chat message send-by-bot","emitted":"to-user-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list","occ":1}, + {"command":"dev app get","emitted":"app-id","expect":"unified-app-id","via":"concept:app_id","occ":5}, + {"command":"chat message list-all","emitted":"from","expect":"start","via":"concept:time_start","occ":2}, + {"command":"chat message list-all","emitted":"start-time","expect":"start","via":"concept:time_start","occ":2}, + {"command":"chat message search-advanced","emitted":"group","expect":"conversation-ids","via":"native:reviewed-single-to-list","occ":4}, + {"command":"chat message send","emitted":"to-user","expect":"user","via":"override:scoped(reviewed+payload)","occ":4}, + {"command":"contact +dept-members","emitted":"name","expect":"dept","via":"override:scoped(reviewed)","occ":2}, + {"command":"contact +dept-members","emitted":"query","expect":"dept","via":"concept:search_query+bind","occ":2}, + {"command":"contact dept list-children","emitted":"parent-id","expect":"dept","via":"concept:dept_id","occ":2}, + {"command":"contact dept list-children","emitted":"parent","expect":"dept","via":"concept:dept_id","occ":2}, + {"command":"ding message receiver-status","emitted":"id","expect":"ding-id","via":"override:scoped(ding_id)","occ":2}, + {"command":"ding message receiver-status","emitted":"open-ding-id","expect":"ding-id","via":"concept:ding_id","occ":2}, + {"command":"chat group members add","emitted":"group","expect":"id","via":"override:bind(open_conversation_id)","occ":3}, + {"command":"attendance +check-result","emitted":"user-id","expect":"did-you-mean:blocked","via":"concept:user_ids+exclude","occ":2}, + {"command":"attendance check result","emitted":"user-ids","expect":"users","via":"concept:user_ids","occ":2}, + {"command":"chat group members remove","emitted":"group","expect":"id","via":"override:bind(open_conversation_id)","occ":2}, + {"command":"chat group set-admin","emitted":"user-id","expect":"user","via":"native:reviewed-compatibility-alias","occ":2}, + {"command":"ding message send","emitted":"robot","expect":"robot-code","via":"concept:robot_code","occ":2}, + {"command":"doc +export-get","emitted":"node","expect":"did-you-mean:blocked","via":"override:block","occ":2}, + {"command":"doc block delete","emitted":"index","expect":"did-you-mean:blocked","via":"override:block","occ":2}, + {"command":"doc block insert","emitted":"before-block-id","expect":"did-you-mean:blocked","via":"guard:requires-multi-parameter-transform","occ":2}, + {"command":"drive info","emitted":"workspace","expect":"space-id","via":"override:published-storage-space-compatibility","occ":2}, + {"command":"mail folder update","emitted":"folder-id","expect":"id","via":"override:bind(folder_id)","occ":2}, + {"command":"report outbox list","emitted":"template-type","expect":"did-you-mean:blocked","via":"override:block","occ":2}, + {"command":"chat +group-members","emitted":"group-name","expect":"group","via":"concept:group_name+bind"}, + {"command":"chat group get-by-group-id","emitted":"conversation-id","expect":"did-you-mean:blocked","via":"guard:open-conversation-id-vs-group-id"}, + {"command":"chat group get-by-group-id","emitted":"id","expect":"did-you-mean:blocked","via":"guard:generic-id-vs-group-id"}, + {"command":"chat group rename","emitted":"conversation-id","expect":"id","via":"concept:open_conversation_id+bind"}, + {"command":"chat group rename","emitted":"group-id","expect":"did-you-mean:blocked","via":"guard:group-id-vs-open-conversation-id"}, + {"command":"chat message send","emitted":"conversation-id","expect":"group","via":"concept:open_conversation_id"}, + {"command":"chat message add-emoji","emitted":"open-conversation-id","expect":"conversation-id","via":"concept:open_conversation_id"}, + {"command":"chat message add-emoji","emitted":"group-id","expect":"did-you-mean:blocked","via":"guard:group-id-vs-open-conversation-id"}, + {"command":"chat +group-members","emitted":"conversation-id","expect":"did-you-mean:blocked","via":"guard:group-name-vs-open-conversation-id"}, + {"command":"chat +send-to-group","emitted":"group-name","expect":"group","via":"concept:group_name+bind"}, + {"command":"chat message search-advanced","emitted":"open-conversation-ids","expect":"conversation-ids","via":"concept:open_conversation_ids"}, + {"command":"chat message search-advanced","emitted":"group-ids","expect":"did-you-mean:blocked","via":"guard:group-id-list-vs-open-conversation-id-list"}, + {"command":"chat message recall","emitted":"message-id","expect":"msg-id","via":"concept:open_message_id"}, + {"command":"chat message add-favorite","emitted":"msg-id","expect":"open-message-id","via":"concept:open_message_id"}, + {"command":"chat message list-by-ids","emitted":"message-ids","expect":"msg-ids","via":"concept:open_message_ids"}, + {"command":"chat message list-by-ids","emitted":"message-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"chat message reply","emitted":"ref-message-id","expect":"ref-msg-id","via":"concept:referenced_open_message_id"}, + {"command":"chat message reply","emitted":"message-id","expect":"did-you-mean:blocked","via":"guard:message-role"}, + {"command":"chat message forward-topic","emitted":"src-open-message-id","expect":"src-msg-id","via":"override:scoped-role"}, + {"command":"chat message forward-topic","emitted":"message-id","expect":"did-you-mean:blocked","via":"guard:message-role"}, + {"command":"chat message combine-forward","emitted":"src-open-cid","expect":"src-conversation-id","via":"override:scoped-role"}, + {"command":"chat message forward-topic","emitted":"dest-open-conversation-id","expect":"dest-conversation-id","via":"override:scoped-role"}, + {"command":"chat message forward","emitted":"conversation-id","expect":"did-you-mean:ambiguous","via":"guard:source-vs-destination-role"}, + {"command":"chat group share-invite","emitted":"conversation-id","expect":"did-you-mean:ambiguous","via":"guard:source-vs-target-role"}, + {"command":"chat message send","emitted":"user-id","expect":"user","via":"concept:user_id"}, + {"command":"chat message list","emitted":"user-id","expect":"user","via":"concept:user_id"}, + {"command":"chat +messages-list-direct","emitted":"user-id","expect":"user","via":"concept:user_id"}, + {"command":"attendance +check-result","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, + {"command":"contact +list-sub-depts","emitted":"parent-id","expect":"dept","via":"concept:dept_id"}, + {"command":"chat +conversation-info","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:user-id-vs-open-dingtalk-id"}, + {"command":"chat group members remove","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, + {"command":"chat group members remove","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"chat group create","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"chat +chat-set-admin","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"chat +messages-read-status","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"chat group members list-by-ids","emitted":"open-dingtalk-ids","expect":"users","via":"concept:open_dingtalk_ids+bind"}, + {"command":"chat group members remove-bot","emitted":"open-bot-id","expect":"bot-id","via":"concept:open_bot_id"}, + {"command":"chat group members remove-bot","emitted":"robot-code","expect":"did-you-mean:blocked","via":"guard:robot-code-vs-open-bot-id"}, + {"command":"chat group members add-bot","emitted":"robot","expect":"robot-code","via":"concept:robot_code"}, + {"command":"chat +bot-find","emitted":"name","expect":"query","via":"override:scoped"}, + {"command":"chat bot find","emitted":"name","expect":"query","via":"override:scoped"}, + {"command":"chat +bot-search","emitted":"query","expect":"name","via":"override:scoped"}, + {"command":"chat bot search","emitted":"query","expect":"name","via":"override:scoped"}, + {"command":"chat message list-favorites","emitted":"limit","expect":"size","via":"override:scoped"}, + {"command":"chat bot search","emitted":"current-page","expect":"page","via":"override:scoped"}, + {"command":"chat bot search","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-number-vs-cursor"}, + {"command":"chat message list-unread-conversations","emitted":"limit","expect":"count","via":"override:scoped"}, + {"command":"chat +messages-list-unread-conversations","emitted":"size","expect":"count","via":"override:scoped"}, + {"command":"chat message list","emitted":"start","expect":"time","via":"override:scoped"}, + {"command":"chat message list","emitted":"end","expect":"did-you-mean:blocked","via":"guard:single-time-vs-range"}, + {"command":"chat message list-all","emitted":"time","expect":"did-you-mean:blocked","via":"guard:time-range-required"}, + {"command":"chat message list-by-sender","emitted":"user-id","expect":"sender-user-id","via":"override:scoped-role"}, + {"command":"chat message list-by-sender","emitted":"open-dingtalk-id","expect":"sender-open-dingtalk-id","via":"override:scoped-role"}, + {"command":"chat category create-smart","emitted":"title","expect":"name","via":"override:scoped"}, + {"command":"chat category create","emitted":"name","expect":"title","via":"override:scoped"}, + {"command":"chat message send","emitted":"file-path","expect":"file","via":"native:reviewed-compatibility-alias"}, + {"command":"chat category add-conv","emitted":"category-id","expect":"did-you-mean:blocked","via":"override:block-cardinality"}, + {"command":"chat category rename","emitted":"category-ids","expect":"did-you-mean:blocked","via":"override:block-cardinality"}, + {"command":"chat group-role update","emitted":"role-ids","expect":"did-you-mean:blocked","via":"override:block-cardinality"}, + {"command":"chat message send-by-webhook","emitted":"at-user-ids","expect":"at-users","via":"override:scoped-role"}, + {"command":"chat message send-by-bot","emitted":"at-users","expect":"at-user-ids","via":"override:scoped-role"}, + {"command":"chat message send-by-bot","emitted":"at-ids","expect":"did-you-mean:ambiguous","via":"guard:user-id-vs-open-dingtalk-id"}, + {"command":"chat +bot-search","emitted":"current-page","expect":"page","via":"override:scoped"}, + {"command":"chat +category-create","emitted":"name","expect":"title","via":"override:scoped"}, + {"command":"chat +category-rename","emitted":"name","expect":"title","via":"override:scoped"}, + {"command":"chat +messages-list-direct","emitted":"start","expect":"time","via":"override:scoped"}, + {"command":"chat +messages-list-unread-conversations","emitted":"limit","expect":"count","via":"override:scoped"}, + {"command":"chat +messages-send-by-webhook","emitted":"at-user-ids","expect":"at-users","via":"override:scoped-role"}, + {"command":"chat +unread-chats","emitted":"limit","expect":"count","via":"override:scoped"}, + {"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:workspace_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":"text","expect":"content","via":"native:reviewed-compatibility-alias"}, + {"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 +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":"conversation-id","expect":"group","via":"native:reviewed-compatibility-alias"}, + {"command":"chat +messages-reply","emitted":"chat","expect":"group","via":"concept:open_conversation_id"}, + {"command":"chat +messages-reply","emitted":"open-conversation-id","expect":"group","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 group audit-join-validation","emitted":"user","expect":"did-you-mean:ambiguous","via":"guard:applicant-vs-inviter-role"}, + {"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","occ":1}, + {"command":"doc +inspect","emitted":"include-info","expect":"did-you-mean:blocked","via":"guard:base-info-always-returned","occ":1}, + {"command":"doc +update","emitted":"mode","expect":"command","via":"override:scoped-operation","occ":5}, + {"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:workspace_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"}, + {"command":"doc +copy","emitted":"dentry-uuid","expect":"node","via":"concept:doc_node_id"}, + {"command":"doc info","emitted":"dentry-id","expect":"did-you-mean:blocked","via":"guard:dentry-id-vs-dentry-uuid"}, + {"command":"doc create","emitted":"knowledge-base-id","expect":"workspace","via":"concept:workspace_id"}, + {"command":"doc create","emitted":"space-id","expect":"workspace","via":"override:published-workspace-compatibility"}, + {"command":"drive list","emitted":"knowledge-base-id","expect":"workspace","via":"concept:workspace_id"}, + {"command":"drive list","emitted":"order-field","expect":"order-by","via":"override:scoped-sort-field"}, + {"command":"drive info","emitted":"dentry-uuid","expect":"node","via":"override:scoped-node-id"}, + {"command":"drive info","emitted":"dentry-id","expect":"did-you-mean:blocked","via":"guard:dentry-id-vs-dentry-uuid"}, + {"command":"drive copy","emitted":"target-folder-id","expect":"folder","via":"override:scoped-destination-folder"}, + {"command":"drive copy","emitted":"target-id","expect":"did-you-mean:ambiguous","via":"guard:destination-role-required"}, + {"command":"drive search","emitted":"created-after","expect":"created-from","via":"concept:created_time_start"}, + {"command":"drive search","emitted":"created-before","expect":"created-to","via":"concept:created_time_end"}, + {"command":"drive search","emitted":"modified-after","expect":"modified-from","via":"concept:drive_modified_time_start"}, + {"command":"drive search","emitted":"modified-before","expect":"modified-to","via":"concept:drive_modified_time_end"}, + {"command":"drive search","emitted":"creator-user-ids","expect":"creator-uids","via":"concept:creator_user_ids"}, + {"command":"drive permission add","emitted":"permission-role","expect":"role","via":"concept:document_permission_role"}, + {"command":"drive permission list","emitted":"role","expect":"did-you-mean:blocked","via":"guard:permission-role-vs-filter-role"}, + {"command":"drive upload","emitted":"source-file","expect":"file","via":"override:scoped-local-file"}, + {"command":"doc +search","emitted":"created-after","expect":"created-from","via":"concept:created_time_start"}, + {"command":"doc +search","emitted":"create-time-start","expect":"created-from","via":"concept:created_time_start","occ":1}, + {"command":"doc +search","emitted":"create-time-end","expect":"created-to","via":"concept:created_time_end","occ":1}, + {"command":"doc +search","emitted":"creator-user-ids","expect":"creator-uids","via":"concept:creator_user_ids"}, + {"command":"doc +access-grant","emitted":"permission-role","expect":"role","via":"concept:document_permission_role"}, + {"command":"doc +export","emitted":"output-path","expect":"output","via":"concept:local_output_path"}, + {"command":"drive download","emitted":"destination-path","expect":"output","via":"concept:local_output_path"}, + {"command":"drive download","emitted":"version-number","expect":"version","via":"concept:doc_version_number"}, + {"command":"drive recycle restore","emitted":"recycle-item-id","expect":"id","via":"concept:drive_recycle_item_id"}, + {"command":"drive upload-info","emitted":"size-bytes","expect":"file-size","via":"concept:drive_file_size_bytes"}, + {"command":"drive +info","emitted":"drive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive commit","emitted":"storage-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive download","emitted":"dingdrive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive info","emitted":"drive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive list","emitted":"storage-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive list","emitted":"sort-direction","expect":"order","via":"concept:drive_sort_direction"}, + {"command":"drive mkdir","emitted":"dingdrive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive recycle list","emitted":"drive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive upload","emitted":"storage-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive upload-info","emitted":"dingdrive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"doc +access-grant","emitted":"user","expect":"did-you-mean:ambiguous","via":"guard:collaborator-name-vs-id-value-domain","occ":6}, + {"command":"doc +access-grant","emitted":"target-user","expect":"did-you-mean:ambiguous","via":"guard:collaborator-name-vs-id-value-domain","occ":1}, + {"command":"doc +access-grant","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:user-id-cannot-feed-name-resolver","occ":1}, + {"command":"doc +access-grant","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:user-id-cannot-feed-name-resolver","occ":1}, + {"command":"doc +access-grant","emitted":"target-user-id","expect":"did-you-mean:blocked","via":"guard:user-id-cannot-feed-name-resolver"}, + {"command":"doc +access-grant","emitted":"target-user-ids","expect":"did-you-mean:blocked","via":"guard:user-id-cannot-feed-name-resolver"}, + {"command":"doc +fetch","emitted":"content-format","expect":"did-you-mean:blocked","via":"guard:content-format-vs-detail-contract","occ":3}, + {"command":"doc +fetch","emitted":"doc-format","expect":"did-you-mean:blocked","via":"guard:content-format-vs-detail-contract","occ":1}, + {"command":"doc +fetch","emitted":"range","expect":"did-you-mean:blocked","via":"guard:composite-range-needs-block-ids","occ":1}, + {"command":"doc +inspect","emitted":"include-access","expect":"include-permissions","via":"override:scoped-permission-section","occ":3}, + {"command":"doc +inspect","emitted":"include-member","expect":"include-permissions","via":"override:scoped-permission-section","occ":1}, + {"command":"doc +inspect","emitted":"include-members","expect":"include-permissions","via":"override:scoped-permission-section","occ":2}, + {"command":"doc +inspect","emitted":"include-meta","expect":"did-you-mean:blocked","via":"guard:base-metadata-already-returned","occ":2}, + {"command":"doc +inspect","emitted":"include-metadata","expect":"did-you-mean:blocked","via":"guard:base-metadata-already-returned","occ":1}, + {"command":"doc +inspect","emitted":"include-blocks","expect":"did-you-mean:blocked","via":"guard:content-read-belongs-to-fetch","occ":1}, + {"command":"doc +inspect","emitted":"include-content","expect":"did-you-mean:blocked","via":"guard:content-read-belongs-to-fetch","occ":1}, + {"command":"doc +inspect","emitted":"role","expect":"did-you-mean:blocked","via":"guard:permission-role-vs-document-node","occ":1}, + {"command":"doc +copy","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-node-role","occ":2}, + {"command":"doc +export","emitted":"wait","expect":"did-you-mean:blocked","via":"guard:workflow-wait-vs-max-polls","occ":1}, + {"command":"doc +media-insert","emitted":"after-block-id","expect":"did-you-mean:blocked","via":"guard:requires-ref-block-and-where-expansion","occ":1}, + {"command":"doc +media-insert","emitted":"before-block-id","expect":"did-you-mean:blocked","via":"guard:requires-ref-block-and-where-expansion"}, + {"command":"doc +update","emitted":"content-file","expect":"did-you-mean:blocked","via":"guard:requires-file-read-transform","occ":1}, + {"command":"doc +update","emitted":"element","expect":"did-you-mean:ambiguous","via":"guard:content-vs-block-vs-reference-role","occ":1}, + {"command":"doc update","emitted":"stdin","expect":"did-you-mean:blocked","via":"guard:stdin-requires-content-dash-transform","occ":1}, + {"command":"doc +version-save","emitted":"title","expect":"did-you-mean:blocked","via":"guard:unsupported-version-metadata","occ":1}, + {"command":"doc +version-save","emitted":"message","expect":"did-you-mean:blocked","via":"guard:unsupported-version-metadata","occ":1}, + {"command":"drive +search","emitted":"keyword","expect":"query","via":"concept:search_query","occ":1}, + {"command":"contact +search-user","emitted":"name","expect":"did-you-mean:blocked","via":"guard:person-name-vs-search-query","occ":1}, + {"command":"drive copy","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-node-role"}, + {"command":"drive +copy","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-node-role"}, + {"command":"drive move","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-node-role"}, + {"command":"drive +move","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-node-role"}, + {"command":"drive shortcut","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-node-role"}, + {"command":"drive +cover","emitted":"dentry-uuid","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +cover","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-drive-node"}, + {"command":"drive +create-folder","emitted":"folder-name","expect":"name","via":"override:scoped-folder-name"}, + {"command":"drive +create-folder","emitted":"storage-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive +create-shortcut","emitted":"source-file-id","expect":"node","via":"override:scoped-source-node"}, + {"command":"drive +create-shortcut","emitted":"target-folder-id","expect":"folder","via":"override:scoped-target-folder"}, + {"command":"drive +create-shortcut","emitted":"target-workspace-id","expect":"workspace","via":"override:scoped-target-workspace"}, + {"command":"drive +create-shortcut","emitted":"name","expect":"did-you-mean:blocked","via":"guard:fuzzy-name-vs-drive-node"}, + {"command":"drive +delete","emitted":"file-id","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +delete","emitted":"dentry-id","expect":"did-you-mean:blocked","via":"guard:dentry-id-vs-node-id"}, + {"command":"drive +download","emitted":"dentry-uuid","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +download","emitted":"destination-path","expect":"output","via":"concept:local_output_path"}, + {"command":"drive +inspect","emitted":"include-statistics","expect":"include-stats","via":"override:scoped-inspect-section"}, + {"command":"drive +inspect","emitted":"include-history","expect":"did-you-mean:blocked","via":"guard:doc-inspect-section"}, + {"command":"drive +list","emitted":"folder-id","expect":"folder","via":"concept:folder_id"}, + {"command":"drive +list","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"drive +list","emitted":"next-token","expect":"cursor","via":"concept:page_cursor"}, + {"command":"drive +list","emitted":"sort-direction","expect":"order","via":"concept:drive_sort_direction"}, + {"command":"drive +list","emitted":"sort-by","expect":"order-by","via":"override:scoped-sort-field"}, + {"command":"drive +publish-get","emitted":"dentry-uuid","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +publish-unset","emitted":"file-id","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +recycle-list","emitted":"storage-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"drive +recycle-list","emitted":"page-token","expect":"cursor","via":"concept:page_cursor"}, + {"command":"drive +recycle-restore","emitted":"recycle-item-id","expect":"id","via":"override:bind(drive_recycle_item_id)"}, + {"command":"drive +recycle-restore","emitted":"node-id","expect":"did-you-mean:blocked","via":"guard:recycle-item-vs-node-id"}, + {"command":"drive +rename","emitted":"file-id","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +rename","emitted":"new-name","expect":"name","via":"override:scoped-display-name"}, + {"command":"drive +star-add","emitted":"dentry-uuid","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +star-list","emitted":"max-results","expect":"limit","via":"concept:pagination_size"}, + {"command":"drive +star-list","emitted":"types","expect":"did-you-mean:ambiguous","via":"guard:content-type-value-domain"}, + {"command":"drive +star-remove","emitted":"file-id","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +stats","emitted":"dentry-uuid","expect":"node","via":"override:scoped-drive-node"}, + {"command":"drive +upload","emitted":"source-file","expect":"file","via":"override:scoped-local-file"}, + {"command":"drive +upload","emitted":"name","expect":"file-name","via":"override:scoped-remote-name"}, + {"command":"drive +upload","emitted":"overwrite-node-id","expect":"node","via":"override:scoped-overwrite-node"}, + {"command":"drive +upload","emitted":"workspace-id","expect":"did-you-mean:blocked","via":"guard:unsupported-workspace-route"}, + {"command":"drive +version-download","emitted":"version-number","expect":"version","via":"concept:doc_version_number"}, + {"command":"drive +version-download","emitted":"save-path","expect":"output","via":"concept:local_output_path"}, + {"command":"drive +version-get","emitted":"version-no","expect":"version","via":"concept:doc_version_number"}, + {"command":"drive +version-history","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"drive +version-history","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"drive +version-revert","emitted":"version-number","expect":"version","via":"concept:doc_version_number"}, + {"command":"drive +version-revert","emitted":"revision","expect":"did-you-mean:blocked","via":"guard:document-revision-vs-file-version"}, + {"command":"drive +cover","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +create-shortcut","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +delete","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +download","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +inspect","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +publish-get","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +publish-unset","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +rename","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +star-add","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +star-remove","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +stats","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +upload","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +version-download","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +version-get","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +version-history","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +version-revert","emitted":"node-id","expect":"node","via":"override:node-id-parity"}, + {"command":"drive +cover","emitted":"url","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +create-shortcut","emitted":"document-id","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +delete","emitted":"folder-id","expect":"node","via":"override:single-folder-node-role"}, + {"command":"drive +inspect","emitted":"document-id","expect":"node","via":"override:document-node-id"}, + {"command":"drive +inspect","emitted":"folder-id","expect":"node","via":"override:single-folder-node-role"}, + {"command":"drive +publish-get","emitted":"url","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +publish-unset","emitted":"document-url","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +rename","emitted":"document-id","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +rename","emitted":"folder-id","expect":"node","via":"override:single-folder-node-role"}, + {"command":"drive +star-add","emitted":"url","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +star-remove","emitted":"doc-id","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +stats","emitted":"document-url","expect":"node","via":"override:existing-command-parity"}, + {"command":"drive +upload","emitted":"file-id","expect":"node","via":"override:overwrite-node-id-role"}, {"command":"aitable +find-record","emitted":"base-id","expect":"base","via":"concept:base_id"}, {"command":"aitable record query","emitted":"base-token","expect":"base-id","via":"concept:base_id"}, {"command":"aitable +find-record","emitted":"table-id","expect":"table","via":"concept:table_id"}, @@ -1102,7 +1195,355 @@ {"command":"wiki +member-update","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, {"command":"wiki +member-update","emitted":"uid","expect":"user","via":"override:scoped_strict"}, {"command":"wiki +member-remove","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, - {"command":"wiki +member-remove","emitted":"uid","expect":"user","via":"override:scoped_strict"} + {"command":"wiki +member-remove","emitted":"uid","expect":"user","via":"override:scoped_strict"}, + {"command":"attendance +check-record","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +check-record","emitted":"target-user-ids","expect":"users","via":"full-product-review:alias"}, + {"command":"attendance +check-result","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +check-result","emitted":"target-user-ids","expect":"users","via":"full-product-review:alias"}, + {"command":"attendance +get-adjustment-rule","emitted":"adjustment-rule-id","expect":"adjustment-id","via":"full-product-review:alias"}, + {"command":"attendance +get-adjustment-rule","emitted":"class-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-approve-template","emitted":"approval-type","expect":"type","via":"full-product-review:alias"}, + {"command":"attendance +get-approve-template","emitted":"approval-types","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-checkin-record","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-checkin-record","emitted":"operator-company-id","expect":"operator-corp-id","via":"full-product-review:alias"}, + {"command":"attendance +get-checkin-record","emitted":"operator-user-id","expect":"operator-staff-id","via":"full-product-review:alias"}, + {"command":"attendance +get-checkin-record","emitted":"user-id","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"attendance +get-leave-records","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-leave-records","emitted":"vacation-type-code","expect":"leave-code","via":"full-product-review:alias"}, + {"command":"attendance +get-overtime-rule","emitted":"adjustment-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-overtime-rule","emitted":"overtime-rule-id","expect":"overtime-id","via":"full-product-review:alias"}, + {"command":"attendance +get-schedule","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-schedule","emitted":"target-user-ids","expect":"users","via":"full-product-review:alias"}, + {"command":"attendance +get-schedule","emitted":"work-date-begin","expect":"start","via":"full-product-review:alias"}, + {"command":"attendance +get-self-setting","emitted":"scene","expect":"setting-scene","via":"override:latest-main-setting-scene"}, + {"command":"attendance +get-self-setting","emitted":"setting-type","expect":"did-you-mean:blocked","via":"guard:setting-type-vs-scene"}, + {"command":"attendance +get-self-setting","emitted":"staff-id","expect":"user","via":"full-product-review:alias"}, + {"command":"attendance +get-summary","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +get-summary","emitted":"attendance-date","expect":"date","via":"full-product-review:alias"}, + {"command":"attendance +get-summary","emitted":"statistics-type","expect":"stats-type","via":"full-product-review:alias"}, + {"command":"attendance +list-approve","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +list-approve","emitted":"target-user-ids","expect":"users","via":"full-product-review:alias"}, + {"command":"attendance +query-report-data","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +query-report-data","emitted":"report-column-ids","expect":"columns","via":"full-product-review:alias"}, + {"command":"attendance +search-adjustment-rule","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +search-adjustment-rule","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"attendance +search-class","emitted":"class-filter-type","expect":"filter-type","via":"full-product-review:alias"}, + {"command":"attendance +search-class","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +search-group","emitted":"attendance-group-type","expect":"type","via":"full-product-review:alias"}, + {"command":"attendance +search-group","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +search-overtime-rule","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"attendance +search-overtime-rule","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"mail +contact-list","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +contact-list","emitted":"next-page-token","expect":"cursor","via":"full-product-review:alias"}, + {"command":"mail +find-mail-user","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +find-mail-user","emitted":"max-results","expect":"limit","via":"full-product-review:alias"}, + {"command":"mail +find-mail-user","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"mail +folder-list","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +folder-list","emitted":"folder-id","expect":"folder","via":"full-product-review:alias"}, + {"command":"mail +message","emitted":"attachment-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +message","emitted":"message-id","expect":"id","via":"full-product-review:alias"}, + {"command":"mail +messages","emitted":"conversation-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +messages","emitted":"message-ids","expect":"ids","via":"full-product-review:alias"}, + {"command":"mail +recent-mail","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +recent-mail","emitted":"max-results","expect":"limit","via":"full-product-review:alias"}, + {"command":"mail +recent-mail","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"mail +search-mail","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +search-mail","emitted":"max-results","expect":"size","via":"full-product-review:alias"}, + {"command":"mail +search-mail","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"mail +template-list","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +template-list","emitted":"next-page-token","expect":"cursor","via":"full-product-review:alias"}, + {"command":"mail +thread","emitted":"conversation-id","expect":"id","via":"full-product-review:alias"}, + {"command":"mail +thread","emitted":"mail-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +thread-list","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +thread-list","emitted":"next-page-token","expect":"cursor","via":"full-product-review:alias"}, + {"command":"mail +triage","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"mail +triage","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"mail +triage","emitted":"subject","expect":"did-you-mean:blocked","via":"guard:kql-field-not-flag"}, + {"command":"mail +unread-mail","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +unread-mail","emitted":"max-results","expect":"size","via":"full-product-review:alias"}, + {"command":"mail +unread-mail","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"mail +user-search","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"mail +user-search","emitted":"next-page-token","expect":"cursor","via":"full-product-review:alias"}, + {"command":"oa +list-cc","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"oa +list-cc","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"oa +list-executed","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"oa +list-executed","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"oa +list-forms","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"oa +list-forms","emitted":"next-page-token","expect":"cursor","via":"full-product-review:alias"}, + {"command":"oa +list-pending","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"oa +list-pending","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"oa +list-submitted","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"oa +list-submitted","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"oa +my-initiated","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"oa +my-initiated","emitted":"current-page","expect":"page","via":"full-product-review:alias"}, + {"command":"ding +list","emitted":"message-type","expect":"type","via":"override:scoped_strict"}, + {"command":"ding +list","emitted":"next-cursor","expect":"cursor","via":"override:scoped_strict"}, + {"command":"ding +list","emitted":"page-token","expect":"did-you-mean:blocked","via":"guard:integer-cursor-vs-token"}, + {"command":"ding +list","emitted":"remind-type","expect":"did-you-mean:blocked","via":"guard:list-enum-vs-reminder-enum"}, + {"command":"ding +recall-personal","emitted":"ding-id","expect":"id","via":"concept:ding_id+bind"}, + {"command":"ding +send-personal","emitted":"open-dingtalk-ids","expect":"users","via":"concept:open_dingtalk_ids+bind"}, + {"command":"ding +send-personal","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:open-dingtalk-id-vs-user-id"}, + {"command":"report +outbox-list","emitted":"page-size","expect":"size","via":"concept:pagination_size"}, + {"command":"report +report-latest","emitted":"end-time","expect":"end","via":"concept:time_end"}, + {"command":"report +report-latest","emitted":"start-time","expect":"start","via":"concept:time_start"}, + {"command":"report +report-latest","emitted":"template-id","expect":"did-you-mean:blocked","via":"guard:template-id-vs-name"}, + {"command":"report +report-latest","emitted":"template-name","expect":"keyword","via":"override:template-name"}, + {"command":"report +template-search","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"report +template-search","emitted":"template-id","expect":"did-you-mean:blocked","via":"guard:template-id-vs-search-query"}, + {"command":"sheet +list-sheets","emitted":"block-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"sheet +list-sheets","emitted":"dentry-uuid","expect":"node","via":"full-product-review:alias"}, + {"command":"sheet +list-sheets","emitted":"document-id","expect":"node","via":"full-product-review:alias"}, + {"command":"sheet +read","emitted":"a1-ranges","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"sheet +read","emitted":"worksheet-id","expect":"sheet-id","via":"full-product-review:alias"}, + {"command":"markdown create","emitted":"body","expect":"content","via":"concept:content_text"}, + {"command":"markdown create","emitted":"content-file","expect":"did-you-mean:ambiguous","via":"guard:content-source-transport"}, + {"command":"markdown create","emitted":"drive-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"markdown create","emitted":"filename","expect":"name","via":"concept:markdown_file_name"}, + {"command":"markdown create","emitted":"local-file","expect":"file","via":"concept:markdown_local_file_path"}, + {"command":"markdown create","emitted":"node-id","expect":"did-you-mean:blocked","via":"guard:create-vs-overwrite"}, + {"command":"markdown create","emitted":"source-file","expect":"file","via":"override:source-file"}, + {"command":"markdown diff","emitted":"context-lines","expect":"context","via":"override:diff-context-lines"}, + {"command":"markdown diff","emitted":"from-version","expect":"version","via":"override:left-version-role"}, + {"command":"markdown diff","emitted":"local-file","expect":"file","via":"concept:markdown_local_file_path"}, + {"command":"markdown diff","emitted":"path","expect":"did-you-mean:ambiguous","via":"guard:node-vs-local-file"}, + {"command":"markdown diff","emitted":"source-file","expect":"file","via":"override:source-file"}, + {"command":"markdown diff","emitted":"to-version","expect":"version2","via":"override:right-version-role"}, + {"command":"markdown diff","emitted":"version-id","expect":"did-you-mean:blocked","via":"guard:version-number-vs-id"}, + {"command":"markdown diff","emitted":"version-no","expect":"version","via":"concept:doc_version_number"}, + {"command":"markdown fetch","emitted":"path","expect":"did-you-mean:ambiguous","via":"guard:remote-node-vs-local-output"}, + {"command":"markdown fetch","emitted":"save-path","expect":"output","via":"concept:local_output_path"}, + {"command":"markdown fetch","emitted":"storage-space-id","expect":"space-id","via":"concept:drive_storage_space_id"}, + {"command":"markdown fetch","emitted":"url","expect":"did-you-mean:blocked","via":"guard:node-id-vs-url"}, + {"command":"markdown overwrite","emitted":"document-id","expect":"node","via":"override:remote-node-id"}, + {"command":"markdown overwrite","emitted":"file-name","expect":"name","via":"concept:markdown_file_name"}, + {"command":"markdown overwrite","emitted":"folder-id","expect":"did-you-mean:blocked","via":"guard:existing-node-vs-parent-folder"}, + {"command":"markdown overwrite","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:destructive-node-target"}, + {"command":"markdown overwrite","emitted":"preview-only","expect":"dry-run","via":"override:preview-safety"}, + {"command":"markdown overwrite","emitted":"source-file","expect":"file","via":"override:source-file"}, + {"command":"markdown overwrite","emitted":"text","expect":"content","via":"concept:content_text"}, + {"command":"markdown patch","emitted":"file","expect":"did-you-mean:blocked","via":"guard:no-file-replacement"}, + {"command":"markdown patch","emitted":"find-text","expect":"pattern","via":"override:patch-pattern"}, + {"command":"markdown patch","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:destructive-node-target"}, + {"command":"markdown patch","emitted":"preview-only","expect":"dry-run","via":"override:preview-safety"}, + {"command":"markdown patch","emitted":"regexp","expect":"regex","via":"override:patch-regex"}, + {"command":"markdown patch","emitted":"replacement","expect":"content","via":"override:replacement-role"}, + {"command":"markdown patch","emitted":"text","expect":"did-you-mean:ambiguous","via":"guard:pattern-vs-replacement"}, + {"command":"aisearch","emitted":"search-term","expect":"query","via":"override:person-target"}, + {"command":"aisearch","emitted":"dimensions","expect":"dimension","via":"concept:aisearch_person_dimension"}, + {"command":"aisearch","emitted":"types","expect":"did-you-mean:blocked","via":"guard:compat-root-is-person-only"}, + {"command":"aisearch","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:target-vs-dimension-role"}, + {"command":"aisearch person","emitted":"person-name","expect":"query","via":"override:person-target"}, + {"command":"aisearch person","emitted":"search-by","expect":"dimension","via":"concept:aisearch_person_dimension"}, + {"command":"aisearch person","emitted":"queries","expect":"did-you-mean:blocked","via":"guard:person-vs-content-query-list"}, + {"command":"aisearch person","emitted":"mobile","expect":"did-you-mean:blocked","via":"guard:full-mobile-routes-to-contact"}, + {"command":"aisearch person","emitted":"job-number","expect":"did-you-mean:blocked","via":"guard:requires-query-plus-dimension"}, + {"command":"aisearch person","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:target-vs-dimension-role"}, + {"command":"aisearch enterprise","emitted":"topics","expect":"queries","via":"concept:aisearch_content_queries"}, + {"command":"aisearch enterprise","emitted":"content-types","expect":"types","via":"concept:aisearch_content_types"}, + {"command":"aisearch enterprise","emitted":"time-window","expect":"time-range","via":"concept:aisearch_time_range"}, + {"command":"aisearch enterprise","emitted":"behavior-type","expect":"did-you-mean:blocked","via":"guard:content-vs-behavior"}, + {"command":"aisearch enterprise","emitted":"direction","expect":"did-you-mean:blocked","via":"guard:no-behavior-flow"}, + {"command":"aisearch enterprise","emitted":"node-id","expect":"did-you-mean:blocked","via":"guard:search-vs-resource-read"}, + {"command":"aisearch enterprise","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:content-type-list-role"}, + {"command":"aisearch enterprise","emitted":"question","expect":"did-you-mean:ambiguous","via":"guard:no-natural-language-slotting"}, + {"command":"aisearch behavior","emitted":"topics","expect":"queries","via":"concept:aisearch_content_queries"}, + {"command":"aisearch behavior","emitted":"resource-types","expect":"types","via":"concept:aisearch_content_types"}, + {"command":"aisearch behavior","emitted":"period","expect":"time-range","via":"concept:aisearch_time_range"}, + {"command":"aisearch behavior","emitted":"action-type","expect":"behavior-type","via":"override:behavior-role"}, + {"command":"aisearch behavior","emitted":"group-name","expect":"chat-scope","via":"override:chat-scope-role"}, + {"command":"aisearch behavior","emitted":"interaction-flow","expect":"direction","via":"override:direction-role"}, + {"command":"aisearch behavior","emitted":"dimension","expect":"did-you-mean:blocked","via":"guard:behavior-vs-person"}, + {"command":"aisearch behavior","emitted":"from","expect":"did-you-mean:blocked","via":"guard:cannot-construct-direction"}, + {"command":"aisearch behavior","emitted":"chat-id","expect":"did-you-mean:blocked","via":"guard:chat-scope-expects-name"}, + {"command":"aisearch behavior","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:content-type-list-role"}, + {"command":"aisearch behavior","emitted":"action","expect":"did-you-mean:ambiguous","via":"guard:behavior-enum-role"}, + {"command":"aisearch behavior","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:actor-direction-target"}, + {"command":"aisearch +search-person","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"aisearch +search-person","emitted":"person-name","expect":"query","via":"override:person-target"}, + {"command":"aisearch +search-person","emitted":"search-by","expect":"dimensions","via":"concept:aisearch_person_dimension+bind"}, + {"command":"aisearch +search-person","emitted":"mobile","expect":"did-you-mean:blocked","via":"guard:full-mobile-routes-to-contact"}, + {"command":"aisearch +search-person","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:target-vs-dimension-role"}, + {"command":"contact +by-mobile","emitted":"org-user-mobile","expect":"mobile","via":"full-product-review:alias"}, + {"command":"contact +by-mobile","emitted":"email","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +list-role-members","emitted":"label-id","expect":"id","via":"full-product-review:alias"}, + {"command":"contact +list-role-members","emitted":"dept","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +lookup","emitted":"employee-name","expect":"name","via":"full-product-review:alias"}, + {"command":"contact +lookup","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +org","emitted":"employee-name","expect":"name","via":"full-product-review:alias"}, + {"command":"contact +org","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +search-mobile","emitted":"org-user-mobile","expect":"mobile","via":"full-product-review:alias"}, + {"command":"contact +search-mobile","emitted":"email","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +team","emitted":"employee-name","expect":"name","via":"full-product-review:alias"}, + {"command":"contact +team","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact account create","emitted":"organization-user-name","expect":"org-user-name","via":"full-product-review:alias"}, + {"command":"contact account create","emitted":"creator-username","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact account update","emitted":"department-assignments-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact account update","emitted":"department-memberships-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact account update","emitted":"manager","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact dept create","emitted":"parent-department-id","expect":"parent","via":"full-product-review:alias"}, + {"command":"contact dept create","emitted":"department-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept get-info","emitted":"department-id","expect":"dept","via":"full-product-review:alias"}, + {"command":"contact dept get-info","emitted":"department","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept list-members","emitted":"department-ids","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact dept list-members","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept search","emitted":"search-word","expect":"query","via":"full-product-review:alias"}, + {"command":"contact dept search","emitted":"subject","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept update","emitted":"parent-department-id","expect":"parent","via":"full-product-review:alias"}, + {"command":"contact dept update","emitted":"target-department-id","expect":"dept","via":"full-product-review:alias"}, + {"command":"contact dept update","emitted":"department","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact label get","emitted":"label-names","expect":"names","via":"full-product-review:alias"}, + {"command":"contact label get","emitted":"dept-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact label list-members","emitted":"dept","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact org create","emitted":"organization-name","expect":"org-name","via":"full-product-review:alias"}, + {"command":"contact org create","emitted":"name","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact user dismission search","emitted":"department-ids","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user dismission search","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user get","emitted":"member-user-ids","expect":"ids","via":"full-product-review:alias"}, + {"command":"contact user get","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user invite","emitted":"department-assignments-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user invite","emitted":"department-memberships-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user invite","emitted":"creator-username","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user profile get","emitted":"field-code-list","expect":"fields","via":"full-product-review:alias"}, + {"command":"contact user profile get","emitted":"output-fields","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user search","emitted":"search-word","expect":"query","via":"full-product-review:alias"}, + {"command":"contact user search","emitted":"subject","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user search-mobile","emitted":"org-user-mobile","expect":"mobile","via":"full-product-review:alias"}, + {"command":"contact user search-mobile","emitted":"email","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user update","emitted":"department-assignments-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user update","emitted":"department-memberships-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user update","emitted":"manager","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact user update-ownness","emitted":"personal-status","expect":"ownness-text","via":"full-product-review:alias"}, + {"command":"contact user update-ownness","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user update-self","emitted":"profile-image-file-id","expect":"avatar-file-id","via":"full-product-review:alias"}, + {"command":"contact user update-self","emitted":"file","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +list-followings","emitted":"open-dingtalk-id","expect":"open-id","via":"override:exact-open-id-role"}, + {"command":"contact +list-followings","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:open-dingtalk-id-vs-user-id"}, + {"command":"contact +list-followings","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-identifier"}, + {"command":"contact +list-dept-members","emitted":"department-ids","expect":"depts","via":"concept:dept_ids"}, + {"command":"contact +list-dept-members","emitted":"dept-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"contact account update","emitted":"profile-image-file-id","expect":"avatar-file-id","via":"override:avatar-file-role"}, + {"command":"live stream list","emitted":"page","expect":"did-you-mean:blocked","via":"guard:no-pagination-input"}, + {"command":"live stream list","emitted":"limit","expect":"did-you-mean:blocked","via":"guard:no-pagination-input"}, + {"command":"live stream list","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:no-pagination-input"}, + {"command":"live stream list","emitted":"status","expect":"did-you-mean:blocked","via":"guard:return-field-not-filter"}, + {"command":"live stream list","emitted":"query","expect":"did-you-mean:blocked","via":"guard:no-search-input"}, + {"command":"live stream list","emitted":"live-id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-live"}, + {"command":"live stream list","emitted":"anchor-id","expect":"did-you-mean:blocked","via":"guard:implicit-current-user"}, + {"command":"live stream list","emitted":"start-time","expect":"did-you-mean:blocked","via":"guard:no-time-range"}, + {"command":"live stream list","emitted":"title","expect":"did-you-mean:blocked","via":"guard:list-vs-create"}, + {"command":"live stream list","emitted":"group-id","expect":"did-you-mean:blocked","via":"guard:no-audience-control"}, + {"command":"live stream list","emitted":"playback","expect":"did-you-mean:blocked","via":"guard:no-playback-control"}, + {"command":"live stream list","emitted":"start-live","expect":"did-you-mean:blocked","via":"guard:no-live-control"}, + {"command":"live stream list","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-live-or-user-id"}, + {"command":"live stream list","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:no-type-filter"}, + {"command":"live stream list","emitted":"time","expect":"did-you-mean:ambiguous","via":"guard:no-time-filter"}, + {"command":"live stream list","emitted":"user","expect":"did-you-mean:ambiguous","via":"guard:implicit-current-user"}, + {"command":"devdoc article search","emitted":"q","expect":"query","via":"concept:search_query"}, + {"command":"devdoc article search","emitted":"keywords","expect":"query","via":"concept:search_query"}, + {"command":"devdoc article search","emitted":"search-word","expect":"query","via":"concept:search_query"}, + {"command":"devdoc article search","emitted":"search-term","expect":"query","via":"override:scoped-query"}, + {"command":"devdoc article search","emitted":"query-text","expect":"query","via":"override:scoped-query"}, + {"command":"devdoc article search","emitted":"results-per-page","expect":"size","via":"override:scoped-page-size"}, + {"command":"devdoc article search","emitted":"page-number","expect":"page","via":"override:scoped-page-number"}, + {"command":"devdoc article search","emitted":"offset","expect":"did-you-mean:blocked","via":"guard:page-vs-offset"}, + {"command":"devdoc article search","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-vs-cursor"}, + {"command":"devdoc article search","emitted":"page-index","expect":"did-you-mean:blocked","via":"guard:one-based-vs-index"}, + {"command":"devdoc article search","emitted":"doc-id","expect":"did-you-mean:blocked","via":"guard:search-vs-object-id"}, + {"command":"devdoc article search","emitted":"error-code","expect":"did-you-mean:blocked","via":"guard:diagnostic-field-needs-query"}, + {"command":"devdoc article search","emitted":"context","expect":"did-you-mean:blocked","via":"guard:requires-query-composition"}, + {"command":"devdoc article search","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-id-role"}, + {"command":"devdoc article search","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:unspecified-type-role"}, + {"command":"hrbrain talent-pool list","emitted":"q","expect":"keyword","via":"concept:search_query"}, + {"command":"hrbrain talent-pool list","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain talent-pool list","emitted":"current-page","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain talent-pool list","emitted":"label-list","expect":"labels","via":"override:labels"}, + {"command":"hrbrain talent-pool list","emitted":"pool-code","expect":"did-you-mean:blocked","via":"guard:list-vs-detail"}, + {"command":"hrbrain talent-pool list","emitted":"creator-id","expect":"did-you-mean:ambiguous","via":"guard:unproven-creator-domain"}, + {"command":"hrbrain talent-pool detail","emitted":"talent-pool-code","expect":"pool-code","via":"concept:hrbrain_pool_code"}, + {"command":"hrbrain talent-pool detail","emitted":"page","expect":"did-you-mean:blocked","via":"guard:detail-vs-list"}, + {"command":"hrbrain talent-pool detail","emitted":"pool","expect":"did-you-mean:ambiguous","via":"guard:pool-name-vs-code"}, + {"command":"hrbrain talent-pool employees","emitted":"talent-pool-code","expect":"pool-code","via":"concept:hrbrain_pool_code"}, + {"command":"hrbrain talent-pool employees","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain talent-pool employees","emitted":"page-no","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain talent-pool employees","emitted":"keyword","expect":"did-you-mean:blocked","via":"guard:list-vs-search"}, + {"command":"hrbrain talent-pool employees","emitted":"pool","expect":"did-you-mean:ambiguous","via":"guard:pool-name-vs-code"}, + {"command":"hrbrain profile metadata","emitted":"employee-no","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile metadata","emitted":"data-queries","expect":"did-you-mean:blocked","via":"guard:metadata-vs-query"}, + {"command":"hrbrain profile metadata","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:work-number-vs-id"}, + {"command":"hrbrain profile query","emitted":"employee-number","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile query","emitted":"profile-queries","expect":"data-queries","via":"override:json-array"}, + {"command":"hrbrain profile query","emitted":"module-queries","expect":"data-queries","via":"override:json-array"}, + {"command":"hrbrain profile query","emitted":"data-query","expect":"did-you-mean:blocked","via":"guard:singular-vs-json-array"}, + {"command":"hrbrain profile query","emitted":"query","expect":"did-you-mean:ambiguous","via":"guard:search-vs-profile-query"}, + {"command":"hrbrain profile labels","emitted":"work-nos","expect":"staff-ids","via":"concept:hrbrain_work_nos"}, + {"command":"hrbrain profile labels","emitted":"employee-nos","expect":"staff-ids","via":"concept:hrbrain_work_nos"}, + {"command":"hrbrain profile labels","emitted":"all-labels","expect":"all-label","via":"override:plural-boolean"}, + {"command":"hrbrain profile labels","emitted":"work-no","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"hrbrain profile labels","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:work-number-vs-user-id"}, + {"command":"hrbrain profile labels","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-id"}, + {"command":"hrbrain profile career","emitted":"staff-no","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile career","emitted":"start-date","expect":"did-you-mean:blocked","via":"guard:return-history-not-filter"}, + {"command":"hrbrain profile career","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:work-number-vs-id"}, + {"command":"hrbrain profile performance","emitted":"employee-no","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile performance","emitted":"rating","expect":"did-you-mean:blocked","via":"guard:return-record-not-filter"}, + {"command":"hrbrain profile performance","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:work-number-vs-id"}, + {"command":"hrbrain search employees","emitted":"q","expect":"keyword","via":"concept:search_query"}, + {"command":"hrbrain search employees","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain search employees","emitted":"current-page","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain search employees","emitted":"talent-pool-code","expect":"pool-code","via":"concept:hrbrain_pool_code"}, + {"command":"hrbrain search employees","emitted":"department-name","expect":"dept-name","via":"override:department-name"}, + {"command":"hrbrain search employees","emitted":"job-title","expect":"position-name","via":"override:position-name"}, + {"command":"hrbrain search employees","emitted":"position-level","expect":"job-level","via":"override:job-level"}, + {"command":"hrbrain search employees","emitted":"dept-id","expect":"did-you-mean:blocked","via":"guard:department-name-vs-id"}, + {"command":"hrbrain search employees","emitted":"origin-json","expect":"did-you-mean:blocked","via":"guard:simple-vs-structured"}, + {"command":"hrbrain search employees","emitted":"pool","expect":"did-you-mean:ambiguous","via":"guard:pool-name-vs-code"}, + {"command":"hrbrain search employees-structured","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain search employees-structured","emitted":"page-num","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain search employees-structured","emitted":"filter-json","expect":"origin-json","via":"override:json-object"}, + {"command":"hrbrain search employees-structured","emitted":"criteria-json","expect":"origin-json","via":"override:json-object"}, + {"command":"hrbrain search employees-structured","emitted":"return-fields-json","expect":"fields","via":"override:json-array"}, + {"command":"hrbrain search employees-structured","emitted":"sort-by","expect":"order-by","via":"override:order-list"}, + {"command":"hrbrain search employees-structured","emitted":"keyword","expect":"did-you-mean:blocked","via":"guard:simple-filter-belongs-in-origin-json"}, + {"command":"hrbrain search employees-structured","emitted":"operator","expect":"did-you-mean:blocked","via":"guard:nested-json-member"}, + {"command":"hrbrain search employees-structured","emitted":"field","expect":"did-you-mean:ambiguous","via":"guard:local-fields-vs-nested-field"}, + {"command":"hrbrain search fields","emitted":"query","expect":"did-you-mean:blocked","via":"guard:no-search-input"}, + {"command":"hrbrain search fields","emitted":"origin-json","expect":"did-you-mean:blocked","via":"guard:fields-vs-execution"}, + {"command":"hrbrain search fields","emitted":"operator","expect":"did-you-mean:blocked","via":"guard:return-field-not-input"}, + {"command":"hrbrain search fields","emitted":"field","expect":"did-you-mean:ambiguous","via":"guard:returned-field-role"}, + {"command":"pat chmod","emitted":"product-code","expect":"product","via":"override:scoped-product"}, + {"command":"pat chmod","emitted":"product-codes","expect":"products","via":"override:scoped-products"}, + {"command":"pat chmod","emitted":"domain-code","expect":"domain","via":"override:scoped-domain"}, + {"command":"pat chmod","emitted":"domain-codes","expect":"domains","via":"override:scoped-domains"}, + {"command":"pat chmod","emitted":"grant-mode","expect":"grant-type","via":"override:scoped-grant-mode"}, + {"command":"pat chmod","emitted":"authorization-mode","expect":"grant-type","via":"override:scoped-grant-mode"}, + {"command":"pat chmod","emitted":"pat-session-id","expect":"session-id","via":"override:scoped-session"}, + {"command":"pat chmod","emitted":"authorization-session-id","expect":"session-id","via":"override:scoped-session"}, + {"command":"pat chmod","emitted":"recommended","expect":"recommend","via":"override:scoped-recommend"}, + {"command":"pat chmod","emitted":"scope","expect":"did-you-mean:blocked","via":"guard:flag-vs-positional"}, + {"command":"pat chmod","emitted":"all-scopes","expect":"did-you-mean:blocked","via":"guard:no-unbounded-expansion"}, + {"command":"pat chmod","emitted":"force","expect":"did-you-mean:blocked","via":"guard:no-confirmation-alias"}, + {"command":"pat chmod","emitted":"app-id","expect":"did-you-mean:blocked","via":"guard:pat-vs-app-permission"}, + {"command":"pat chmod","emitted":"open-browser","expect":"did-you-mean:blocked","via":"guard:authorization-vs-browser-policy"}, + {"command":"pat chmod","emitted":"agent-id","expect":"did-you-mean:ambiguous","via":"guard:agent-id-vs-agent-code"}, + {"command":"pat chmod","emitted":"session","expect":"did-you-mean:ambiguous","via":"guard:grant-type-vs-session-id"}, + {"command":"pat browser-policy","emitted":"open-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat browser-policy","emitted":"browser-enabled","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat browser-policy","emitted":"allow-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat browser-policy","emitted":"disable-browser","expect":"did-you-mean:blocked","via":"guard:requires-boolean-inversion"}, + {"command":"pat browser-policy","emitted":"scope","expect":"did-you-mean:blocked","via":"guard:policy-vs-authorization"}, + {"command":"pat browser-policy","emitted":"products","expect":"did-you-mean:blocked","via":"guard:policy-vs-plan"}, + {"command":"pat browser-policy","emitted":"app-id","expect":"did-you-mean:blocked","via":"guard:pat-vs-app-permission"}, + {"command":"pat browser-policy","emitted":"agent-id","expect":"did-you-mean:ambiguous","via":"guard:agent-id-vs-agent-code"}, + {"command":"pat browser-policy","emitted":"policy","expect":"did-you-mean:ambiguous","via":"guard:missing-boolean-role"}, + {"command":"pat +browser-policy","emitted":"open-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat +browser-policy","emitted":"browser-enabled","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat +browser-policy","emitted":"allow-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat +browser-policy","emitted":"disable-browser","expect":"did-you-mean:blocked","via":"guard:requires-boolean-inversion"}, + {"command":"pat +browser-policy","emitted":"scope","expect":"did-you-mean:blocked","via":"guard:policy-vs-authorization"}, + {"command":"pat +browser-policy","emitted":"products","expect":"did-you-mean:blocked","via":"guard:policy-vs-plan"}, + {"command":"pat +browser-policy","emitted":"app-id","expect":"did-you-mean:blocked","via":"guard:pat-vs-app-permission"}, + {"command":"pat +browser-policy","emitted":"agent-id","expect":"did-you-mean:ambiguous","via":"guard:agent-id-vs-agent-code"}, + {"command":"pat +browser-policy","emitted":"policy","expect":"did-you-mean:ambiguous","via":"guard:missing-boolean-role"} ] } } diff --git a/internal/cli/schema_catalog_test.go b/internal/cli/schema_catalog_test.go index 16f7806f..3e80796c 100644 --- a/internal/cli/schema_catalog_test.go +++ b/internal/cli/schema_catalog_test.go @@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) { t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"]) } - for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} { + for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} { if parameters[flagName]["required"] != false { t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"]) } @@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) { if parameters["max-depth"]["type"] != "integer" { t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"]) } + if got := parameters["version"]["property"]; got != "historyVersion" { + t.Fatalf("doc read --version property = %#v, want historyVersion", got) + } + if parameters["version"]["type"] != "integer" { + t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"]) + } + if got := parameters["password"]["property"]; got != "password" { + t.Fatalf("doc read --password property = %#v, want password", got) + } } func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) { diff --git a/internal/cli/schema_command_exclusions.go b/internal/cli/schema_command_exclusions.go index 328c1f42..3b06c6d8 100644 --- a/internal/cli/schema_command_exclusions.go +++ b/internal/cli/schema_command_exclusions.go @@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{ "agoal scorecard detail", "agoal scorecard entity-detail", "agoal scorecard update", + "agoal scorecard search-entities", "agoal strategy detail", "agoal strategy list", "agoal strategy update", diff --git a/internal/cli/schema_constraints_runtime.go b/internal/cli/schema_constraints_runtime.go index 423528d6..f5dae2ca 100644 --- a/internal/cli/schema_constraints_runtime.go +++ b/internal/cli/schema_constraints_runtime.go @@ -96,7 +96,14 @@ func init() { registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style") registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size") registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria") - registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y") + RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"file", "src"}}, + RequireOneOf: [][]string{{"file", "src"}}, + }) + RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"file", "src"}}, + RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}}, + }) registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color") registerRequireOneOf("sheet.import", "folder-token", "workspace") registerRequireOneOf("wiki.search_wikiSpaces", "query", "type") diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index b9e0263a..1cc9c432 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "doc.insert_document_block --level": "aggregate convenience input used to build element", "doc.insert_document_block --content": "aggregate convenience input used to build element", "doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata", + "doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.", "doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.", "doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.", @@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised", "drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised", "drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command", + "drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.", "drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", @@ -552,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.", "sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties", + "sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image", "sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.", @@ -644,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties", + "sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image", "sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.", "sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", @@ -664,6 +668,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.", "wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata", "wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document", + "wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.", "wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces", "wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces", "wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches", diff --git a/internal/cobracmd/tree.go b/internal/cobracmd/tree.go index e395162e..60de616e 100644 --- a/internal/cobracmd/tree.go +++ b/internal/cobracmd/tree.go @@ -16,9 +16,10 @@ package cobracmd import ( + "fmt" "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command { cmd := &cobra.Command{ Use: use, Short: short, - Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, } - // Tag as a group container: its RunE only prints help, so cobra's - // Runnable() can't distinguish it from a real leaf — callers that need to - // collapse empty groups rely on this annotation. - cmdutil.MarkGroup(cmd) + corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) return cmd } @@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) { if dst == nil || src == nil { return } + mergeGroupPolicy(dst, src) if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) { dst.Short = src.Short } @@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) { } } +func mergeGroupPolicy(dst, src *cobra.Command) { + dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst) + if err != nil { + panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err)) + } + srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src) + if err != nil { + panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err)) + } + if len(dst.Commands()) > 0 && !dstOK { + panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath())) + } + if len(src.Commands()) > 0 && !srcOK { + panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath())) + } + if dstOK && !srcOK { + if !isNeutralMergePlaceholder(src) { + panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q", + src.CommandPath(), dst.CommandPath())) + } + return + } + if !dstOK && srcOK { + if !isNeutralMergePlaceholder(dst) { + panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q", + src.CommandPath(), dst.CommandPath())) + } + corecmd.ApplyGroupPolicy(dst, srcPolicy) + return + } + if dstOK && srcOK && dstPolicy != srcPolicy { + // A NavigationOnly/Reject/Sibling source is the framework's neutral + // service scaffold (shortcuts and plugin overlays use it before being + // folded into an owning product root). The destination owns the merged + // command's default action and recovery scope, so preserve its policy. + // Any stronger source declaration would lose behavior during this + // destination-oriented merge and therefore fails closed. + if srcPolicy != (corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) { + panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v", + dst.CommandPath(), dstPolicy, srcPolicy)) + } + return + } +} + +// isNeutralMergePlaceholder reports whether cmd contributes metadata only. +// Such a shell may adopt (or be folded into) one typed group declaration. +// Anything executable, parse-affecting, or child-bearing must declare its own +// compatible GroupPolicy so tree assembly cannot silently discard behavior. +func isNeutralMergePlaceholder(cmd *cobra.Command) bool { + if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil || + cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil || + cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil || + cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil || + cmd.TraverseChildren || cmd.DisableFlagParsing { + return false + } + hasFlags := false + cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true }) + cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true }) + return !hasFlags +} + +// ValidateGroupTree checks the final assembled Cobra tree rather than source +// syntax. Every command with children must carry one valid typed GroupPolicy; +// leaves must carry none. This catches dynamically assembled aliases, plugin +// parents, and constructors outside any one source directory. +func ValidateGroupTree(root *cobra.Command) error { + if root == nil { + return fmt.Errorf("cannot validate a nil command tree") + } + return validateGroupNode(root) +} + +func validateGroupNode(cmd *cobra.Command) error { + policy, declared, err := corecmd.GroupPolicyFor(cmd) + if err != nil { + return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err) + } + children := cmd.Commands() + if len(children) == 0 { + if declared { + return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy) + } + return nil + } + if !declared { + return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath()) + } + if !cmd.Runnable() { + return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode) + } + if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) { + return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath()) + } + if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil { + return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath()) + } + if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil { + return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath()) + } + if policy.Positionals == corecmd.PositionalsReject { + if cmd.Args == nil { + return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath()) + } + } + if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) { + return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath()) + } + for _, child := range children { + if err := validateGroupNode(child); err != nil { + return err + } + } + return nil +} + +func hasAvailableDescendant(cmd *cobra.Command) bool { + for _, child := range cmd.Commands() { + if !child.IsAvailableCommand() { + continue + } + return true + } + return false +} + // ShouldReplaceLeaf decides whether src should replace dst as a leaf command // based on override priority and local flag count. func ShouldReplaceLeaf(dst, src *cobra.Command) bool { diff --git a/internal/cobracmd/tree_test.go b/internal/cobracmd/tree_test.go index 3ca75901..07d07147 100644 --- a/internal/cobracmd/tree_test.go +++ b/internal/cobracmd/tree_test.go @@ -14,8 +14,10 @@ package cobracmd import ( + "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/spf13/cobra" ) @@ -143,20 +145,399 @@ func TestNewGroupCommand(t *testing.T) { t.Fatalf("Short = %q, want %q", cmd.Short, "my group description") } if cmd.Args == nil { - t.Fatal("Args should be set (cobra.NoArgs)") + t.Fatal("Args should be set (cobra.ArbitraryArgs)") } - // Verify Args rejects arguments. - if err := cmd.Args(cmd, []string{"extra"}); err == nil { - t.Fatal("expected Args to reject extra arguments") + // Args must reach the shared resolver instead of Cobra's generic arg error. + if err := cmd.Args(cmd, []string{"extra"}); err != nil { + t.Fatalf("Args intercepted command resolution: %v", err) } // Verify RunE is set and returns help (no error for valid invocation). if cmd.RunE == nil { t.Fatal("RunE should not be nil") } + policy, ok, err := corecmd.GroupPolicyFor(cmd) + if err != nil || !ok || policy != (corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) { + t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err) + } // RunE calls cmd.Help() which should not error. if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("RunE returned unexpected error: %v", err) } + if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatalf("RunE typo error = %v", err) + } +} + +func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) { + t.Run("valid final tree", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + nested := NewGroupCommand("nested", "nested") + nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.AddCommand(nested) + if err := ValidateGroupTree(root); err != nil { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("nil tree", func(t *testing.T) { + if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") { + t.Fatalf("ValidateGroupTree(nil) = %v", err) + } + }) + + t.Run("children require declaration", func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("leaf rejects stale declaration", func(t *testing.T) { + leaf := NewGroupCommand("stale", "stale") + if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) { + leaf := &cobra.Command{Use: "stale-deep"} + corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("declared group must stay runnable", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.RunE = nil + root.Run = nil + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.Args = nil + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("allowed positionals require explicit Args contract", func(t *testing.T) { + root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("validation does not execute positional contracts", func(t *testing.T) { + calls := 0 + root := &cobra.Command{ + Use: "dws", + Args: func(*cobra.Command, []string) error { + calls++ + return nil + }, + RunE: func(*cobra.Command, []string) error { return nil }, + } + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err != nil { + t.Fatalf("ValidateGroupTree() = %v", err) + } + if calls != 0 { + t.Fatalf("ValidateGroupTree executed Args %d times", calls) + } + }) + + t.Run("deep recovery requires available descendants", func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) +} + +func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) { + t.Run("malformed policy metadata", func(t *testing.T) { + root := &cobra.Command{ + Use: "dws", + Annotations: map[string]string{ + "dws.internal.corecmd.group_policy.v1": "malformed", + }, + } + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("navigation-only execution hook changed", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.RunE = nil + root.Run = func(*cobra.Command, []string) {} + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("hybrid business execution hook removed", func(t *testing.T) { + root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.RunE = nil + root.Run = func(*cobra.Command, []string) {} + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("nested validation error is propagated", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(NewGroupCommand("stale", "stale")) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("deep recovery accepts an available descendant", func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err != nil { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) +} + +func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) { + t.Run("copies source declaration", func(t *testing.T) { + dst := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") + MergeCommandTree(dst, src) + policy, ok, err := corecmd.GroupPolicyFor(dst) + if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling { + t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err) + } + }) + + t.Run("typed destination accepts metadata-only source", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root", Long: "source details"} + MergeCommandTree(dst, src) + if dst.Long != "source details" { + t.Fatalf("Long = %q", dst.Long) + } + }) + + t.Run("accepts identical declarations", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := NewGroupCommand("root", "source") + MergeCommandTree(dst, src) + }) + + t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) { + businessCalled := false + dst := &cobra.Command{ + Use: "root", + RunE: func(*cobra.Command, []string) error { + businessCalled = true + return nil + }, + } + want := corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + } + corecmd.ApplyGroupPolicy(dst, want) + dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }}) + + src := NewGroupCommand("root", "neutral scaffold") + src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }}) + MergeCommandTree(dst, src) + + got, ok, err := corecmd.GroupPolicyFor(dst) + if err != nil || !ok || got != want { + t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want) + } + if ChildByName(dst, "overlay") == nil { + t.Fatal("neutral scaffold child was not merged") + } + if err := dst.RunE(dst, nil); err != nil || !businessCalled { + t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err) + } + }) + + t.Run("rejects conflicting declarations", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root"} + corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled, + }) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) { + dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }} + corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + src := &cobra.Command{Use: "root"} + corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) { + dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }} + src := NewGroupCommand("root", "source") + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }}) + src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }} + src.Flags().String("source-only", "", "must not be silently discarded") + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root", Args: cobra.NoArgs} + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("rejects undeclared destination group", func(t *testing.T) { + dst := &cobra.Command{Use: "root"} + dst.AddCommand(&cobra.Command{Use: "child"}) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, &cobra.Command{Use: "root"}) + }) + + t.Run("rejects undeclared source group", func(t *testing.T) { + src := &cobra.Command{Use: "root"} + src.AddCommand(&cobra.Command{Use: "child"}) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(&cobra.Command{Use: "root"}, src) + }) +} + +func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) { + mustPanic := func(t *testing.T, want string, fn func()) { + t.Helper() + defer func() { + got := recover() + message, ok := got.(string) + if !ok || !strings.Contains(message, want) { + t.Fatalf("panic = %v, want substring %q", got, want) + } + }() + fn() + } + + malformed := func() *cobra.Command { + return &cobra.Command{ + Use: "root", + Annotations: map[string]string{ + "dws.internal.corecmd.group_policy.v1": "malformed", + }, + } + } + + t.Run("malformed destination policy", func(t *testing.T) { + mustPanic(t, "destination command", func() { + MergeCommandTree(malformed(), &cobra.Command{Use: "root"}) + }) + }) + + t.Run("malformed source policy", func(t *testing.T) { + mustPanic(t, "source command", func() { + MergeCommandTree(&cobra.Command{Use: "root"}, malformed()) + }) + }) + + t.Run("local flag prevents placeholder merge", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root"} + src.Flags().String("local", "", "local parse behavior") + mustPanic(t, "behavior-bearing leaf", func() { + MergeCommandTree(dst, src) + }) + }) + + t.Run("persistent flag prevents placeholder merge", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root"} + src.PersistentFlags().String("persistent", "", "inherited parse behavior") + mustPanic(t, "behavior-bearing leaf", func() { + MergeCommandTree(dst, src) + }) + }) } func TestNewHiddenGroupCommand(t *testing.T) { @@ -341,11 +722,11 @@ func TestMergeCommandTree(t *testing.T) { t.Run("child merge recursive", func(t *testing.T) { t.Parallel() - dst := &cobra.Command{Use: "root"} + dst := NewGroupCommand("root", "destination") dstChild := &cobra.Command{Use: "sub", Short: ""} dst.AddCommand(dstChild) - src := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") srcChild := &cobra.Command{Use: "sub", Short: "Merged short"} src.AddCommand(srcChild) @@ -361,12 +742,12 @@ func TestMergeCommandTree(t *testing.T) { t.Run("leaf replacement by higher priority", func(t *testing.T) { t.Parallel() - dst := &cobra.Command{Use: "root"} + dst := NewGroupCommand("root", "destination") dstLeaf := &cobra.Command{Use: "leaf", Short: "old"} SetOverridePriority(dstLeaf, 1) dst.AddCommand(dstLeaf) - src := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") srcLeaf := &cobra.Command{Use: "leaf", Short: "new"} SetOverridePriority(srcLeaf, 5) src.AddCommand(srcLeaf) @@ -383,10 +764,10 @@ func TestMergeCommandTree(t *testing.T) { t.Run("new child addition", func(t *testing.T) { t.Parallel() - dst := &cobra.Command{Use: "root"} + dst := NewGroupCommand("root", "destination") dst.AddCommand(&cobra.Command{Use: "existing"}) - src := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"}) MergeCommandTree(dst, src) diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index b94cc080..abf3260e 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -11,11 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package corecmd is the shared, dispatch-agnostic base for building leaf -// commands. It concentrates flag registration, the alias/env/default effective -// value fallback chain, required validation, cross-flag constraint declaration -// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs -// assembly, and Agent Runtime Schema projection. +// Package corecmd is the shared, dispatch-agnostic base for building commands. +// It concentrates typed group policy, flag registration, the alias/env/default +// effective value fallback chain, required validation, cross-flag constraint +// declaration checks + runtime enforcement, SafetySpec-driven confirmation, +// toolArgs assembly, and Agent Runtime Schema projection. // // Declaration vs execution (framework rule): // @@ -231,7 +231,9 @@ const ( // or assemble business params that belong in Flags/ConstParams. // // Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New -// validates this at construction time. corecmd stays dispatch-agnostic and +// validates this at construction time. Non-leaf commands are declared +// separately through ApplyGroupPolicy so leaf execution fields can never be +// configured and then silently ignored. corecmd stays dispatch-agnostic and // never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the // body. type Spec struct { diff --git a/internal/corecmd/group_policy.go b/internal/corecmd/group_policy.go new file mode 100644 index 00000000..e79cec07 --- /dev/null +++ b/internal/corecmd/group_policy.go @@ -0,0 +1,218 @@ +// 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 corecmd + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// GroupMode declares whether a command with children is navigation-only or +// also owns business execution. The zero value means the command is a leaf. +type GroupMode string + +const ( + // GroupNavigationOnly is a parent whose own invocation only renders help. + GroupNavigationOnly GroupMode = "navigation_only" + // GroupHybrid is a runnable business command that also owns children. + GroupHybrid GroupMode = "hybrid" +) + +// PositionalsPolicy declares whether a group may consume positional values. +type PositionalsPolicy string + +const ( + // PositionalsReject makes every unmatched positional token eligible for + // command-resolution recovery rather than business execution. + PositionalsReject PositionalsPolicy = "reject" + // PositionalsAllow reserves positional values for the group's business + // execution. Recovery must therefore be disabled to avoid ambiguity. + PositionalsAllow PositionalsPolicy = "allow" +) + +// RecoveryPolicy declares the search scope for unknown-command recovery. +type RecoveryPolicy string + +const ( + // RecoverySibling suggests only direct children of the current group. + RecoverySibling RecoveryPolicy = "sibling" + // RecoveryDeep may search all descendants of the current group. + RecoveryDeep RecoveryPolicy = "deep" + // RecoveryDisabled leaves positional handling entirely to Cobra or the + // command's business execution. + RecoveryDisabled RecoveryPolicy = "disabled" +) + +// GroupPolicy is the typed declaration for every non-leaf command. +// +// Its zero value deliberately means "leaf": callers must declare all three +// fields together for a group. ApplyGroupPolicy compiles the declaration to +// Cobra behavior and private framework metadata; command authors must not +// author parallel kind annotations themselves. +type GroupPolicy struct { + Mode GroupMode + Positionals PositionalsPolicy + Recovery RecoveryPolicy +} + +const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1" + +// IsZero reports whether p is the leaf declaration. +func (p GroupPolicy) IsZero() bool { + return p.Mode == "" && p.Positionals == "" && p.Recovery == "" +} + +// ValidateGroupPolicy rejects partial declarations, unknown enum values, and +// combinations whose parsing semantics would be ambiguous. +func ValidateGroupPolicy(p GroupPolicy) error { + if p.IsZero() { + return nil + } + switch p.Mode { + case GroupNavigationOnly, GroupHybrid: + default: + return fmt.Errorf("invalid group mode %q", p.Mode) + } + switch p.Positionals { + case PositionalsReject, PositionalsAllow: + default: + return fmt.Errorf("invalid group positionals policy %q", p.Positionals) + } + switch p.Recovery { + case RecoverySibling, RecoveryDeep, RecoveryDisabled: + default: + return fmt.Errorf("invalid group recovery policy %q", p.Recovery) + } + if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject { + return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject) + } + if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled { + return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled) + } + return nil +} + +// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior. +// Invalid declarations panic because they are framework construction bugs, +// matching the fail-closed behavior of Spec flag/constraint declarations. +// +// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid +// groups retain their existing RunE; when they reject positionals, a wrapper +// sends non-empty args to the same unknown-command resolver before invoking +// business execution. When recovery is enabled, rejecting positionals +// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the +// token with a generic error before command resolution can produce bounded +// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs. +func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) { + if cmd == nil { + panic("cannot apply GroupPolicy to a nil command") + } + if policy.IsZero() { + panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name())) + } + if err := ValidateGroupPolicy(policy); err != nil { + panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err)) + } + if existing, ok, err := GroupPolicyFor(cmd); err != nil { + panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err)) + } else if ok && existing != policy { + panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy)) + } else if ok { + return + } + + if policy.Mode == GroupNavigationOnly { + cmd.Run = nil + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runGroupPolicy(cmd, args, policy) + } + } else if cmd.RunE == nil { + panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name())) + } else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled { + businessRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return runGroupPolicy(cmd, args, policy) + } + return businessRunE(cmd, args) + } + } + if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled { + cmd.Args = cobra.ArbitraryArgs + } else if policy.Positionals == PositionalsReject { + cmd.Args = cobra.NoArgs + } + + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy) +} + +// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is +// false only for a leaf. Malformed private metadata is returned as an error so +// tree assembly can fail closed instead of silently treating a group as a leaf. +func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) { + if cmd == nil || cmd.Annotations == nil { + return GroupPolicy{}, false, nil + } + raw, ok := cmd.Annotations[groupPolicyAnnotation] + if !ok { + return GroupPolicy{}, false, nil + } + parts := strings.Split(raw, "|") + if len(parts) != 3 { + return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw) + } + policy := GroupPolicy{ + Mode: GroupMode(parts[0]), + Positionals: PositionalsPolicy(parts[1]), + Recovery: RecoveryPolicy(parts[2]), + } + if err := ValidateGroupPolicy(policy); err != nil { + return GroupPolicy{}, false, err + } + if policy.IsZero() { + return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero") + } + return policy, true, nil +} + +func encodeGroupPolicy(policy GroupPolicy) string { + return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery) +} + +func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error { + if len(args) == 0 { + return cmd.Help() + } + input := strings.TrimSpace(args[0]) + reason := cmdutil.ClassifyCommandResolution(cmd, input) + suggestions := cmdutil.SuggestSubcommands(cmd, input) + if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep { + if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 { + suggestions = deep + } + } + return cmdutil.NewCommandResolution( + cmd, + input, + reason, + suggestions, + "", + ).Err() +} diff --git a/internal/corecmd/group_policy_test.go b/internal/corecmd/group_policy_test.go new file mode 100644 index 00000000..f2a6e31b --- /dev/null +++ b/internal/corecmd/group_policy_test.go @@ -0,0 +1,323 @@ +// 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 corecmd + +import ( + "errors" + "runtime" + "slices" + "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 TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) { + valid := []GroupPolicy{ + {}, + {Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}, + {Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep}, + {Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled}, + {Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling}, + {Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep}, + {Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled}, + {Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, + } + for _, policy := range valid { + if err := ValidateGroupPolicy(policy); err != nil { + t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err) + } + } + + invalid := []struct { + name string + policy GroupPolicy + needle string + }{ + {name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"}, + {name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"}, + {name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"}, + {name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"}, + {name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"}, + {name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) { + t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle) + } + }) + } +} + +func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) { + policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling} + cmd := &cobra.Command{Use: "parent"} + ApplyGroupPolicy(cmd, policy) + + got, ok, err := GroupPolicyFor(cmd) + if err != nil || !ok || got != policy { + t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy) + } + if !cmd.Runnable() || cmd.TraverseChildren { + t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren) + } + if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil { + t.Fatal("PositionalsReject must let command resolution inspect unmatched args") + } + if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatalf("navigation recovery error = %v", err) + } + var help strings.Builder + cmd.SetOut(&help) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("navigation help error = %v", err) + } + if output := help.String(); !strings.Contains(output, "Usage:") { + t.Fatalf("navigation help output = %q", output) + } + // Re-applying the same declaration is idempotent. + ApplyGroupPolicy(cmd, policy) + + traversing := &cobra.Command{Use: "traversing", TraverseChildren: true} + ApplyGroupPolicy(traversing, policy) + if !traversing.TraverseChildren { + t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface") + } + + leaf := &cobra.Command{Use: "leaf"} + if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() { + t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err) + } + annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}} + if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() { + t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) { + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + ApplyGroupPolicy(root, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + parent := &cobra.Command{ + Use: "search", + RunE: func(*cobra.Command, []string) error { return nil }, + } + parent.Flags().String("dimension", "", "parent-only search dimension") + ApplyGroupPolicy(parent, GroupPolicy{ + Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + childCalled := false + child := &cobra.Command{ + Use: "enterprise", + RunE: func(*cobra.Command, []string) error { + childCalled = true + return nil + }, + } + parent.AddCommand(child) + root.AddCommand(parent) + root.SetArgs([]string{"search", "--dimension", "name", "enterprise"}) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") { + t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err) + } + if childCalled { + t.Fatal("parent local flag leaked into child command execution") + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) { + called := false + cmd := &cobra.Command{ + Use: "hybrid", + RunE: func(*cobra.Command, []string) error { + called = true + return nil + }, + } + ApplyGroupPolicy(cmd, GroupPolicy{ + Mode: GroupHybrid, + Positionals: PositionalsAllow, + Recovery: RecoveryDisabled, + }) + if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called { + t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) { + called := false + wrapperFrames := 0 + cmd := &cobra.Command{ + Use: "hybrid", + RunE: func(*cobra.Command, []string) error { + called = true + pcs := make([]uintptr, 32) + frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)]) + for { + frame, more := frames.Next() + if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") { + wrapperFrames++ + } + if !more { + break + } + } + return nil + }, + } + policy := GroupPolicy{ + Mode: GroupHybrid, + Positionals: PositionalsReject, + Recovery: RecoverySibling, + } + ApplyGroupPolicy(cmd, policy) + // Applying the identical declaration must be a no-op. In particular, it + // must not wrap the already wrapped Hybrid RunE a second time. + ApplyGroupPolicy(cmd, policy) + if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatalf("hybrid typo error = %v", err) + } + if called { + t.Fatal("unknown positional must not reach hybrid business RunE") + } + if err := cmd.RunE(cmd, nil); err != nil || !called { + t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err) + } + if wrapperFrames != 1 { + t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) { + root := &cobra.Command{Use: "dws"} + sheet := &cobra.Command{Use: "sheet"} + rangeGroup := &cobra.Command{Use: "range"} + rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + ApplyGroupPolicy(rangeGroup, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + sheet.AddCommand( + rangeGroup, + &cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}}, + ) + ApplyGroupPolicy(sheet, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep, + }) + root.AddCommand(sheet) + + err := sheet.RunE(sheet, []string{"read"}) + var structured *apperrors.Error + if !errors.As(err, &structured) { + t.Fatalf("deep recovery error = %T %v", err, err) + } + if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) || + structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` { + t.Fatalf("deep recovery = %#v", structured) + } + if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) { + t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"]) + } + + err = sheet.RunE(sheet, []string{"+list-sheet"}) + structured = nil + if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) { + t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err) + } + if !slices.Equal(structured.Actions, []string{ + "Run 'dws sheet --help' for the full list", + "Run 'dws shortcut list --service sheet --format json'", + }) { + t.Fatalf("direct shortcut actions = %#v", structured.Actions) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) { + cmd := &cobra.Command{Use: "parent"} + ApplyGroupPolicy(cmd, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled, + }) + if err := cmd.Args(cmd, []string{"extra"}); err == nil { + t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra") + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) { + mustPanic := func(name, needle string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), needle) { + t.Fatalf("panic = %v, want %q", got, needle) + } + }() + fn() + }) + } + mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) }) + mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) }) + mustPanic("invalid policy", "invalid GroupPolicy", func() { + ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{ + Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled, + }) + }) + mustPanic("hybrid must run", "must declare RunE", func() { + ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{ + Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + }) + mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() { + cmd := &cobra.Command{Use: "parent"} + ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}) + ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled}) + }) + mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() { + cmd := &cobra.Command{ + Use: "broken", + Annotations: map[string]string{ + groupPolicyAnnotation: "hybrid|reject|unexpected", + }, + } + ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}) + }) +} + +func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) { + for name, test := range map[string]struct { + encoded string + needle string + }{ + "malformed": {encoded: "hybrid|reject", needle: "malformed"}, + "invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"}, + "zero policy": {encoded: "||", needle: "must not be zero"}, + } { + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "broken", + Annotations: map[string]string{ + groupPolicyAnnotation: test.encoded, + }, + } + if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) { + t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle) + } + }) + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 84491c52..c95b3b31 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -151,6 +151,24 @@ func WithOperation(operation string) Option { } } +// IsConfirmationRequired reports whether err (or any wrapped cause) is a +// typed framework confirmation-gate failure carrying reason +// confirmation_required. Downstream classifiers must pass such errors through +// verbatim: the "re-run with --yes" semantics can only be carried by the +// machine-readable reason, while message-text classification actively +// misroutes them (a command path containing "permission" would be reported as +// an auth failure, and any other wording degrades to an unclassified error). +func IsConfirmationRequired(err error) bool { + if err == nil { + return false + } + var typed *Error + if stderrors.As(err, &typed) { + return strings.TrimSpace(typed.Reason) == "confirmation_required" + } + return false +} + // WithServerKey records the server identifier associated with the failure. func WithServerKey(serverKey string) Option { return func(err *Error) { diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 60918b1b..744592c3 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -15,6 +15,7 @@ package errors import ( stderrors "errors" + "fmt" "os" "path/filepath" "strings" @@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) { t.Fatalf("normal mode should not show RPC Code, got %q", got) } } + +func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) { + t.Parallel() + + if IsConfirmationRequired(nil) { + t.Fatal("nil error must not report confirmation_required") + } + if IsConfirmationRequired(NewValidation("missing required flag")) { + t.Fatal("plain validation error must not report confirmation_required") + } + plain := stderrors.New("需要用户确认") + if IsConfirmationRequired(plain) { + t.Fatal("message text alone must not report confirmation_required") + } + confirmation := NewValidation( + "blocked", + WithReason("confirmation_required"), + ) + if !IsConfirmationRequired(confirmation) { + t.Fatal("typed confirmation error must report confirmation_required") + } + // 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。 + wrapped := fmt.Errorf("call tool: %w", confirmation) + if !IsConfirmationRequired(wrapped) { + t.Fatal("wrapped confirmation error must report confirmation_required") + } + otherReason := NewValidation("rate limited", WithReason("rate_limit")) + if IsConfirmationRequired(otherReason) { + t.Fatal("other reasons must not report confirmation_required") + } +} diff --git a/internal/helpers/agoal.go b/internal/helpers/agoal.go index ec894341..d01e2630 100644 --- a/internal/helpers/agoal.go +++ b/internal/helpers/agoal.go @@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation // ────────────────────────────────────────────────────────── func newAgoalCommand() *cobra.Command { - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "agoal", Short: "Agoal 管理", Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。 @@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command { dws agoal scorecard detail 获取计分卡详情 dws agoal scorecard entity-detail 获取计分卡实体详情 dws agoal scorecard update 更新计分卡 + dws agoal scorecard search-entities 搜索计分卡指标与关键事项 dws agoal user rules 获取用户规则 dws agoal user objectives 查询用户目标列表 dws agoal report list-statistics 获取周月报数据跟催列表 @@ -38,11 +39,11 @@ func newAgoalCommand() *cobra.Command { dws agoal obj-template list 获取目标模板列表 dws agoal obj-template create-or-update 新增或更新目标模板`, RunE: groupRunE, - } + }) // ── strategy: 战略解码管理 ────────────────────────────────── - strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE} + strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}) strategyListCmd := &cobra.Command{ Use: "list", @@ -138,7 +139,7 @@ scopeType 支持: // ── contract: 经营合约管理 ────────────────────────────────── - contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE} + contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}) contractListCmd := &cobra.Command{ Use: "list", @@ -257,7 +258,7 @@ scopeType 支持: // ── scorecard: 计分卡管理 ─────────────────────────────────── - scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE} + scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}) scorecardDetailCmd := &cobra.Command{ Use: "detail", @@ -359,11 +360,41 @@ scopeType 支持: scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)") scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)") - scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd) + scorecardSearchContentCmd := &cobra.Command{ + Use: "search-entities", + Short: "搜索计分卡指标与关键事项", + Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`, + Example: ` dws agoal scorecard search-entities --keyword "业绩" + dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "keyword"); err != nil { + return err + } + toolArgs := map[string]any{ + "keyword": mustGetFlag(cmd, "keyword"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + if v, _ := cmd.Flags().GetInt("page"); v != 0 { + toolArgs["page"] = v + } + if v, _ := cmd.Flags().GetInt("page-size"); v != 0 { + toolArgs["pageSize"] = v + } + return callMCPTool("search_score_card_entities", toolArgs) + }, + } + scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)") + scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)") + scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)") + scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)") + + scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd) // ── user: 用户目标管理 ────────────────────────────────────── - userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE} + userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}) userRulesCmd := &cobra.Command{ Use: "rules", @@ -414,7 +445,7 @@ scopeType 支持: // ── report: 周月报管理 ────────────────────────────────────── - reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE} + reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}) reportListStatisticsCmd := &cobra.Command{ Use: "list-statistics", @@ -489,7 +520,7 @@ scopeType 支持: // ── template: 目标模板管理 ────────────────────────────────── - objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE} + objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}) objTemplateListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/aisearch.go b/internal/helpers/aisearch.go index 92cbe0a6..c414ec76 100644 --- a/internal/helpers/aisearch.go +++ b/internal/helpers/aisearch.go @@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command { return groupRunE(cmd, args) }, } + newHybridGroupCommand(root) // root 和 person 各自定义同一组本地 flag,这样: // - dws aisearch --query xxx ← root 自己能解析 diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 2f72dcd0..add275ce 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "aitable", Short: "AI 表格操作", Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。 @@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command { dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`, RunE: groupRunE, SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos - } + }) // ── base: Base 管理 ───────────────────────────────────────── - baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE} + baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}) baseGetPrimaryDocIdCmd := &cobra.Command{ Use: "get-primary-doc-id", @@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错 // ── table: 数据表管理 ─────────────────────────────────────── - tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE} + tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}) tableGetCmd := &cobra.Command{ Use: "get", @@ -2223,7 +2223,7 @@ config 结构参考: // ── field: 字段管理 ───────────────────────────────────────── - fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE} + fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}) fieldGetCmd := &cobra.Command{ Use: "get", @@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。 // ── record: 记录管理 ──────────────────────────────────────── - recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE} + recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}) recordQueryCmd := &cobra.Command{ Use: "query", @@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, // ── template: 模板搜索 ────────────────────────────────────── - templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}) templateSearchCmd := &cobra.Command{ Use: "search", @@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, // ── attachment: 附件管理 ────────────────────────────────────── - attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE} + attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}) attachmentUploadCmd := &cobra.Command{ Use: "upload", @@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, // ── view: 视图管理 ─────────────────────────────────────────── - viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE} + viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}) viewGetCmd := &cobra.Command{ Use: "get", @@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, return callAitableTool("get_views", toolArgs) }, } + newHybridGroupCommand(viewGetCmd) // ─── view get 子命令:按属性投影 view 响应 ────────────── // card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。 @@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。 return callAitableTool("update_view", toolArgs) }, } + newHybridGroupCommand(viewUpdateCmd) // ─── view update 子命令:按属性局部更新 ──────────────────── @@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, // ── form: 表单管理 ────────────────────────────────────────── - formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE} - formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE} - formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE} + formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}) + formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}) + formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}) formListCmd := &cobra.Command{ Use: "list", @@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, // ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ── - formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE} + formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}) formQuestionsCreateCmd := &cobra.Command{ Use: "create", @@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, // ── workflow: 自动化工作流管理 ──────────────────────────────── - workflowCmd := &cobra.Command{ + workflowCmd := newGroupCommand(&cobra.Command{ Use: "workflow", Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)", RunE: groupRunE, - } + }) workflowCreateCmd := &cobra.Command{ Use: "create", @@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正 // ── dashboard: 仪表盘管理 ──────────────────────────────────── - dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE} + dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}) dashboardConfigExampleCmd := &cobra.Command{ Use: "config-example", @@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── dashboard share: 仪表盘分享管理 ──────────────────────────── - dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE} + dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}) dashboardShareGetCmd := &cobra.Command{ Use: "get", @@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── chart: 图表管理 ────────────────────────────────────────── - chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE} + chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}) chartWidgetsExampleCmd := &cobra.Command{ Use: "widgets-example", @@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── chart share: 图表分享管理 ──────────────────────────────── - chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE} + chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}) chartShareGetCmd := &cobra.Command{ Use: "get", @@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── export / import: 数据导入导出 ──────────────────────────── - exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE} + exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}) exportDataCmd := &cobra.Command{ Use: "data", @@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with }, }) - importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE} + importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}) // ── advperm: 高级权限 / 自定义角色 ──────────────────────────── - advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE} + advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}) advpermEnableCmd := &cobra.Command{ Use: "enable", @@ -7392,7 +7394,7 @@ role-get 自行 merge)。 // ── section: 文件夹与节点管理(导航树组织) ────────────────────────────── - sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE} + sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}) sectionCreateCmd := &cobra.Command{ Use: "create", @@ -8422,6 +8424,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 sectionMoveNodeCmd, ) + // ── datasource: 数据源同步管理 ────────────────────────────── + + datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}) + + datasourceGetConfigCmd := &cobra.Command{ + Use: "get-config", + Short: "获取数据源表同步配置", + Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "table-id"); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + return callAitableTool("get_datasource_config", map[string]any{ + "baseId": baseID, + "tableId": mustGetFlag(cmd, "table-id"), + }) + }, + } + DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{ + Safety: aitableSafetyRead(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_get_config", + CanonicalPath: "aitable.datasource_get_config", + CLIPath: "aitable datasource get-config", + PrimaryCLIPath: "aitable datasource get-config", + }, + Description: "获取数据源表的同步配置信息。", + Interface: aitableMCPInterface("get_datasource_config"), + Selection: contract.SelectionSpec{ + AgentSummary: "获取数据源表的同步配置信息。", + UseWhen: []string{"查看已有数据源表的配置详情时"}, + AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"}, + Examples: []string{"dws aitable datasource get-config --base-id --table-id "}, + }, + }, + }) + datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)") + + datasourceListSourcesCmd := &cobra.Command{ + Use: "list-sources", + Short: "列出可用数据源来源", + Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "datasource-type"); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + return callAitableTool("list_datasource_sources", map[string]any{ + "baseId": baseID, + "datasourceType": mustGetFlag(cmd, "datasource-type"), + }) + }, + } + DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{ + Safety: aitableSafetyRead(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_list_sources", + CanonicalPath: "aitable.datasource_list_sources", + CLIPath: "aitable datasource list-sources", + PrimaryCLIPath: "aitable datasource list-sources", + }, + Description: "列出指定 Base 下可用的数据源条目。", + Interface: aitableMCPInterface("list_datasource_sources"), + Selection: contract.SelectionSpec{ + AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。", + UseWhen: []string{"创建或更新数据源前需要查看可用来源时"}, + AvoidWhen: []string{"获取字段结构用 datasource get-fields"}, + Examples: []string{"dws aitable datasource list-sources --base-id --datasource-type OA"}, + }, + }, + }) + datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)") + + validateJSONObject := func(flag, raw string) error { + var v any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err) + } + if _, ok := v.(map[string]any); !ok { + return fmt.Errorf("--%s must be a JSON object, got %T", flag, v) + } + return nil + } + validateAutoSyncSetting := func(raw string) error { + return validateJSONObject("auto-sync-setting", raw) + } + + datasourceGetFieldsCmd := &cobra.Command{ + Use: "get-fields", + Short: "获取数据源可同步字段列表", + Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil { + return err + } + if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + return callAitableTool("get_datasource_fields", map[string]any{ + "baseId": baseID, + "datasourceType": mustGetFlag(cmd, "datasource-type"), + "sourceConfig": mustGetFlag(cmd, "source-config"), + }) + }, + } + DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{ + Safety: aitableSafetyRead(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_get_fields", + CanonicalPath: "aitable.datasource_get_fields", + CLIPath: "aitable datasource get-fields", + PrimaryCLIPath: "aitable datasource get-fields", + }, + Description: "获取指定数据源来源的可同步字段列表。", + Interface: aitableMCPInterface("get_datasource_fields"), + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。", + UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"}, + AvoidWhen: []string{"列出可用来源用 datasource list-sources"}, + Examples: []string{`dws aitable datasource get-fields --base-id --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`}, + }, + }, + }) + datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)") + datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)") + + datasourceCreateCmd := &cobra.Command{ + Use: "create", + Short: "创建数据源表并触发首次同步", + Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil { + return err + } + if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + auto, _ := cmd.Flags().GetBool("auto") + toolArgs := map[string]any{ + "baseId": baseID, + "datasourceType": mustGetFlag(cmd, "datasource-type"), + "sourceConfig": mustGetFlag(cmd, "source-config"), + "auto": auto, + } + if v, _ := cmd.Flags().GetString("field-ids"); v != "" { + toolArgs["fieldIds"] = parseCSVValues(v) + } + if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" { + if err := validateAutoSyncSetting(v); err != nil { + return err + } + toolArgs["autoSyncSetting"] = v + } + return callAitableTool("create_datasource", toolArgs) + }, + } + DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{ + Safety: aitableSafetyWrite(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_create", + CanonicalPath: "aitable.datasource_create", + CLIPath: "aitable datasource create", + PrimaryCLIPath: "aitable datasource create", + }, + Description: "为指定 Base 创建数据源表并触发首次全量同步。", + Interface: aitableMCPInterface("create_datasource"), + Selection: contract.SelectionSpec{ + AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。", + UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"}, + AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"}, + Examples: []string{`dws aitable datasource create --base-id --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "base-id", Property: "baseId", Required: boolPtr(true)}, + {Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)}, + {Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)}, + {Name: "auto", Property: "auto"}, + {Name: "field-ids", Property: "fieldIds", InterfaceType: "array"}, + {Name: "auto-sync-setting", Property: "autoSyncSetting"}, + }, + }, + }) + datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)") + datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)") + datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游") + datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段") + datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay") + + datasourceUpdateCmd := &cobra.Command{ + Use: "update", + Short: "更新数据源表同步配置并触发同步", + Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "table-id"); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + toolArgs := map[string]any{ + "baseId": baseID, + "tableId": mustGetFlag(cmd, "table-id"), + } + if cmd.Flags().Changed("source-config") { + if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil { + return err + } + toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config") + } + if cmd.Flags().Changed("auto") { + auto, _ := cmd.Flags().GetBool("auto") + toolArgs["auto"] = auto + } + if cmd.Flags().Changed("field-ids") { + v := mustGetFlag(cmd, "field-ids") + if v == "" { + return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入") + } + toolArgs["fieldIds"] = parseCSVValues(v) + } + if cmd.Flags().Changed("auto-sync-setting") { + v := mustGetFlag(cmd, "auto-sync-setting") + if v == "" { + return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入") + } + if err := validateAutoSyncSetting(v); err != nil { + return err + } + toolArgs["autoSyncSetting"] = v + } + if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") { + return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync") + } + return callAitableTool("update_datasource_config", toolArgs) + }, + } + DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{ + Safety: aitableSafetyWrite(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_update", + CanonicalPath: "aitable.datasource_update", + CLIPath: "aitable datasource update", + PrimaryCLIPath: "aitable datasource update", + }, + Description: "更新已有数据源表的同步配置并触发一次同步。", + Interface: aitableMCPInterface("update_datasource_config"), + Selection: contract.SelectionSpec{ + AgentSummary: "更新已有数据源表的同步配置并触发一次同步。", + UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"}, + AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"}, + Examples: []string{ + "dws aitable datasource update --base-id --table-id --auto", + `dws aitable datasource update --base-id --table-id --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "base-id", Property: "baseId", Required: boolPtr(true)}, + {Name: "table-id", Property: "tableId", Required: boolPtr(true)}, + {Name: "source-config", Property: "sourceConfig"}, + {Name: "auto", Property: "auto"}, + {Name: "field-ids", Property: "fieldIds", InterfaceType: "array"}, + {Name: "auto-sync-setting", Property: "autoSyncSetting"}, + }, + }, + }) + datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)") + datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段") + datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置") + datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)") + datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay") + + datasourceSyncCmd := &cobra.Command{ + Use: "sync", + Short: "触发数据源表手动同步", + Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "table-ids"); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids")) + if len(tableIDs) < 1 || len(tableIDs) > 5 { + return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs)) + } + return callAitableTool("run_datasource_sync", map[string]any{ + "baseId": baseID, + "tableIds": tableIDs, + }) + }, + } + DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{ + Safety: aitableSafetyWrite(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_sync", + CanonicalPath: "aitable.datasource_sync", + CLIPath: "aitable datasource sync", + PrimaryCLIPath: "aitable datasource sync", + }, + Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。", + Interface: aitableMCPInterface("run_datasource_sync"), + Selection: contract.SelectionSpec{ + AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。", + UseWhen: []string{"需要手动触发已有数据源表的数据同步时"}, + AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"}, + Examples: []string{"dws aitable datasource sync --base-id --table-ids TBL1,TBL2"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "base-id", Property: "baseId", Required: boolPtr(true)}, + {Name: "table-ids", Property: "tableIds", Required: boolPtr(true)}, + }, + }, + }) + datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)") + + datasourceSyncStatusCmd := &cobra.Command{ + Use: "sync-status", + Short: "按任务 ID 查询数据源同步任务状态", + Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil { + return err + } + baseID, err := mustFlagOrFallback(cmd, "base-id", "base") + if err != nil { + return err + } + ids := parseCSVValues(mustGetFlag(cmd, "task-ids")) + if len(ids) < 1 || len(ids) > 5 { + return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids)) + } + toolArgs := map[string]any{ + "baseId": baseID, + "tableId": mustGetFlag(cmd, "table-id"), + "taskIds": ids, + } + return callAitableTool("get_datasource_sync_status", toolArgs) + }, + } + DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{ + Safety: aitableSafetyRead(), + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "datasource_sync_status", + CanonicalPath: "aitable.datasource_sync_status", + CLIPath: "aitable datasource sync-status", + PrimaryCLIPath: "aitable datasource sync-status", + }, + Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。", + Interface: aitableMCPInterface("get_datasource_sync_status"), + Selection: contract.SelectionSpec{ + AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。", + UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"}, + AvoidWhen: []string{"触发同步用 datasource sync"}, + Examples: []string{"dws aitable datasource sync-status --base-id --table-id --task-ids TASK1"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "base-id", Property: "baseId", Required: boolPtr(true)}, + {Name: "table-id", Property: "tableId", Required: boolPtr(true)}, + {Name: "task-ids", Property: "taskIds", Required: boolPtr(true)}, + }, + }, + }) + datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)") + datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)") + datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)") + + datasourceCmd.AddCommand( + datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd, + datasourceCreateCmd, datasourceUpdateCmd, + datasourceSyncCmd, datasourceSyncStatusCmd, + ) + // 组装 aitable 命令树 root.AddCommand( baseCmd, tableCmd, fieldCmd, @@ -8432,6 +8844,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 attachmentCmd, templateCmd, advpermCmd, sectionCmd, + datasourceCmd, ) // 批量注册 --base 作为 --base-id 的隐藏别名 diff --git a/internal/helpers/aitable_datasource_test.go b/internal/helpers/aitable_datasource_test.go new file mode 100644 index 00000000..9bce8c57 --- /dev/null +++ b/internal/helpers/aitable_datasource_test.go @@ -0,0 +1,462 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +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" +) + +type aitableDatasourceCaller struct { + calls []aitableTestCall +} + +func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args}) + return &edition.ToolResult{Content: []edition.ContentBlock{{ + Type: "text", + Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`, + }}}, nil +} + +func (*aitableDatasourceCaller) Format() string { return "json" } +func (*aitableDatasourceCaller) DryRun() bool { return false } +func (*aitableDatasourceCaller) Fields() string { return "" } +func (*aitableDatasourceCaller) JQ() string { return "" } + +func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) { + t.Helper() + testseam.Protect(t, &os.Args) + + caller := &aitableDatasourceCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + os.Args = append([]string{"dws", "aitable", "datasource"}, args...) + + root := newAitableCommand() + root.SetArgs(append([]string{"datasource"}, args...)) + return caller, root.Execute() +} + +func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123") + if err == nil || !strings.Contains(err.Error(), "table-ids") { + t.Fatalf("error = %v, want table-ids required", err) + } +} + +func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123", + "--table-ids", "T1,T2,T3,T4,T5,T6") + if err == nil || !strings.Contains(err.Error(), "1-5") { + t.Fatalf("error = %v, want 1-5 limit", err) + } +} + +func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123", + "--table-ids", "") + if err == nil || !strings.Contains(err.Error(), "table-ids") { + t.Fatalf("error = %v, want table-ids error", err) + } +} + +func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123", + "--table-ids", "T1,T2,T3,T4,T5") + if err != nil { + t.Fatalf("5 table-ids should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } +} + +func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123", + "--table-ids", "T1") + if err != nil { + t.Fatalf("single table-id should succeed: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(caller.calls)) + } +} + +func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "sync-status", + "--base-id", "BASE123", "--table-id", "TBL456", + "--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6") + if err == nil || !strings.Contains(err.Error(), "requires 1-5") { + t.Fatalf("error = %v, want 1-5 limit", err) + } +} + +func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "sync-status", + "--base-id", "BASE123", "--table-id", "TBL456", + "--task-ids", "TK1,TK2,TK3,TK4,TK5") + if err != nil { + t.Fatalf("5 task-ids should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } +} + +func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "sync-status", + "--base-id", "BASE123", "--table-id", "TBL456") + if err == nil || !strings.Contains(err.Error(), "task-ids") { + t.Fatalf("error = %v, want task-ids required", err) + } +} + +func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123") + if err == nil || !strings.Contains(err.Error(), "table-id") { + t.Fatalf("error = %v, want table-id required", err) + } +} + +func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123") + if err == nil || !strings.Contains(err.Error(), "table-id") { + t.Fatalf("error = %v, want table-id required", err) + } +} + +func TestAitableDatasourceGetConfigSuccess(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "get-config", + "--base-id", "BASE123", "--table-id", "TBL456") + if err != nil { + t.Fatalf("get-config should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } + if caller.calls[0].args["tableId"] != "TBL456" { + t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"]) + } +} + +func TestAitableDatasourceListSourcesSuccess(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "list-sources", + "--base-id", "BASE123", "--datasource-type", "OA") + if err != nil { + t.Fatalf("list-sources should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } +} + +func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123") + if err == nil || !strings.Contains(err.Error(), "datasource-type") { + t.Fatalf("error = %v, want datasource-type required", err) + } +} + +func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "get-fields", + "--base-id", "BASE123", "--datasource-type", "OA") + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config required", err) + } +} + +func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "get-fields", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `not-json`) + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config validation error", err) + } +} + +func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) { + cases := []string{`[]`, `"text"`, `1`, `true`, `null`} + for _, raw := range cases { + _, err := runAitableDatasourceCommand(t, "get-fields", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", raw) + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err) + } + } +} + +func TestAitableDatasourceGetFieldsSuccess(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "get-fields", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`) + if err != nil { + t.Fatalf("get-fields should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } + if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` { + t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"]) + } +} + +func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA") + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config required", err) + } +} + +func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `not-json`) + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config validation error", err) + } +} + +func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) { + cases := []string{`[]`, `"text"`, `1`, `true`, `null`} + for _, raw := range cases { + _, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", raw) + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err) + } + } +} + +func TestAitableDatasourceCreateSuccess(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`) + if err != nil { + t.Fatalf("create should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } + if v, ok := caller.calls[0].args["auto"]; !ok || v != false { + t.Fatalf("auto = %v, want false when not provided", v) + } +} + +func TestAitableDatasourceCreateWithAuto(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`, + "--auto") + if err != nil { + t.Fatalf("create with --auto should succeed: %v", err) + } + if v, ok := caller.calls[0].args["auto"]; !ok || v != true { + t.Fatalf("auto = %v, want true", v) + } +} + +func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`, + "--auto", + "--field-ids", "fldAAA,fldBBB", + "--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`) + if err != nil { + t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } + fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string) + if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" { + t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"]) + } + if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` { + t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"]) + } +} + +func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "create", + "--base-id", "BASE123", "--datasource-type", "OA", + "--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`, + "--auto-sync-setting", `not-json`) + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting validation error", err) + } +} + +func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123") + if err == nil || !strings.Contains(err.Error(), "table-id") { + t.Fatalf("error = %v, want table-id required", err) + } +} + +func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--source-config", `not-json`) + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config validation error", err) + } +} + +func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) { + cases := []string{`[]`, `"text"`, `1`, `true`, `null`} + for _, raw := range cases { + _, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--source-config", raw) + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err) + } + } +} + +func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456") + if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") { + t.Fatalf("error = %v, want at least one config change required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP should not be called when no changes provided") + } +} + +func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", "--auto") + if err != nil { + t.Fatalf("update with --auto only should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } + if v, ok := caller.calls[0].args["auto"]; !ok || v != true { + t.Fatalf("auto = %v, want true", v) + } +} + +func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`) + if err != nil { + t.Fatalf("update with source-config should succeed: %v", err) + } + if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` { + t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"]) + } + if _, ok := caller.calls[0].args["auto"]; ok { + t.Fatalf("auto should not be sent when --auto is omitted") + } +} + +func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", "--auto=false") + if err != nil { + t.Fatalf("update with --auto=false should succeed: %v", err) + } + if v, ok := caller.calls[0].args["auto"]; !ok || v != false { + t.Fatalf("auto = %v, want false", v) + } +} + +func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--field-ids", "fldAAA,fldBBB", + "--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`) + if err != nil { + t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" { + t.Fatalf("unexpected calls: %#v", caller.calls) + } + fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string) + if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" { + t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"]) + } + if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` { + t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"]) + } +} + +func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--auto-sync-setting", `not-json`) + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting validation error", err) + } +} + +func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) { + cases := []string{`[]`, `"text"`, `1`, `true`, `null`} + for _, raw := range cases { + _, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--auto-sync-setting", raw) + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err) + } + } +} + +func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--field-ids", "") + if err == nil || !strings.Contains(err.Error(), "field-ids") { + t.Fatalf("error = %v, want field-ids empty error", err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP should not be called when empty field-ids is rejected") + } +} + +func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) { + caller, err := runAitableDatasourceCommand(t, "update", + "--base-id", "BASE123", "--table-id", "TBL456", + "--auto-sync-setting", "") + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting empty error", err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected") + } +} + +func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "get-fields", + "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`) + if err == nil || !strings.Contains(err.Error(), "base-id") { + t.Fatalf("error = %v, want base-id required", err) + } +} + +func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) { + _, err := runAitableDatasourceCommand(t, "create", + "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`) + if err == nil || !strings.Contains(err.Error(), "base-id") { + t.Fatalf("error = %v, want base-id required", err) + } +} diff --git a/internal/helpers/attendance.go b/internal/helpers/attendance.go index b8fbcb41..cab5652d 100644 --- a/internal/helpers/attendance.go +++ b/internal/helpers/attendance.go @@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "attendance", Short: "考勤打卡 / 排班 / 统计", Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。 @@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command { globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知) vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`, RunE: groupRunE, - } + }) // ── record ─────────────────────────────────────────────── - attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE} + attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}) attendanceRecordGetCmd := &cobra.Command{ Use: "get", @@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command { // ── check ──────────────────────────────────────────────── - attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE} + attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}) // MCP tool: query_check_result attendanceCheckResultCmd := &cobra.Command{ @@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command { // ── approve ──────────────────────────────────────────────── - attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE} + attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}) // 审批类型关键词到 bizType 数字映射 // 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类), @@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command { // ── shift ──────────────────────────────────────────────── - attendanceShiftCmd := &cobra.Command{ + attendanceShiftCmd := newGroupCommand(&cobra.Command{ Use: "shift", Short: "班次查询", Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。 返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`, RunE: groupRunE, - } + }) // MCP tool: batch_get_employee_shifts attendanceShiftListCmd := &cobra.Command{ @@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command { // ── class ──────────────────────────────────────────────── - attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE} + attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}) // MCP tool: get_class_list attendanceClassSearchCmd := &cobra.Command{ @@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换 // ── adjustment-rule ──────────────────────────────────── - attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE} + attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}) // MCP tool: get_adjustment_rule_detail attendanceAdjustmentGetCmd := &cobra.Command{ @@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换 // ── overtime-rule ────────────────────────────────────── - attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE} + attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}) // MCP tool: get_overtime_rule_detail attendanceOvertimeGetCmd := &cobra.Command{ @@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换 // ── group ────────────────────────────────────────────── - attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE} + attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}) // MCP tool: get_simple_groups attendanceGroupSearchCmd := &cobra.Command{ @@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, }) // ── selfsetting ───────────────────────────────────────────── - attendanceSelfSettingCmd := &cobra.Command{ + attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{ Use: "selfsetting", Short: "个人规则设置", Long: `个人规则设置相关命令。 @@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。 save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`, RunE: groupRunE, - } + }) // MCP tool: query_self_setting attendanceSelfSettingGetCmd := &cobra.Command{ @@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, }) // ── globalsetting ──────────────────────────────────────── - attendanceGlobalSettingCmd := &cobra.Command{ + attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{ Use: "globalsetting", Short: "全局规则设置(仅管理员)", Long: `全局规则设置相关命令,仅管理员可以调用。 @@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。 save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`, RunE: groupRunE, - } + }) // MCP tool: query_global_setting attendanceGlobalSettingGetCmd := &cobra.Command{ @@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── report ────────────────────────────────────────────── - attendanceReportCmd := &cobra.Command{ + attendanceReportCmd := newGroupCommand(&cobra.Command{ Use: "report", Short: "查询考勤报表和结果", Long: `考勤 MCP 报表接口,仅对管理员开放 @@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, query-data 根据字段查询考勤数据 query-leave 查询用户假期数据`, RunE: groupRunE, - } + }) // MCP tool: get_report_columns reportColumnsCmd := &cobra.Command{ @@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── 假期 vacation ─────────────────────────────────────────────── - attendanceVacationCmd := &cobra.Command{ + attendanceVacationCmd := newGroupCommand(&cobra.Command{ Use: "vacation", Short: "假期管理", Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。 @@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, save-balance 更新员工假期余额 records 查询指定员工假期余额变更记录`, RunE: groupRunE, - } + }) // ── 假期规则 types ───────────────────────────────────────── @@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── schedule ────────────────────────────────────────────── - attendanceScheduleCmd := &cobra.Command{ + attendanceScheduleCmd := newGroupCommand(&cobra.Command{ Use: "schedule", Short: "排班管理", Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。 @@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, import 导入排班记录到排班制考勤组 get 获取指定用户的排班记录`, RunE: groupRunE, - } + }) // schedule import (generateTurnSchedule) scheduleImportCmd := &cobra.Command{ @@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── checkin ────────────────────────────────────────────── - checkinCmd := &cobra.Command{ + checkinCmd := newGroupCommand(&cobra.Command{ Use: "checkin", Short: "签到管理", Long: `签到记录的查询。 子命令: records 查询指定员工的签到记录`, RunE: groupRunE, - } + }) // MCP tool: queryUserRecordByStaffIds checkinRecordsCmd := &cobra.Command{ diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index 5c71ea67..faf250fc 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -2,14 +2,11 @@ package helpers import ( "encoding/json" - "fmt" - "os" "sort" "strconv" "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -18,10 +15,9 @@ import ( // dws calendar — 日历产品命令组 // ────────────────────────────────────────────────────────── -// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that prints -// a warning-level "Did you mean" hint to stderr (instead of returning an Error) -// and exits 0. Scoped to calendar.go on purpose so the shared cmdutil.HintSubCmd -// used by other products keeps returning errors as before. +// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that returns +// the shared typed validation error while preserving Calendar's reviewed +// replacement guidance. // // The `suggestion` argument should be the bare corrected command (no leading // "use:" / "hint:" prefix); the helper wraps it with the standard "Did you @@ -29,186 +25,9 @@ import ( func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command { c := hintSubCmd(use, suggestion) c.DisableFlagParsing = true - c.RunE = func(cmd *cobra.Command, args []string) error { - fmt.Fprintf(os.Stderr, "warning: command %q does not exist\n hint: %s\t %s\n more: %s \n", - cmd.Parent().CommandPath()+" "+use, - suggestion, - "[MUST] use --help to see command detail", - "'dws calendar --help' to see all available commands") - return nil - } return c } -// installUnknownVerbFallback makes `group` emit a consistent warning-style -// "Did you mean" hint whenever the caller types an unknown subcommand under -// that group, regardless of whether extra flags follow. This is a blanket -// safety net that covers every verb we never thought to pre-register via -// calendarInfoHintSubCmd (e.g. `dws calendar room query --min-duration 30`). -// -// Two Cobra knobs make this work together: -// - FParseErrWhitelist.UnknownFlags=true stops pflag from aborting with -// "unknown flag: --xxx" before RunE ever runs. -// - Args=cobra.ArbitraryArgs lets Cobra pass the bad verb through as the -// first positional arg instead of rejecting it. -// -// If the user types a *known* subcommand, Cobra still dispatches to that -// child's RunE as usual; this fallback only fires when resolution stops at -// `group` with leftover args. -func installUnknownVerbFallback(group *cobra.Command) { - group.FParseErrWhitelist.UnknownFlags = true - group.Args = cobra.ArbitraryArgs - - // Override HelpFunc so that ` --help` also shows - // the "unknown subcommand" error instead of silently printing help. - // Cobra intercepts --help before RunE, so without this the fallback - // would never fire when --help is present. - origHelp := group.HelpFunc() - group.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if cmd == group { - // HelpFunc receives os.Args[1:] (full arg slice without binary). - // Strip tokens matching the resolved command path to get actual - // leftover args that should be checked for unknown verbs. - depth := len(strings.Fields(cmd.CommandPath())) - 1 - leftover := stripCommandPrefix(args, depth) - if bad := findUnknownVerb(cmd, leftover); bad != "" { - printUnknownSubcmdError(cmd, bad) - return - } - origHelp(cmd, args) - return - } - // For non-group commands, render base help then apply the safety - // annotation. Recursion safety hinges on NOT calling - // cmd.Root().HelpFunc(): in test trees calendar IS the root, so that - // would re-enter this wrapper. origHelp was captured before any - // wrapping and is the plain cobra renderer. - origHelp(cmd, args) - cli.RenderSafetyAnnotation(cmd) - }) - - prev := group.RunE - group.RunE = func(cmd *cobra.Command, args []string) error { - // Unknown flags whitelisted by pflag may leak into args. Pick the first - // non-flag token as the offending verb. - if bad := findUnknownVerb(cmd, args); bad != "" { - printUnknownSubcmdError(cmd, bad) - return nil - } - // No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently - // swallows bad flags, scan the original os.Args for flags unregistered - // on this command and report them explicitly. - if flag := findUnknownFlag(cmd); flag != "" { - fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", flag) - fmt.Fprintf(os.Stderr, " hint: Run '%s --help' to see available options\n", cmd.CommandPath()) - return nil - } - if prev != nil { - return prev(cmd, args) - } - return cmd.Help() - } -} - -// findUnknownVerb returns the first positional arg that is not a registered -// subcommand (or alias) of cmd. Returns "" if all args are flags or known. -func findUnknownVerb(cmd *cobra.Command, args []string) string { - for _, a := range args { - if strings.HasPrefix(a, "-") { - continue - } - isKnown := false - for _, c := range cmd.Commands() { - if c.Name() == a { - isKnown = true - break - } - for _, alias := range c.Aliases { - if alias == a { - isKnown = true - break - } - } - if isKnown { - break - } - } - if !isKnown { - return a - } - } - return "" -} - -// printUnknownSubcmdError prints the standard "unknown subcommand" error to -// stderr with available commands and a did-you-mean hint. -func printUnknownSubcmdError(cmd *cobra.Command, bad string) { - var available []string - for _, c := range cmd.Commands() { - if !c.Hidden && c.Name() != "help" { - available = append(available, c.Name()) - } - } - fmt.Fprintf(os.Stderr, "Error: unknown subcommand %q for %q\n", bad, cmd.CommandPath()) - fmt.Fprintf(os.Stderr, " available: %s\n", strings.Join(available, ", ")) - if s := cmd.SuggestionsFor(bad); len(s) > 0 { - fmt.Fprintf(os.Stderr, " hint: did you mean %q\n", cmd.CommandPath()+" "+s[0]) - } else { - fmt.Fprintf(os.Stderr, " hint: %s --help\n", cmd.CommandPath()) - } -} - -// stripCommandPrefix strips the first `depth` non-flag tokens from args. -// This is needed because Cobra's HelpFunc receives os.Args[1:] (the full arg -// slice without the binary name), including the resolved command path tokens. -// depth should be len(strings.Fields(cmd.CommandPath())) - 1. -func stripCommandPrefix(args []string, depth int) []string { - skipped := 0 - for i, a := range args { - if skipped >= depth { - return args[i:] - } - if !strings.HasPrefix(a, "-") { - skipped++ - } - } - return nil -} - -// findUnknownFlag scans os.Args for flags that are not registered on cmd. -// Returns the first offending flag token (e.g. "--today") or "". -func findUnknownFlag(cmd *cobra.Command) string { - depth := len(strings.Fields(cmd.CommandPath())) - 1 - leftover := stripCommandPrefix(os.Args[1:], depth) - for i := 0; i < len(leftover); i++ { - a := leftover[i] - if a == "--" { - break - } - if strings.HasPrefix(a, "--") { - name := a[2:] - if eqIdx := strings.Index(name, "="); eqIdx >= 0 { - name = name[:eqIdx] - } - if name == "help" { - continue - } - if cmd.Flags().Lookup(name) == nil { - return a - } - } else if strings.HasPrefix(a, "-") && a != "-" { - ch := a[1:2] - if ch == "h" { - continue - } - if cmd.Flags().ShorthandLookup(ch) == nil { - return a - } - } - } - return "" -} - func newCalendarCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/calendar.json // products.calendar). Catalog assembly stamps provenance contract_final. @@ -224,7 +43,7 @@ func newCalendarCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "calendar", Short: "日历日程 / 会议室 / 闲忙", Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。 @@ -238,11 +57,11 @@ func newCalendarCommand() *cobra.Command { dws calendar book [list|get|search|update] 日历本管理 dws calendar acl [list|add|delete] 日历访问权限管理`, RunE: groupRunE, - } + }) // ── event: 日程 ───────────────────────────────────────────── - eventCmd := &cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE} + eventCmd := newGroupCommand(&cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE}) eventListCmd := &cobra.Command{ Use: "list", @@ -816,13 +635,13 @@ func newCalendarCommand() *cobra.Command { // ── attendee: 参会人 (曾用名: participant) ───────────────── - participantCmd := &cobra.Command{ + participantCmd := newGroupCommand(&cobra.Command{ Use: "attendee", Aliases: []string{"participant"}, Short: "日程参会人管理", Long: "管理日程的参会人。alias:`participant`,仍作为别名保留,历史调用无需改动。", RunE: groupRunE, - } + }) participantListCmd := &cobra.Command{ Use: "list", @@ -1008,7 +827,7 @@ func newCalendarCommand() *cobra.Command { // ── room: 会议室 ──────────────────────────────────────────── - roomCmd := &cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE} + roomCmd := newGroupCommand(&cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE}) roomSearchCmd := &cobra.Command{ Use: "search", @@ -1352,7 +1171,7 @@ func newCalendarCommand() *cobra.Command { // ── busy: 闲忙 ────────────────────────────────────────────── - busyCmd := &cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE} + busyCmd := newGroupCommand(&cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE}) busySearchCmd := &cobra.Command{ Use: "search", @@ -1444,7 +1263,7 @@ func newCalendarCommand() *cobra.Command { // ── attachment: 附件 ──────────────────────────────────────── - attachmentCmd := &cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE} + attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE}) attachmentAddCmd := &cobra.Command{ Use: "add", @@ -1529,7 +1348,7 @@ func newCalendarCommand() *cobra.Command { // ── acl: 日历访问权限 ───────────────────────────────────────── - aclCmd := &cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE} + aclCmd := newGroupCommand(&cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE}) aclListCmd := &cobra.Command{ Use: "list", @@ -1619,7 +1438,7 @@ func newCalendarCommand() *cobra.Command { // ── book: 日历本 ──────────────────────────────────────────── - bookCmd := &cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE} + bookCmd := newGroupCommand(&cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE}) bookListCmd := &cobra.Command{ Use: "list", @@ -2657,12 +2476,6 @@ func newCalendarCommand() *cobra.Command { root.AddCommand(eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd) - // Install the unknown-verb fallback on every group command. This covers - // arbitrary typos like `dws calendar room query --min-duration 30` that - // the per-verb calendarInfoHintSubCmd registrations below can't anticipate. - for _, g := range []*cobra.Command{root, eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd} { - installUnknownVerbFallback(g) - } // Hint subcommands must swallow any extra flags/args the caller passes, // otherwise `dws calendar list` prints the nice "ambiguous command" hint // but `dws calendar list --start ...` fails earlier with cobra's diff --git a/internal/helpers/calendar_command_coverage_more_test.go b/internal/helpers/calendar_command_coverage_more_test.go index 98e44817..d38a6310 100644 --- a/internal/helpers/calendar_command_coverage_more_test.go +++ b/internal/helpers/calendar_command_coverage_more_test.go @@ -1,9 +1,10 @@ package helpers import ( - "os" + "errors" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -46,16 +47,13 @@ func TestCrossPlatformCoverageCalendarOptionalFlagsRemainingCoverage(t *testing. func TestCrossPlatformCoverageCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) { root := &cobra.Command{Use: "calendar"} - group := &cobra.Command{Use: "room"} + group := newGroupCommand(&cobra.Command{Use: "room"}) group.SuggestionsMinimumDistance = 3 group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}}) root.AddCommand(group) - installUnknownVerbFallback(group) - oldArgs := os.Args - os.Args = []string{"dws", "calendar", "room", "--unknown"} - t.Cleanup(func() { os.Args = oldArgs }) - if err := group.RunE(group, nil); err != nil { - t.Fatalf("unknown flag fallback: %v", err) + err := group.RunE(group, []string{"serach"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" { + t.Fatalf("typed suggestion fallback: %#v", err) } - printUnknownSubcmdError(group, "serach") } diff --git a/internal/helpers/calendar_coverage_more_test.go b/internal/helpers/calendar_coverage_more_test.go index 5c552e09..f167df4e 100644 --- a/internal/helpers/calendar_coverage_more_test.go +++ b/internal/helpers/calendar_coverage_more_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -175,42 +176,22 @@ func TestCrossPlatformCoverageBuildRecurrenceCoverage(t *testing.T) { func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) { root := &cobra.Command{Use: "calendar"} - group := &cobra.Command{Use: "room", RunE: func(*cobra.Command, []string) error { return errors.New("previous") }} + group := newGroupCommand(&cobra.Command{Use: "room"}) known := &cobra.Command{Use: "search", Aliases: []string{"find"}} hidden := &cobra.Command{Use: "secret", Hidden: true} group.AddCommand(known, hidden) root.AddCommand(group) - installUnknownVerbFallback(group) - _ = group.RunE(group, []string{"unknown"}) - _ = group.RunE(group, []string{"--ignored", "search"}) - _ = group.RunE(group, nil) - group.HelpFunc()(group, []string{"calendar", "room", "unknown"}) - group.HelpFunc()(known, nil) - printUnknownSubcmdError(group, "searhc") - printUnknownSubcmdError(group, "unrelated") + var structured *apperrors.Error + if err := group.RunE(group, []string{"searhc"}); !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" { + t.Fatalf("typed group recovery = %#v", err) + } + if err := group.RunE(group, nil); err != nil { + t.Fatalf("group help = %v", err) + } hint := calendarInfoHintSubCmd("query", "use search") group.AddCommand(hint) - _ = hint.RunE(hint, nil) - - oldArgs := os.Args - t.Cleanup(func() { os.Args = oldArgs }) - group.Flags().StringP("known", "k", "", "") - for _, args := range [][]string{ - {"dws", "calendar", "room", "--"}, - {"dws", "calendar", "room", "--help"}, - {"dws", "calendar", "room", "--known=value"}, - {"dws", "calendar", "room", "--unknown=value"}, - {"dws", "calendar", "room", "-h"}, - {"dws", "calendar", "room", "-k", "value"}, - {"dws", "calendar", "room", "-x"}, - } { - os.Args = args - _ = findUnknownFlag(group) + if err := hint.RunE(hint, nil); err == nil { + t.Fatal("calendar compatibility hint succeeded") } - nilPrev := &cobra.Command{Use: "empty"} - root.AddCommand(nilPrev) - installUnknownVerbFallback(nilPrev) - os.Args = []string{"dws", "calendar", "empty"} - _ = nilPrev.RunE(nilPrev, nil) } diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 94720e8d..ba7b8751 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2403,13 +2403,13 @@ func newChatCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "chat", Aliases: []string{"im"}, Short: "群聊 / 消息 / 机器人", Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`, RunE: groupRunE, - } + }) chatChmodCmd := &cobra.Command{ Use: "chmod ", @@ -2499,12 +2499,12 @@ func newChatCommand() *cobra.Command { }, }) - chatDataAuthCmd := &cobra.Command{ + chatDataAuthCmd := newGroupCommand(&cobra.Command{ Use: "data-auth", Short: "授予 chat 数据读取权限", Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`, RunE: groupRunE, - } + }) chatDataAuthCrossOrgCmd := &cobra.Command{ Use: "cross-org", Short: "授予跨组织 chat 数据访问权限", @@ -2576,7 +2576,7 @@ func newChatCommand() *cobra.Command { // ── group 子命令 ────────────────────────────────────────── - chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE} + chatGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE}) chatGroupCreateCmd := &cobra.Command{ Use: "create", @@ -2766,6 +2766,7 @@ func newChatCommand() *cobra.Command { return callMCPTool("get_group_members", toolArgs) }, } + newHybridGroupCommand(chatGroupMembersCmd) chatGroupMembersAddBotCmd := &cobra.Command{ Use: "add-bot", @@ -2974,12 +2975,12 @@ func newChatCommand() *cobra.Command { // ── message 子命令 ──────────────────────────────────────── - chatMessageCmd := &cobra.Command{ + chatMessageCmd := newGroupCommand(&cobra.Command{ Use: "message", Short: "会话消息管理", Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`, RunE: groupRunE, - } + }) chatMessageListCmd := &cobra.Command{ Use: "list", @@ -4674,7 +4675,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id // ── bot 子命令 ──────────────────────────────────────────── - chatBotCmd := &cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE} + chatBotCmd := newGroupCommand(&cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE}) chatBotSearchCmd := &cobra.Command{ Use: "search", @@ -5210,12 +5211,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id // ── file 子命令(会话文件上传,不暴露 spaceId)─────────────── - chatFileCmd := &cobra.Command{ + chatFileCmd := newGroupCommand(&cobra.Command{ Use: "file", Short: "会话文件上传(已下线)", Hidden: true, RunE: groupRunE, - } + }) chatFileUploadCmd := &cobra.Command{ Use: "upload", @@ -5250,7 +5251,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id // ── category 子命令(会话分组,走 IM MCP)─────────────────── - chatCategoryCmd := &cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE} + chatCategoryCmd := newGroupCommand(&cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE}) chatCategoryListCmd := &cobra.Command{ Use: "list", @@ -7580,7 +7581,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 // ── group-role 子命令(群身份管理)──────────────────────── - chatGroupRoleCmd := &cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE} + chatGroupRoleCmd := newGroupCommand(&cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE}) chatGroupRoleListCmd := &cobra.Command{ Use: "list", @@ -9864,7 +9865,7 @@ status 可选值: }) // ── group notice: 群公告管理 ──────────────────────────────── - chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE} + chatGroupNoticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}) chatGroupNoticeCreateCmd := &cobra.Command{ Use: "create", @@ -10385,7 +10386,7 @@ status 可选值: "fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true, "he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true, } - chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE} + chatTextCmd := newGroupCommand(&cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}) chatTextTranslateCmd := &cobra.Command{ Use: "translate", Short: "翻译文本内容", @@ -10450,11 +10451,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd) // ── chat group user-settings ── - chatGroupUserSettingsCmd := &cobra.Command{ + chatGroupUserSettingsCmd := newGroupCommand(&cobra.Command{ Use: "user-settings", Short: "批量查询或更新当前用户的群会话设置", RunE: groupRunE, - } + }) chatGroupUserSettingsQueryCmd := &cobra.Command{ Use: "query", Short: "批量查询当前用户的群会话设置", diff --git a/internal/helpers/chat/toolbar.go b/internal/helpers/chat/toolbar.go index 379568f3..3cd2e1c9 100644 --- a/internal/helpers/chat/toolbar.go +++ b/internal/helpers/chat/toolbar.go @@ -13,7 +13,10 @@ package chat -import "github.com/spf13/cobra" +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/spf13/cobra" +) func newChatToolbarCommand() *cobra.Command { toolbarCmd := &cobra.Command{ @@ -22,6 +25,11 @@ func newChatToolbarCommand() *cobra.Command { Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。", RunE: groupRunE, } + corecmd.ApplyGroupPolicy(toolbarCmd, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) toolbarCmd.DisableAutoGenTag = true toolbarCmd.AddCommand( diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index b2f70a1b..0c72cbdd 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -121,8 +122,12 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes } root.SetArgs(tc.args) err = root.ExecuteContext(context.Background()) - if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) { - t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint) + var structured *apperrors.Error + if !errors.As(err, &structured) { + t.Fatalf("chat %s with legacy flags error = %T %v, want structured validation", tc.path, err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) { + t.Fatalf("chat %s with legacy flags error = %#v, want migration hint %q", tc.path, structured, tc.hint) } } } diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go index 0c0f5c03..1dfd32c7 100644 --- a/internal/helpers/chat_media_upload.go +++ b/internal/helpers/chat_media_upload.go @@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command { }, } media.AddCommand(newChatMediaUploadCommand()) + newHybridGroupCommand(media) return media } diff --git a/internal/helpers/chat_personal_emotion.go b/internal/helpers/chat_personal_emotion.go index eb46fe67..26138cbd 100644 --- a/internal/helpers/chat_personal_emotion.go +++ b/internal/helpers/chat_personal_emotion.go @@ -13,12 +13,12 @@ import ( const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command." func newChatEmotionCommand() *cobra.Command { - cmd := &cobra.Command{ + cmd := newGroupCommand(&cobra.Command{ Use: "emotion", Short: "个人收藏表情", Long: "查询、发送和新增当前用户的个人收藏表情。", RunE: groupRunE, - } + }) cmd.AddCommand( newChatEmotionListCommand(), newChatEmotionSendCommand(), diff --git a/internal/helpers/conference.go b/internal/helpers/conference.go index 2b34ba44..9341652c 100644 --- a/internal/helpers/conference.go +++ b/internal/helpers/conference.go @@ -39,8 +39,9 @@ func newConferenceCommand() *cobra.Command { 直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`, RunE: runUnavailable, } + newHybridGroupCommand(root) - meetingCmd := &cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE} + meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}) meetingCreateCmd := &cobra.Command{ Use: "reserve", @@ -59,7 +60,7 @@ func newConferenceCommand() *cobra.Command { root.AddCommand(meetingCmd) // member 子命令组 — 成员管理 - memberCmd := &cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE} + memberCmd := newGroupCommand(&cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE}) memberInviteCmd := &cobra.Command{ Use: "invite", diff --git a/internal/helpers/connect_exec_fixture_test.go b/internal/helpers/connect_exec_fixture_test.go index 98c7ab0b..1ab3f63b 100644 --- a/internal/helpers/connect_exec_fixture_test.go +++ b/internal/helpers/connect_exec_fixture_test.go @@ -10,6 +10,8 @@ import ( "runtime" "sync" "testing" + + "github.com/spf13/cobra" ) const ( @@ -39,7 +41,16 @@ func TestMain(m *testing.M) { if os.Getenv(helpersShellStubEnv) == "1" { os.Exit(runHelpersShellStub()) } + // Cobra's Windows pre-exec hook walks the process table on every Execute* + // call to detect Explorer launches. Helpers tests execute command trees + // thousands of times, and none of those in-process invocations can be an + // Explorer launch, so keep that production-only check out of the test + // process. This also prevents every new exhaustive harness from having to + // remember a test-local override. + originalMousetrapHelpText := cobra.MousetrapHelpText + cobra.MousetrapHelpText = "" code := m.Run() + cobra.MousetrapHelpText = originalMousetrapHelpText if helpersShellStubBaseDir != "" { _ = os.RemoveAll(helpersShellStubBaseDir) } diff --git a/internal/helpers/contact.go b/internal/helpers/contact.go index e97ce6b7..54af5c95 100644 --- a/internal/helpers/contact.go +++ b/internal/helpers/contact.go @@ -422,7 +422,7 @@ func newContactCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "contact", Short: "通讯录 / 用户 / 部门 / 角色 / 人员关系", Long: `查询钉钉通讯录:用户搜索、手机号查找、部门搜索、子部门 / 成员列表、人员关系;用户花名册档案信息(学历、家庭、银行卡、合同等)与离职员工信息。 @@ -441,9 +441,9 @@ func newContactCommand() *cobra.Command { - contact user profile fields/get: 员工花名册档案查询(学历、家庭、银行卡等) - contact user dismission search: 离职员工列表查询`, RunE: groupRunE, - } + }) - userCmd := &cobra.Command{ + userCmd := newGroupCommand(&cobra.Command{ Use: "user", Short: "人员管理", Long: `人员管理:通讯录用户查询、修改员工信息、邀请员工加入企业、用户档案(花名册)查询、离职员工查询。 @@ -457,7 +457,7 @@ func newContactCommand() *cobra.Command { - 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get - 查询离职员工列表 → contact user dismission search`, RunE: groupRunE, - } + }) contactUserGetSelfCmd := &cobra.Command{ Use: "get-self", @@ -499,10 +499,10 @@ func newContactCommand() *cobra.Command { }, }) - relationCmd := &cobra.Command{Use: "relation", + relationCmd := newGroupCommand(&cobra.Command{Use: "relation", Short: "人员关系查询", Long: `查询钉钉人员关系:特别关注人。`, - RunE: groupRunE} + RunE: groupRunE}) contactRelationListMyFollowingsCmd := &cobra.Command{ Use: "list-my-followings", @@ -704,7 +704,7 @@ func newContactCommand() *cobra.Command { // ── label 角色 ────────────────────────────────────────────────── - contactLabelCmd := &cobra.Command{ + contactLabelCmd := newGroupCommand(&cobra.Command{ Use: "label", Aliases: []string{"role"}, Short: "角色查询", @@ -720,7 +720,7 @@ func newContactCommand() *cobra.Command { 2. 从返回结果中匹配目标角色名称及 labelId 3. contact label list-members --id → 获取该角色下的成员`, RunE: groupRunE, - } + }) runContactLabelList := func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -796,7 +796,7 @@ func newContactCommand() *cobra.Command { contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd) - contactDeptCmd := &cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE} + contactDeptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE}) contactDeptSearchCmd := &cobra.Command{ Use: "search", @@ -988,7 +988,7 @@ func newContactCommand() *cobra.Command { }) // ── user profile 用户档案(花名册) ──────────────────────────────────── - contactUserProfileCmd := &cobra.Command{ + contactUserProfileCmd := newGroupCommand(&cobra.Command{ Use: "profile", Short: "用户档案(花名册)", Long: `用户档案(花名册):查询花名册字段列表、查询员工花名册字段信息。 @@ -1000,7 +1000,7 @@ func newContactCommand() *cobra.Command { - contact user get: 组织管理信息(部门、主管、管理员权限) - contact user profile get: 个人档案信息(学历、家庭、银行卡等)`, RunE: groupRunE, - } + }) contactUserProfileFieldsCmd := &cobra.Command{ Use: "fields", @@ -1124,12 +1124,12 @@ contact user profile fields 获取可用字段列表。 contactUserProfileCmd.AddCommand(contactUserProfileFieldsCmd, contactUserProfileGetCmd) // ── user dismission 离职员工 ─────────────────────────────────────────── - contactUserDismissionCmd := &cobra.Command{ + contactUserDismissionCmd := newGroupCommand(&cobra.Command{ Use: "dismission", Short: "离职员工查询", Long: `离职员工查询:分页获取离职员工列表,支持按员工姓名、离职时间范围、部门进行过滤。`, RunE: groupRunE, - } + }) contactUserDismissionSearchCmd := &cobra.Command{ Use: "search", @@ -1568,7 +1568,7 @@ contact user profile fields 获取可用字段列表。 // ── org 企业管理 ────────────────────────────────────────────────── - contactOrgCmd := &cobra.Command{ + contactOrgCmd := newGroupCommand(&cobra.Command{ Use: "org", Short: "企业管理", Long: `企业管理:创建企业。 @@ -1578,7 +1578,7 @@ contact user profile fields 获取可用字段列表。 - 创建企业专属账号 → contact account create - 邀请员工加入企业 → contact user invite`, RunE: groupRunE, - } + }) contactOrgCreateCmd := &cobra.Command{ Use: "create", @@ -1639,12 +1639,12 @@ contact user profile fields 获取可用字段列表。 // ── account 企业账号管理 ────────────────────────────────────────── - contactAccountCmd := &cobra.Command{ + contactAccountCmd := newGroupCommand(&cobra.Command{ Use: "account", Short: "企业账号管理", Long: "企业账号管理:创建或更新企业专属账号。", RunE: groupRunE, - } + }) contactAccountCreateCmd := &cobra.Command{ Use: "create", diff --git a/internal/helpers/contact_coverage_more_test.go b/internal/helpers/contact_coverage_more_test.go index 3579151b..cb4c5b6c 100644 --- a/internal/helpers/contact_coverage_more_test.go +++ b/internal/helpers/contact_coverage_more_test.go @@ -1,9 +1,11 @@ package helpers import ( + "errors" "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -37,8 +39,11 @@ func TestCrossPlatformCoverageContactRemainingCompatibilityBranches(t *testing.T if err := hint.RunE(hint, []string{"--help"}); err != nil { t.Fatalf("hint help: %v", err) } - if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") { - t.Fatalf("hint guidance err=%v", err) + err := hint.RunE(hint, []string{"unexpected"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation || + structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "use: dws contact dept") { + t.Fatalf("hint guidance err=%#v", structured) } if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") { diff --git a/internal/helpers/coverage_pure_more_test.go b/internal/helpers/coverage_pure_more_test.go index 2c526eb6..7b2e70fb 100644 --- a/internal/helpers/coverage_pure_more_test.go +++ b/internal/helpers/coverage_pure_more_test.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" "io" - "os" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -30,30 +30,6 @@ func TestCrossPlatformCoveragePureScalarAndCommandHelpersCoverage(t *testing.T) _ = isNumericUserID(value) } - root := &cobra.Command{Use: "calendar"} - known := &cobra.Command{Use: "event", Aliases: []string{"e"}} - hidden := &cobra.Command{Use: "hidden", Hidden: true} - root.AddCommand(known, hidden) - for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} { - _ = findUnknownVerb(root, args) - } - printUnknownSubcmdError(root, "evnt") - for _, depth := range []int{0, 1, 3} { - _ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth) - } - oldArgs := os.Args - t.Cleanup(func() { os.Args = oldArgs }) - root.Flags().StringP("known", "k", "", "") - for _, args := range [][]string{ - {"dws", "calendar", "--known=x"}, - {"dws", "calendar", "--unknown"}, - {"dws", "calendar", "-z"}, - {"dws", "calendar", "--", "--ignored"}, - } { - os.Args = args - _ = findUnknownFlag(root) - } - for _, event := range []any{ nil, map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}}, @@ -284,7 +260,7 @@ func TestCrossPlatformCoverageSmallHandlerAndFormatterCoverage(t *testing.T) { group := &cobra.Command{Use: "range"} group.AddCommand(&cobra.Command{Use: "read"}) parent.AddCommand(group) - _ = deepSuggestSubcommand(parent, "read") - _ = deepSuggestSubcommand(parent, "missing") + _ = cmdutil.SuggestDescendantSubcommands(parent, "read") + _ = cmdutil.SuggestDescendantSubcommands(parent, "missing") _ = time.Now() } diff --git a/internal/helpers/destructive_confirmation_test.go b/internal/helpers/destructive_confirmation_test.go index 1f0a74c2..fe15d8b0 100644 --- a/internal/helpers/destructive_confirmation_test.go +++ b/internal/helpers/destructive_confirmation_test.go @@ -504,3 +504,147 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) { }) } } + +// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand +// but additionally rewrites os.Args so resolveProductID routes doc/wiki +// auto-routed tools to their real MCP server (doc → doc, wiki → wiki). +func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error { + t.Helper() + testseam.Protect(t, &os.Args) + root := build() + os.Args = append([]string{"dws", root.Name()}, args...) + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + InitDeps(caller) + deps.Out.w = io.Discard + if root.PersistentFlags().Lookup("yes") == nil { + root.PersistentFlags().Bool("yes", false, "confirm high-risk operation") + } + if root.PersistentFlags().Lookup("dry-run") == nil { + root.PersistentFlags().Bool("dry-run", false, "preview without executing") + } + root.SilenceErrors = true + root.SilenceUsage = true + if root.InOrStdin() == os.Stdin { + root.SetIn(strings.NewReader("")) + } + root.SetArgs(args) + return root.Execute() +} + +// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the +// destructive confirmation gate on the three batch remove entry points +// (drive/doc permission remove, wiki member remove). Removing up to 30 +// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole +// departments, chats, or role groups, so every invocation — new --members +// format and legacy --users alike — must confirm first: without --yes the +// command fails with the typed confirmation_required error and performs zero +// MCP calls; with --yes it dispatches exactly one call with the complete, +// precise tool arguments; --dry-run previews without any call. +func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) { + cases := []struct { + name string + build func() *cobra.Command + args []string + product string + tool string + wantArgs map[string]any + }{ + { + name: "drive permission remove --members", + build: newDriveCommand, + args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`}, + product: "doc", + tool: "remove_permission", + wantArgs: map[string]any{ + "nodeId": "n1", + "members": []map[string]any{ + {"type": "CONVERSATION", "id": "cid1"}, + {"type": "TAG", "id": "t1", "corpId": "c1"}, + }, + }, + }, + { + name: "drive permission remove --users", + build: newDriveCommand, + args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"}, + product: "doc", + tool: "remove_permission", + wantArgs: map[string]any{ + "nodeId": "n1", + "userIds": []string{"uid1", "uid2"}, + }, + }, + { + name: "doc permission remove --members", + build: newDocCommand, + args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`}, + product: "doc", + tool: "remove_permission", + wantArgs: map[string]any{ + "nodeId": "n1", + "members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}}, + }, + }, + { + name: "wiki member remove --members", + build: newWikiCommand, + args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`}, + product: "wiki", + tool: "remove_member", + wantArgs: map[string]any{ + "workspaceId": "ws1", + "members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}}, + }, + }, + { + name: "wiki member remove --users", + build: newWikiCommand, + args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"}, + product: "wiki", + tool: "remove_member", + wantArgs: map[string]any{ + "workspaceId": "ws1", + "userIds": []string{"uid1"}, + }, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。 + caller := &guardedMutationCaller{} + err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...) + requireTypedConfirmationError(t, err) + if len(caller.calls) != 0 { + t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls) + } + + // 2) 明确确认(--yes):恰好一次调用,参数完整且精确。 + confirmed := &guardedMutationCaller{} + if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil { + t.Fatalf("confirmed remove returned error: %v", err) + } + if len(confirmed.calls) != 1 { + t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls) + } + call := confirmed.calls[0] + if call.productID != tc.product || call.toolName != tc.tool { + t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool) + } + if !reflect.DeepEqual(call.args, tc.wantArgs) { + t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs) + } + + // 3) --dry-run 预览:不触发任何 MCP 调用。 + dryRun := &guardedMutationCaller{dryRun: true} + if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil { + t.Fatalf("dry-run remove returned error: %v", err) + } + if len(dryRun.calls) != 0 { + t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls) + } + }) + } +} diff --git a/internal/helpers/dev.go b/internal/helpers/dev.go index 2c3d6f04..cd7798bc 100644 --- a/internal/helpers/dev.go +++ b/internal/helpers/dev.go @@ -16,7 +16,6 @@ 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" ) @@ -71,7 +70,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(root) + newGroupCommand(root) doc := &cobra.Command{ Use: "doc", @@ -83,7 +82,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(doc) + newGroupCommand(doc) doc.AddCommand(newDevDocSearchCommand(runner)) root.AddCommand( diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index bad84387..0b0c13e6 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -23,7 +23,6 @@ import ( 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/output" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -140,7 +139,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(root) + newGroupCommand(root) webapp := &cobra.Command{ Use: "webapp", @@ -152,7 +151,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(webapp) + newGroupCommand(webapp) webapp.AddCommand( newDevAppWebappGetCommand(runner), newDevAppWebappConfigCommand(runner), @@ -168,7 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(permission) + newGroupCommand(permission) permission.AddCommand( newDevAppPermissionListCommand(runner), newDevAppPermissionAddCommand(runner), @@ -185,7 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(credentials) + newGroupCommand(credentials) credentials.AddCommand(newDevAppCredentialsGetCommand(runner)) member := &cobra.Command{ @@ -198,7 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(member) + newGroupCommand(member) member.AddCommand( newDevAppMemberListCommand(runner), newDevAppMemberAddCommand(runner), @@ -215,7 +214,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(security) + newGroupCommand(security) security.AddCommand(newDevAppSecurityConfigCommand(runner)) robot := &cobra.Command{ @@ -228,7 +227,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(robot) + newGroupCommand(robot) robot.AddCommand( newDevAppRobotSubmitCommand(runner), newDevAppRobotResultCommand(runner), @@ -248,7 +247,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(version) + newGroupCommand(version) version.AddCommand( newDevAppVersionCreateCommand(runner), newDevAppVersionListCommand(runner), @@ -268,7 +267,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(event) + newGroupCommand(event) event.AddCommand( newDevAppEventListCommand(runner), newDevAppEventSubscribeCommand(runner), diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index c8d96c35..0d18adff 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -587,6 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { newDevAppRobotConnectRestartCommand(), newDevAppRobotConnectListCommand(runner), ) + newHybridGroupCommand(cmd) cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)") cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD") // 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖 diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go index cc926c01..ab5ae5b0 100644 --- a/internal/helpers/devdoc.go +++ b/internal/helpers/devdoc.go @@ -24,14 +24,14 @@ func newDevdocCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "devdoc", Short: "开放平台文档搜索", Long: `搜索钉钉开放平台开发文档。默认以表格格式输出(标题、URL),使用 -f json 获取原始 JSON。`, RunE: groupRunE, - } + }) - articleCmd := &cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE} + articleCmd := newGroupCommand(&cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE}) articleCmd.AddCommand(newDevdocArticleSearchCommand()) root.AddCommand(articleCmd) root.AddCommand(hintSubCmd("search", "use: dws devdoc article search --query <关键词>")) diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index c3db7200..e2f0f95d 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -42,14 +42,14 @@ func newDingCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "ding", Short: "DING 消息 / 发送 / 撤回", Long: `发送和撤回 DING 消息(应用内/短信/电话)。预发环境可用。`, RunE: groupRunE, - } + }) - dingMessageCmd := &cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE} + dingMessageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE}) dingMessageSendCmd := &cobra.Command{ Use: "send", diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 23cbb186..a4cf3cb9 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -1166,7 +1166,7 @@ func newDocCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "doc", Short: "钉钉文档管理", Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。 @@ -1190,7 +1190,7 @@ func newDocCommand() *cobra.Command { 文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`, RunE: groupRunE, - } + }) searchCmd := &cobra.Command{ Use: "search", @@ -1429,9 +1429,14 @@ func newDocCommand() *cobra.Command { readCmd := &cobra.Command{ Use: "read", Short: "读取文档内容 (Markdown)", - Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`, + Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。 + +互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。 +--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`, Example: ` dws doc read --node DOC_ID - dws doc read --node "https://alidocs.dingtalk.com/i/nodes/"`, + dws doc read --node "https://alidocs.dingtalk.com/i/nodes/" + dws doc read --node PUBLIC_URL --password + dws doc read --node DOC_ID --version 3`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { @@ -1441,6 +1446,15 @@ func newDocCommand() *cobra.Command { "dws doc read --node DOC_ID --content-format jsonml"); err != nil { return err } + password, _ := cmd.Flags().GetString("password") + historyVersion := 0 + historyVersionSet := cmd.Flags().Changed("version") + if historyVersionSet { + historyVersion, _ = cmd.Flags().GetInt("version") + if historyVersion < 0 { + return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion) + } + } format, _ := cmd.Flags().GetString("content-format") scope, _ := cmd.Flags().GetString("scope") tags, _ := cmd.Flags().GetString("tags") @@ -1481,15 +1495,18 @@ func newDocCommand() *cobra.Command { startBlockID, endBlockID, outputPath, + password, + historyVersion, + historyVersionSet, ) } if format == "jsonml" { outputPath, _ := cmd.Flags().GetString("output") - return runDocReadJsonML(cmd, nodeID, outputPath) + return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet) } - return callMCPTool("get_document_content", map[string]any{ - "nodeId": nodeID, - }) + toolArgs := map[string]any{"nodeId": nodeID} + applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet) + return callMCPTool("get_document_content", toolArgs) }, } DeclareLeafMetadata(readCmd, LeafSpec{ @@ -1516,6 +1533,8 @@ func newDocCommand() *cobra.Command { UseWhen: []string{ "用户要读取钉钉在线文字文档(adoc)正文(Markdown)时", "用户直接粘贴文档 URL 且无其他指令时(默认读内容)", + "互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码", + "要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)", "只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope", }, AvoidWhen: []string{ @@ -1533,9 +1552,11 @@ func newDocCommand() *cobra.Command { {Name: "content-format", Property: "format", Required: boolPtr(false)}, {Name: "end-block-id", Required: boolPtr(false)}, {Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"}, + {Name: "password", Property: "password", Required: boolPtr(false)}, {Name: "scope", Required: boolPtr(false)}, {Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"}, {Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"}, + {Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"}, }, }, }) @@ -1805,7 +1826,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, }) - fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE} + fileCmd := newGroupCommand(&cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}) fileCreateCmd := &cobra.Command{ Use: "create", @@ -1883,7 +1904,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, }) - folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE} + folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}) folderCreateCmd := &cobra.Command{ Use: "create", @@ -2042,12 +2063,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, }) - blockCmd := &cobra.Command{ + blockCmd := newGroupCommand(&cobra.Command{ Use: "block", Short: "块级编辑", Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`, RunE: groupRunE, - } + }) blockListCmd := &cobra.Command{ Use: "list", @@ -2680,6 +2701,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)") readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填") readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)") + readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入") + readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版") cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags") cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags") cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section") @@ -2821,12 +2844,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 _ = renameCmd.Flags().MarkHidden("title") // ── media (文档媒体/附件) ──────────────────────────────── - mediaCmd := &cobra.Command{ + mediaCmd := newGroupCommand(&cobra.Command{ Use: "media", Short: "文档媒体 / 附件管理", Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`, RunE: groupRunE, - } + }) mediaDownloadCmd := &cobra.Command{ Use: "download", @@ -3013,12 +3036,12 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表 mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd) // ── comment (文档评论) ────────────────────────────────── - commentCmd := &cobra.Command{ + commentCmd := newGroupCommand(&cobra.Command{ Use: "comment", Short: "文档评论 / 评论管理", Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`, RunE: groupRunE, - } + }) commentListCmd := &cobra.Command{ Use: "list", @@ -3522,22 +3545,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 commentCmd.AddCommand(newCommentBaseCommands("doc")...) // ── permission (文档协作权限) ──────────────────────────── - permissionCmd := &cobra.Command{ + permissionCmd := newGroupCommand(&cobra.Command{ Use: "permission", Aliases: []string{"perm"}, Short: "文档协作权限管理", Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`, RunE: groupRunE, - } + }) permissionAddCmd := &cobra.Command{ Use: "add", Short: "添加文档协作者", + Args: cobra.NoArgs, Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。 -通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。 +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId -支持的角色 (--role)(必须大写): +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织) + DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织) + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型 + +支持的角色(大小写不敏感): MANAGER 管理员,可读写、管理成员 EDITOR 编辑者,可查看、编辑、上传内容 DOWNLOADER 查看下载者,可查看并下载内容 @@ -3545,29 +3577,46 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 注意: - OWNER 角色不可通过此接口添加。 +- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。 - 单次请求最多 30 个成员,超出请分批调用。 +- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。 用户 uid 可通过「钉钉通讯录」相关命令检索,如: dws contact user search --keyword "姓名"`, Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR - dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`, + dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID + dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify + dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - if err := validateRequiredFlags(cmd, "role"); err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { - return err + toolArgs := map[string]any{"nodeId": nodeID} + members, mErr := collectMembers(cmd, false) + if mErr != nil { + return mErr } - toolArgs := map[string]any{ - "nodeId": nodeID, - "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")), - "userIds": userIds, + if len(members) > 0 { + toolArgs["members"] = members + if cmd.Flags().Changed("notify") { + notify, _ := cmd.Flags().GetBool("notify") + toolArgs["notify"] = notify + } + } else { + if err := validateRequiredFlags(cmd, "role"); err != nil { + return err + } + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role")) + toolArgs["userIds"] = userIds } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v @@ -3604,7 +3653,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 Examples: []string{"dws doc permission add --node --users uid1 --role READER --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "node", Property: "nodeId"}, + {Name: "notify", Property: "notify"}, {Name: "role", Property: "roleId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -3613,18 +3664,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 }) permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)") - permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)") + permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)") permissionAddCmd.Flags().String("user", "", "") _ = permissionAddCmd.Flags().MarkHidden("user") - permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)") + permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)") permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)") + permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥") + permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)") permissionUpdateCmd := &cobra.Command{ Use: "update", Short: "更新文档协作者权限", - Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。 + Args: cobra.NoArgs, + Long: `更新指定节点已有协作者的权限角色。 -支持的角色 (--role)(必须大写): +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId + DEPT 部门,id 为部门 ID,需携带 corpId + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId + +支持的角色 (--role)(大小写不敏感): MANAGER 管理员 EDITOR 编辑者 DOWNLOADER 查看下载者 @@ -3634,26 +3698,43 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 - OWNER 角色不可通过此接口变更。 - 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。 - 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。 +- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。 +- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。 仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`, Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR - dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`, + dws doc permission update --node DOC_ID --users uid1,uid2 --role READER + dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false + dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - if err := validateRequiredFlags(cmd, "role"); err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { - return err + toolArgs := map[string]any{"nodeId": nodeID} + members, mErr := collectMembers(cmd, false) + if mErr != nil { + return mErr } - toolArgs := map[string]any{ - "nodeId": nodeID, - "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")), - "userIds": userIds, + if len(members) > 0 { + toolArgs["members"] = members + if cmd.Flags().Changed("notify") { + notify, _ := cmd.Flags().GetBool("notify") + toolArgs["notify"] = notify + } + } else { + if err := validateRequiredFlags(cmd, "role"); err != nil { + return err + } + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role")) + toolArgs["userIds"] = userIds } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v @@ -3687,7 +3768,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 Examples: []string{"dws doc permission update --node --users uid1 --role EDITOR --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "node", Property: "nodeId"}, + {Name: "notify", Property: "notify"}, {Name: "role", Property: "roleId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -3696,11 +3779,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 }) permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)") - permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)") + permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)") permissionUpdateCmd.Flags().String("user", "", "") _ = permissionUpdateCmd.Flags().MarkHidden("user") - permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)") + permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)") permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)") + permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥") + permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)") permissionListCmd := &cobra.Command{ Use: "list", @@ -3708,11 +3793,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 Short: "查询文档协作者列表", Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。 -注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。 -若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`, +底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。 +出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。 +当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。 +操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`, Example: ` dws doc permission list --node DOC_ID - dws doc permission list --node DOC_ID --limit 100 - dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`, + dws doc permission list --node DOC_ID --limit 50 + dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR + dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { @@ -3721,14 +3809,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 toolArgs := map[string]any{ "nodeId": nodeID, } - limit := 0 - if cmd.Flags().Changed("limit") { - limit, _ = cmd.Flags().GetInt("limit") - } else if cmd.Flags().Changed("max-results") { - limit, _ = cmd.Flags().GetInt("max-results") + if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil { + return err + } else if ok { + toolArgs["pageSize"] = size } - if limit > 0 { - toolArgs["maxResults"] = limit + if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" { + toolArgs["nextToken"] = v } if v := mustGetFlag(cmd, "filter-role"); v != "" { toolArgs["filterRoleIds"] = parseRoleList(v) @@ -3766,50 +3853,75 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 }, Parameters: []contract.ParamDecl{ {Name: "filter-role", Property: "filterRoleIds"}, - {Name: "limit", Property: "maxResults"}, + // limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize, + // 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。 + {Name: "limit"}, + {Name: "next-token", Property: "nextToken"}, {Name: "node", Property: "nodeId"}, {Name: "workspace", Property: "workspaceId"}, }, + Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"}, }, }) permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)") - permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200") + permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50") permissionListCmd.Flags().Int("max-results", 0, "") _ = permissionListCmd.Flags().MarkHidden("max-results") permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER") + permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken") permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)") permissionRemoveCmd := &cobra.Command{ Use: "remove", Aliases: []string{"rm"}, Short: "移除文档协作者权限", - Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。 + Long: `从指定节点移除一个或多个协作成员的权限。 + +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId) + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId + DEPT 部门,id 为部门 ID,需携带 corpId + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId 移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。 注意: - OWNER 角色不可通过此接口移除。 -- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。 +- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。 - 单次请求最多 30 个成员,超出请分批调用。 用户 uid 可通过「钉钉通讯录」相关命令检索,如: dws contact user search --keyword "姓名"`, Example: ` dws doc permission remove --node DOC_ID --users uid1 dws doc permission remove --node DOC_ID --users uid1,uid2,uid3 - dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`, + dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 + dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - toolArgs := map[string]any{ - "nodeId": nodeID, - "userIds": userIds, + toolArgs := map[string]any{"nodeId": nodeID} + members, mErr := collectMembers(cmd, true) + if mErr != nil { + return mErr + } + if len(members) > 0 { + toolArgs["members"] = members + } else { + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["userIds"] = userIds } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v @@ -3819,8 +3931,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 } DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{ Safety: contract.SafetySpec{ + // 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个 + // 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的 + // destructive 入口,必须经过用户确认(--yes 或交互 yes)。 Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -3843,6 +3958,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 Examples: []string{"dws doc permission remove --node --users uid1 --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "node", Property: "nodeId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -3850,9 +3966,10 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 }, }) permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)") - permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)") + permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)") permissionRemoveCmd.Flags().String("user", "", "") _ = permissionRemoveCmd.Flags().MarkHidden("user") + permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥") permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)") // permission 子命令的 --node 隐藏别名 @@ -4143,6 +4260,7 @@ CLI 内部自动完成全部流程: _ = exportCmd.Flags().MarkHidden("file-id") exportCmd.AddCommand(exportGetCmd) + newHybridGroupCommand(exportCmd) // ── import: 文件导入为在线文档(一体化:上传→转换→轮询)────────────── importCmd := &cobra.Command{ @@ -4239,14 +4357,15 @@ CLI 内部自动完成全部流程: }) importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)") importCmd.AddCommand(importGetCmd) + newHybridGroupCommand(importCmd) // ── doc version 子命令组 ── - versionCmd := &cobra.Command{ + versionCmd := newGroupCommand(&cobra.Command{ Use: "version", Short: "文档历史版本管理", Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`, RunE: groupRunE, - } + }) versionSaveCmd := &cobra.Command{ Use: "save", @@ -4432,7 +4551,7 @@ CLI 内部自动完成全部流程: versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd) // ── template 子命令组 ────────────────────────────────────────────────────── - templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}) templateListCmd := &cobra.Command{ Use: "list", @@ -4752,16 +4871,30 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) { } } +// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号) +// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段, +// 显式 --version 0 表示读取文档初始版本。 +func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) { + if password != "" { + args["password"] = password + } + if historyVersionSet && historyVersion >= 0 { + args["historyVersion"] = historyVersion + } +} + // resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。 // 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。 -func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error { +func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{ + toolArgs := map[string]any{ "nodeId": nodeID, "format": "jsonml", - }) + } + applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet) + resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs) if err != nil { return err } @@ -4813,7 +4946,7 @@ func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error // runDocReadScope calls get_document_content with JSONML filtering parameters // and preserves the returned read-only fragment container. -func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error { +func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() @@ -4833,6 +4966,7 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, if endBlockID != "" && scope == "range" { args["endBlockId"] = endBlockID } + applyDocReadAccessParams(args, password, historyVersion, historyVersionSet) resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args) if err != nil { @@ -5161,3 +5295,85 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) { } return userIds, nil } + +// collectMembers parses the --members JSON array flag (new format), returning +// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG +// member types, each carrying an independent roleId. +// +// When onlyTypeID is true (remove operations), roleId is not required — +// only type and id are needed. +func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) { + raw := mustGetFlag(cmd, "members") + if raw == "" { + return nil, nil + } + var members []map[string]any + if err := json.Unmarshal([]byte(raw), &members); err != nil { + return nil, fmt.Errorf("--members JSON 解析失败: %w", err) + } + if len(members) == 0 { + return nil, fmt.Errorf("--members 不能为空数组") + } + if len(members) > 30 { + return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用") + } + for i, m := range members { + mt, ok := m["type"].(string) + if !ok { + return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i) + } + if _, ok := m["id"].(string); !ok { + return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i) + } + // USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填 + if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil { + return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt) + } + if !onlyTypeID { + if _, ok := m["roleId"].(string); !ok { + return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i) + } + if r, ok := m["roleId"].(string); ok { + m["roleId"] = normalizePermissionRole(r) + } + } + } + return members, nil +} + +// validateMembersExclusivity ensures --members (new format) and --users (legacy +// format) are not used simultaneously. Exactly one must be provided. +func validateMembersExclusivity(cmd *cobra.Command) error { + hasMembers := mustGetFlag(cmd, "members") != "" + hasUsers := flagOrFallback(cmd, "users", "user") != "" + if hasMembers && hasUsers { + return fmt.Errorf("--members 与 --users 互斥,不可同时传递") + } + if !hasMembers && !hasUsers { + return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一") + } + if hasMembers && mustGetFlag(cmd, "role") != "" { + return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId") + } + return nil +} + +// permissionPageSizeFromFlags resolves and validates the page size for the +// permission / member list commands. Both --limit and the hidden +// --max-results alias map to the server pageSize, whose accepted range is +// 1..50 (the backend rejects pageSize > 50 with +// invalidRequest.inputArgs.invalid, and non-positive values are invalid). +// It returns (size, true, nil) when a page size was explicitly provided. +func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) { + for _, name := range []string{"limit", "max-results"} { + if !cmd.Flags().Changed(name) { + continue + } + size, _ := cmd.Flags().GetInt(name) + if size < 1 || size > 50 { + return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size) + } + return size, true, nil + } + return 0, false, nil +} diff --git a/internal/helpers/doc_coverage_more_test.go b/internal/helpers/doc_coverage_more_test.go index 674d74f1..bbff1a7d 100644 --- a/internal/helpers/doc_coverage_more_test.go +++ b/internal/helpers/doc_coverage_more_test.go @@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}} installScriptedCaller(t, caller) - err := runDocReadJsonML(&cobra.Command{}, "node", tc.output) + err := runDocReadJsonML("node", tc.output, "", 0, false) if tc.wantFail && err == nil { t.Fatal("expected failure") } diff --git a/internal/helpers/doc_read_scope_test.go b/internal/helpers/doc_read_scope_test.go index 3fe9e8d7..dd68fc1d 100644 --- a/internal/helpers/doc_read_scope_test.go +++ b/internal/helpers/doc_read_scope_test.go @@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) { if err != nil || len(remaining) != 0 { t.Fatalf("find read: remaining=%v err=%v", remaining, err) } - for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} { + for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} { if read.Flags().Lookup(name) == nil { t.Fatalf("doc read missing --%s", name) } @@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { output := installDocReadScopeCaller(t, tt.caller) - err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output) + err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want %q", err, tt.wantErr) @@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) { caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`} stdout := installDocReadScopeCaller(t, caller) outputPath := filepath.Join(t.TempDir(), "fragment.json") - if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil { + if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil { t.Fatalf("runDocReadScope: %v", err) } var receipt map[string]any @@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) { t.Run("human output reports missing fragment", func(t *testing.T) { caller := &docReadScopeCaller{text: `{}`, format: "raw"} stdout := installDocReadScopeCaller(t, caller) - if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil { + if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil { t.Fatalf("runDocReadScope: %v", err) } if !strings.Contains(stdout.String(), "未匹配到节点") { @@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) { caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"} stdout := installDocReadScopeCaller(t, caller) outputPath := filepath.Join(t.TempDir(), "fragment.json") - if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil { + if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil { t.Fatalf("runDocReadScope: %v", err) } if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) { @@ -312,3 +312,93 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) { } }) } + +// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径 +// 都把 --password / --version 透传到 get_document_content,且非法版本号在 +// 本地校验阶段被拒绝、不产生远端调用。 +func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) { + t.Run("markdown path forwards password and history version", func(t *testing.T) { + caller := &docReadScopeCaller{text: `{"markdown":"body"}`} + if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil { + t.Fatalf("execute: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(caller.calls)) + } + want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7} + if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want) + } + }) + + t.Run("jsonml path forwards password and history version", func(t *testing.T) { + caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`} + if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil { + t.Fatalf("execute: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(caller.calls)) + } + want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7} + if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want) + } + }) + + t.Run("scope path forwards password and history version", func(t *testing.T) { + caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`} + if err := executeDocReadScopeCommand(t, caller, + "read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline", + "--password", "pw", "--version", "7"); err != nil { + t.Fatalf("execute: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(caller.calls)) + } + want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7} + if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want) + } + }) + + t.Run("negative history version is rejected locally", func(t *testing.T) { + for _, raw := range []string{"-1", "-3"} { + caller := &docReadScopeCaller{} + err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw) + if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") { + t.Fatalf("--version %s error = %v, want invalid --version", raw, err) + } + if len(caller.calls) != 0 { + t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls)) + } + } + }) + + t.Run("explicit zero history version forwards initial version", func(t *testing.T) { + caller := &docReadScopeCaller{text: `{"markdown":"body"}`} + if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil { + t.Fatalf("execute: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(caller.calls)) + } + want := map[string]any{"nodeId": "doc-1", "historyVersion": 0} + if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want) + } + }) + + t.Run("unset history version is not forwarded", func(t *testing.T) { + caller := &docReadScopeCaller{text: `{"markdown":"body"}`} + if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil { + t.Fatalf("execute: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(caller.calls)) + } + want := map[string]any{"nodeId": "doc-1"} + if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want) + } + }) +} diff --git a/internal/helpers/doc_style.go b/internal/helpers/doc_style.go index 109a3d56..45c76f50 100644 --- a/internal/helpers/doc_style.go +++ b/internal/helpers/doc_style.go @@ -20,7 +20,7 @@ const docStyleGetToolName = "get_document_style" // newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。 func newDocStyleCommand() *cobra.Command { - styleCmd := &cobra.Command{ + styleCmd := newGroupCommand(&cobra.Command{ Use: "style", Short: "文档样式配置 (封面/背景)", Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。 @@ -34,13 +34,13 @@ func newDocStyleCommand() *cobra.Command { 封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`, RunE: groupRunE, - } + }) - coverCmd := &cobra.Command{ + coverCmd := newGroupCommand(&cobra.Command{ Use: "cover", Short: "文档封面设置/移除", RunE: groupRunE, - } + }) coverSetCmd := &cobra.Command{ Use: "set", Short: "设置文档封面", @@ -110,11 +110,11 @@ func newDocStyleCommand() *cobra.Command { }, }) - backgroundCmd := &cobra.Command{ + backgroundCmd := newGroupCommand(&cobra.Command{ Use: "background", Short: "文档背景设置/清除", RunE: groupRunE, - } + }) backgroundSetCmd := &cobra.Command{ Use: "set", Short: "设置文档背景纯色", diff --git a/internal/helpers/doc_whiteboard.go b/internal/helpers/doc_whiteboard.go index 2ca8e224..3416aaf4 100644 --- a/internal/helpers/doc_whiteboard.go +++ b/internal/helpers/doc_whiteboard.go @@ -207,12 +207,12 @@ func runWhiteboardInsert(cmd *cobra.Command, _ []string) error { } func newDocWhiteboardCommand() *cobra.Command { - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "whiteboard", Short: "白板卡片管理", Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`, RunE: groupRunE, - } + }) insertCmd := &cobra.Command{ Use: "insert", diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 08243328..e91135a7 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -375,12 +375,12 @@ func newDriveCommand() *cobra.Command { }, }, }) - driveCmd := &cobra.Command{ + driveCmd := newGroupCommand(&cobra.Command{ Use: "drive", Short: "钉盘文件管理", Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`, RunE: groupRunE, - } + }) driveListCmd := &cobra.Command{ Use: "list", @@ -2030,39 +2030,67 @@ func newDriveCommand() *cobra.Command { driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)") // ── drive permission (文档节点权限管理) ── - drivePermissionCmd := &cobra.Command{ + drivePermissionCmd := newGroupCommand(&cobra.Command{ Use: "permission", Aliases: []string{"perm"}, Short: "文档节点权限管理", Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。 注意: 仅适用于文档空间节点,不适用于钉盘文件。`, RunE: groupRunE, - } + }) drivePermAddCmd := &cobra.Command{ Use: "add", Short: "添加协作者", + Args: cobra.NoArgs, Long: `为文档空间节点添加协作成员并授予指定角色。 -支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`, +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织) + DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织) + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型 + +支持的角色: MANAGER / EDITOR / DOWNLOADER / READER +--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。 +省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`, Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER - dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`, + dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR + dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify + dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - if err := validateRequiredFlags(cmd, "role"); err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { - return err + toolArgs := map[string]any{"nodeId": nodeID} + members, mErr := collectMembers(cmd, false) + if mErr != nil { + return mErr } - toolArgs := map[string]any{ - "nodeId": nodeID, - "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")), - "userIds": userIds, + if len(members) > 0 { + toolArgs["members"] = members + if cmd.Flags().Changed("notify") { + notify, _ := cmd.Flags().GetBool("notify") + toolArgs["notify"] = notify + } + } else { + if err := validateRequiredFlags(cmd, "role"); err != nil { + return err + } + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role")) + toolArgs["userIds"] = userIds } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v @@ -2103,7 +2131,9 @@ func newDriveCommand() *cobra.Command { Examples: []string{"dws drive permission add --node --users uid1,uid2 --role READER --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "node", Property: "nodeId"}, + {Name: "notify", Property: "notify"}, {Name: "role", Property: "roleId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -2111,35 +2141,64 @@ func newDriveCommand() *cobra.Command { }, }) drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") - drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)") + drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)") drivePermAddCmd.Flags().String("user", "", "") _ = drivePermAddCmd.Flags().MarkHidden("user") - drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)") + drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)") drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)") + drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥") + drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)") drivePermUpdateCmd := &cobra.Command{ Use: "update", Short: "更新协作者权限", + Args: cobra.NoArgs, Long: `更新文档空间节点已有协作者的权限角色。 -支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`, - Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`, +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId + DEPT 部门,id 为部门 ID,需携带 corpId + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId + +支持的角色: MANAGER / EDITOR / DOWNLOADER / READER +--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`, + Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR + dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false + dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - if err := validateRequiredFlags(cmd, "role"); err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { - return err + toolArgs := map[string]any{"nodeId": nodeID} + members, mErr := collectMembers(cmd, false) + if mErr != nil { + return mErr } - toolArgs := map[string]any{ - "nodeId": nodeID, - "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")), - "userIds": userIds, + if len(members) > 0 { + toolArgs["members"] = members + if cmd.Flags().Changed("notify") { + notify, _ := cmd.Flags().GetBool("notify") + toolArgs["notify"] = notify + } + } else { + if err := validateRequiredFlags(cmd, "role"); err != nil { + return err + } + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role")) + toolArgs["userIds"] = userIds } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v @@ -2176,7 +2235,9 @@ func newDriveCommand() *cobra.Command { Examples: []string{"dws drive permission update --node --users uid1 --role EDITOR --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "node", Property: "nodeId"}, + {Name: "notify", Property: "notify"}, {Name: "role", Property: "roleId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -2184,33 +2245,39 @@ func newDriveCommand() *cobra.Command { }, }) drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") - drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)") + drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)") drivePermUpdateCmd.Flags().String("user", "", "") _ = drivePermUpdateCmd.Flags().MarkHidden("user") - drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)") + drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)") drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)") + drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥") + drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)") drivePermListCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "查询协作者列表", - Long: `查询文档空间节点的协作者列表。`, + Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。 + +底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。 +出参包含 totalCount、hasMore 和 nextToken。 +当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`, Example: ` dws drive permission list --node DOC_ID - dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`, + dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR + dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } toolArgs := map[string]any{"nodeId": nodeID} - limit := 0 - if cmd.Flags().Changed("limit") { - limit, _ = cmd.Flags().GetInt("limit") - } else if cmd.Flags().Changed("max-results") { - limit, _ = cmd.Flags().GetInt("max-results") + if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil { + return err + } else if ok { + toolArgs["pageSize"] = size } - if limit > 0 { - toolArgs["maxResults"] = limit + if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" { + toolArgs["nextToken"] = v } if v := mustGetFlag(cmd, "filter-role"); v != "" { toolArgs["filterRoleIds"] = parseRoleList(v) @@ -2251,38 +2318,184 @@ func newDriveCommand() *cobra.Command { }, Parameters: []contract.ParamDecl{ {Name: "filter-role", Property: "filterRoleIds"}, - {Name: "limit", Property: "maxResults"}, + // limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize, + // 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。 + {Name: "limit"}, + {Name: "next-token", Property: "nextToken"}, {Name: "node", Property: "nodeId"}, {Name: "workspace", Property: "workspaceId"}, }, + Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"}, }, }) drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") - drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200") + drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50") drivePermListCmd.Flags().Int("max-results", 0, "") _ = drivePermListCmd.Flags().MarkHidden("max-results") drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER") + drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken") drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)") - drivePermRemoveCmd := &cobra.Command{ - Use: "remove", - Aliases: []string{"rm"}, - Short: "移除协作者权限", - Long: `从文档空间节点移除协作成员的权限。`, - Example: ` dws drive permission remove --node DOC_ID --users uid1 - dws drive permission remove --node DOC_ID --users uid1,uid2`, + drivePermGetSettingCmd := &cobra.Command{ + Use: "get-setting", + Short: "查询节点权限设置", + Long: `查询文档空间节点的权限设置,返回三部分配置: + +- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理) +- shareScope: 分享范围(可见范围、链接分享设置) +- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等) + +查询协作者列表请改用 permission list。`, + Example: ` dws drive permission get-setting --node DOC_ID`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - userIds, err := collectUserIDs(cmd) + return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID}) + }, + } + DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "drive", + Name: "get_permission_setting", + CanonicalPath: "drive.get_permission_setting", + CLIPath: "drive permission get-setting", + PrimaryCLIPath: "drive permission get-setting", + }, + Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"节点权限设置(权限模式/分享范围/权限策略)", + "properties":{ + "docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"}, + "nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"}, + "permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"}, + "shareScope":{ + "type":"object", + "description":"分享范围设置", + "properties":{ + "visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"}, + "partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"}, + "defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"}, + "canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"}, + "canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"}, + "linkShare":{ + "type":"object", + "description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回", + "properties":{ + "requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"}, + "expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"}, + "expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"}, + "forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"} + }, + "additionalProperties":true + } + }, + "additionalProperties":true + }, + "policies":{ + "type":"array", + "description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥", + "items":{ + "type":"object", + "description":"权限策略项", + "properties":{ + "code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"}, + "name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"}, + "description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"}, + "value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"}, + "disabledValues":{ + "type":"array", + "description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组", + "items":{ + "type":"object", + "properties":{ + "value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"}, + "reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"} + }, + "required":["value"], + "additionalProperties":true + } + }, + "allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"} + }, + "required":["code","name","description","disabledValues"], + "additionalProperties":true + } + } + }, + "required":["docUrl","nodeId","shareScope","policies"], + "additionalProperties":true +}`), + }, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)", + UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"}, + AvoidWhen: []string{ + "查协作者清单用 permission list", + "查可申请角色与审批人用 permission apply-info", + }, + Examples: []string{"dws drive permission get-setting --node --format json"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "nodeId"}, + }, + }, + }) + drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") + + drivePermRemoveCmd := &cobra.Command{ + Use: "remove", + Aliases: []string{"rm"}, + Short: "移除协作者权限", + Long: `从文档空间节点移除协作成员的权限。 + +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId) + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId + DEPT 部门,id 为部门 ID,需携带 corpId + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`, + Example: ` dws drive permission remove --node DOC_ID --users uid1 + dws drive permission remove --node DOC_ID --users uid1,uid2 + dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { return err } - toolArgs := map[string]any{ - "nodeId": nodeID, - "userIds": userIds, + if err := validateMembersExclusivity(cmd); err != nil { + return err + } + toolArgs := map[string]any{"nodeId": nodeID} + members, mErr := collectMembers(cmd, true) + if mErr != nil { + return mErr + } + if len(members) > 0 { + toolArgs["members"] = members + } else { + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["userIds"] = userIds } if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v @@ -2292,8 +2505,11 @@ func newDriveCommand() *cobra.Command { } DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{ Safety: contract.SafetySpec{ + // 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个 + // 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的 + // destructive 入口,必须经过用户确认(--yes 或交互 yes)。 Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -2319,6 +2535,7 @@ func newDriveCommand() *cobra.Command { Examples: []string{"dws drive permission remove --node --users uid1 --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "node", Property: "nodeId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -2326,13 +2543,14 @@ func newDriveCommand() *cobra.Command { }, }) drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)") - drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)") + drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)") drivePermRemoveCmd.Flags().String("user", "", "") _ = drivePermRemoveCmd.Flags().MarkHidden("user") + drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥") drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)") // permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致) - for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd} { + for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} { c.Flags().String("url", "", "") c.Flags().String("id", "", "") c.Flags().String("node-id", "", "") @@ -2564,7 +2782,7 @@ func newDriveCommand() *cobra.Command { drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT") drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符") - drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd) + drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd) // --node 隐藏别名(保持与迁移前 doc 命令一致) driveNodeAliasCmds := []*cobra.Command{ @@ -2588,12 +2806,12 @@ func newDriveCommand() *cobra.Command { _ = driveRenameCmd.Flags().MarkHidden("title") // ── drive recycle 子命令组 ── - recycleCmd := &cobra.Command{ + recycleCmd := newGroupCommand(&cobra.Command{ Use: "recycle", Short: "钉盘回收站管理", Long: `管理钉盘回收站:查看回收站列表、还原回收项。`, RunE: groupRunE, - } + }) recycleListCmd := &cobra.Command{ Use: "list", @@ -2703,7 +2921,7 @@ func newDriveCommand() *cobra.Command { // ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)── // folder create → dws wiki node create --type folder - driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE} + driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE}) driveFolderCreateCmd := &cobra.Command{ Use: "create", Short: "创建文件夹(deprecated)", @@ -2733,12 +2951,12 @@ func newDriveCommand() *cobra.Command { driveFolderCmd.AddCommand(driveFolderCreateCmd) // ── drive publish (文件互联网公开发布管理) ── - drivePublishCmd := &cobra.Command{ + drivePublishCmd := newGroupCommand(&cobra.Command{ Use: "publish", Short: "文件互联网公开发布管理", Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`, RunE: groupRunE, - } + }) drivePublishSetCmd := &cobra.Command{ Use: "set", @@ -3033,11 +3251,11 @@ func newDriveCommand() *cobra.Command { _ = driveRecentCmd.Flags().MarkHidden("page-token") // ── drive star (文档收藏管理) ── - driveStarCmd := &cobra.Command{ + driveStarCmd := newGroupCommand(&cobra.Command{ Use: "star", Short: "文档收藏管理", RunE: groupRunE, - } + }) driveStarAddCmd := &cobra.Command{ Use: "add", Short: "收藏文档", diff --git a/internal/helpers/drive_permission_get_setting_test.go b/internal/helpers/drive_permission_get_setting_test.go new file mode 100644 index 00000000..fae03b95 --- /dev/null +++ b/internal/helpers/drive_permission_get_setting_test.go @@ -0,0 +1,237 @@ +// 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" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" +) + +// ── drive permission get-setting:跨产品路由与 --node 别名归一化 ── + +func TestCrossPlatformCoverageDrivePermissionGetSettingRoutesToDrive(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "permission", "get-setting", "--node", "node-1") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want exactly one", caller.calls) + } + call := caller.calls[0] + if call.productID != "drive" || call.toolName != "get_permission_setting" { + t.Fatalf("call = %#v", call) + } + if len(call.args) != 1 || call.args["nodeId"] != "node-1" { + t.Fatalf("args = %#v, want only nodeId=node-1", call.args) + } +} + +func TestCrossPlatformCoverageDrivePermissionGetSettingHiddenAliases(t *testing.T) { + for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "permission", "get-setting", "--"+alias, "node-alias") + if err != nil { + t.Fatalf("alias --%s: %v", alias, err) + } + if len(caller.calls) != 1 { + t.Fatalf("alias --%s calls = %#v, want exactly one", alias, caller.calls) + } + call := caller.calls[0] + if call.productID != "drive" || call.toolName != "get_permission_setting" { + t.Fatalf("alias --%s call = %#v", alias, call) + } + if call.args["nodeId"] != "node-alias" { + t.Fatalf("alias --%s args = %#v, want nodeId=node-alias", alias, call.args) + } + } +} + +func TestCrossPlatformCoverageDrivePermissionGetSettingRequiresNode(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "permission", "get-setting") + if err == nil || !strings.Contains(err.Error(), "flag --node is required") { + t.Fatalf("err = %v, want flag --node is required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("calls = %#v, want none before required-flag validation", caller.calls) + } +} + +// ── drive permission get-setting:ResultSpec 返回值契约 ── + +func TestCrossPlatformCoverageDrivePermissionGetSettingResultContract(t *testing.T) { + drive := newDriveCommand() + leaf, _, err := drive.Find([]string{"permission", "get-setting"}) + if err != nil || leaf == nil { + t.Fatalf("find drive permission get-setting: command=%v err=%v", leaf, err) + } + final, ok := contractfinal.RuntimeContractFinal(leaf) + if !ok || final.Identity == nil || final.Identity.CanonicalPath != "drive.get_permission_setting" { + t.Fatalf("get-setting ContractFinal identity = %#v, found = %v", final.Identity, ok) + } + if final.Result == nil { + t.Fatal("get-setting final Result is nil") + } + wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure} + if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) { + t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes) + } + + var root map[string]any + if err := json.Unmarshal(final.Result.DataSchema, &root); err != nil { + t.Fatalf("result data_schema is not JSON: %v\n%s", err, final.Result.DataSchema) + } + assertSchemaRequired(t, root, "docUrl", "nodeId", "shareScope", "policies") + properties := resultSchemaProperties(t, final.Result.DataSchema) + if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, []string{"docUrl", "nodeId", "permissionMode", "policies", "shareScope"}) { + t.Fatalf("result properties = %#v", got) + } + + permissionMode, ok := properties["permissionMode"].(map[string]any) + if !ok { + t.Fatalf("permissionMode = %#v, want schema object", properties["permissionMode"]) + } + if got := schemaEnumValues(t, permissionMode); !reflect.DeepEqual(got, []string{"INHERITED", "INDEPENDENT", ""}) { + t.Fatalf("permissionMode enum = %#v", got) + } + if types, ok := permissionMode["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" { + t.Fatalf("permissionMode type = %#v, want [string null]", permissionMode["type"]) + } + + shareScope := schemaObjectProperty(t, properties, "shareScope") + shareScopeProperties := schemaProperties(t, shareScope) + if got := sortedContractSchemaKeys(shareScopeProperties); !reflect.DeepEqual(got, []string{"canRecommend", "canSearch", "defaultRole", "linkShare", "partnerIncluded", "visibility"}) { + t.Fatalf("shareScope properties = %#v", got) + } + if _, ok := shareScope["required"]; ok { + t.Fatalf("shareScope required = %#v, want none (linkShare is only returned when link sharing is configured)", shareScope["required"]) + } + if got := schemaEnumValues(t, shareScopeProperties["visibility"].(map[string]any)); !reflect.DeepEqual(got, []string{"PRIVATE", "ORGANIZATION", "PUBLIC", ""}) { + t.Fatalf("visibility enum = %#v", got) + } + if got := schemaEnumValues(t, shareScopeProperties["defaultRole"].(map[string]any)); !reflect.DeepEqual(got, []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER", ""}) { + t.Fatalf("defaultRole enum = %#v", got) + } + linkShareProperties := schemaProperties(t, schemaObjectProperty(t, shareScopeProperties, "linkShare")) + if got := sortedContractSchemaKeys(linkShareProperties); !reflect.DeepEqual(got, []string{"expireAt", "expireDays", "forCurrentNode", "requirePassword"}) { + t.Fatalf("linkShare properties = %#v", got) + } + + policies, ok := properties["policies"].(map[string]any) + if !ok || policies["type"] != "array" { + t.Fatalf("policies = %#v, want array schema", properties["policies"]) + } + policiesDescription, _ := policies["description"].(string) + for _, fragment := range []string{"未下发或不受支持的策略不会返回", "node_spread_scope 仅文件夹类节点返回", "两者互斥"} { + if !strings.Contains(policiesDescription, fragment) { + t.Fatalf("policies description missing %q: %s", fragment, policiesDescription) + } + } + items, ok := policies["items"].(map[string]any) + if !ok { + t.Fatalf("policies items = %#v", policies["items"]) + } + assertSchemaRequired(t, items, "code", "name", "description", "disabledValues") + itemProperties := schemaProperties(t, items) + if got := sortedContractSchemaKeys(itemProperties); !reflect.DeepEqual(got, []string{"allowedValues", "code", "description", "disabledValues", "name", "value"}) { + t.Fatalf("policy item properties = %#v", got) + } + code, ok := itemProperties["code"].(map[string]any) + if !ok { + t.Fatalf("code = %#v", itemProperties["code"]) + } + if got := schemaEnumValues(t, code); !reflect.DeepEqual(got, []string{ + "external_share", "external_share_manager_only", "member_invite", "member_invite_org_only", + "comment", "permission_apply", "external_permission_apply", "watermark", "node_spread", + "online_content_copy", "node_move_forbidden", "node_spread_scope", + }) { + t.Fatalf("code enum = %#v", got) + } + value, ok := itemProperties["value"].(map[string]any) + if !ok { + t.Fatalf("value = %#v", itemProperties["value"]) + } + valueDescription, _ := value["description"].(string) + for _, fragment := range []string{"ENABLED/DISABLED", "READER_AND_ABOVE", "NOBODY", "ALL_NODES", "PREVIEWABLE_ONLY", "不低于该角色才允许", "所有人禁止", "限制对所有文档生效", "仅对可预览的文档"} { + if !strings.Contains(valueDescription, fragment) { + t.Fatalf("value description missing %q: %s", fragment, valueDescription) + } + } + for _, field := range []string{"name", "description"} { + entry, ok := itemProperties[field].(map[string]any) + if !ok || entry["type"] != "string" { + t.Fatalf("policy item %s = %#v, want string schema", field, itemProperties[field]) + } + entryDescription, _ := entry["description"].(string) + if !strings.Contains(entryDescription, "确定性字段") || !strings.Contains(entryDescription, "只要该策略返回就必带") { + t.Fatalf("policy item %s description = %q", field, entryDescription) + } + } + disabledValues, ok := itemProperties["disabledValues"].(map[string]any) + if !ok || disabledValues["type"] != "array" { + t.Fatalf("disabledValues = %#v, want array schema", itemProperties["disabledValues"]) + } + disabledValuesDescription, _ := disabledValues["description"].(string) + for _, fragment := range []string{"与 allowedValues 互斥", "恒返回", "空数组"} { + if !strings.Contains(disabledValuesDescription, fragment) { + t.Fatalf("disabledValues description missing %q: %s", fragment, disabledValuesDescription) + } + } + disabledItems, ok := disabledValues["items"].(map[string]any) + if !ok { + t.Fatalf("disabledValues items = %#v", disabledValues["items"]) + } + assertSchemaRequired(t, disabledItems, "value") + disabledItemProperties := schemaProperties(t, disabledItems) + if got := sortedContractSchemaKeys(disabledItemProperties); !reflect.DeepEqual(got, []string{"reason", "value"}) { + t.Fatalf("disabledValues item properties = %#v", got) + } + if disabledValue, ok := disabledItemProperties["value"].(map[string]any); !ok || disabledValue["type"] != "string" { + t.Fatalf("disabledValues value = %#v, want string schema", disabledItemProperties["value"]) + } + reason, ok := disabledItemProperties["reason"].(map[string]any) + if !ok { + t.Fatalf("disabledValues reason = %#v", disabledItemProperties["reason"]) + } + if types, ok := reason["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" { + t.Fatalf("disabledValues reason type = %#v, want [string null]", reason["type"]) + } + allowedValues, ok := itemProperties["allowedValues"].(map[string]any) + if !ok { + t.Fatalf("allowedValues = %#v", itemProperties["allowedValues"]) + } + if allowedValuesDescription, _ := allowedValues["description"].(string); !strings.Contains(allowedValuesDescription, "与 value 同一值域") { + t.Fatalf("allowedValues description = %q", allowedValues["description"]) + } +} + +func schemaEnumValues(t *testing.T, property map[string]any) []string { + t.Helper() + raw, ok := property["enum"].([]any) + if !ok { + t.Fatalf("schema enum = %#v, want array", property["enum"]) + } + values := make([]string, 0, len(raw)) + for _, value := range raw { + switch typed := value.(type) { + case string: + values = append(values, typed) + case nil: + values = append(values, "") + default: + t.Fatalf("schema enum value = %#v, want string or null", value) + } + } + return values +} diff --git a/internal/helpers/errors.go b/internal/helpers/errors.go index 9e6cf7f9..e5e703db 100644 --- a/internal/helpers/errors.go +++ b/internal/helpers/errors.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) const ( @@ -134,6 +136,13 @@ func WrapErrorWithOperation(err error, operation string) error { if _, ok := err.(*PATError); ok { return err } + // 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传: + // “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会 + // 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED, + // 其余则丢失 reason 退化为 UNCLASSIFIED)。 + if apperrors.IsConfirmationRequired(err) { + return err + } msg := err.Error() // File lock timeout (cross-process lock) @@ -254,6 +263,24 @@ func WrapErrorWithOperation(err error, operation string) error { } } + // 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”), + // 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截, + // 透传服务端原始错误信息。 + if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") { + errMsg := msg + var body map[string]any + if json.Unmarshal([]byte(msg), &body) == nil { + errMsg = businessErrorDisplayMessage(body, msg) + } + return &CLIError{ + Code: CodeMCPToolError, + Message: errMsg, + Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId", + Operation: operation, + Cause: err, + } + } + // Resource not found if errContainsAny(msg, "not found", "不存在", "资源不存在") { suggestion := "Check if the resource exists or if your account has permission" @@ -379,21 +406,98 @@ func suggestForBusinessErrorText(text string) string { return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list" case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"): return "API rate limit exceeded, wait a moment and retry" + case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") || + strings.Contains(text, "成员不存在"): + return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId" case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"): return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。" case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"): return "Check input parameters. Use --help for available flags" + case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") || + strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") || + strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") || + strings.Contains(text, "FORBIDDEN"): + return permissionDeniedSuggestion(text) default: return "" } } +// documentPermissionServerCodes are permission-denied codes that are +// demonstrably drive-specific (their identity carries the forbidden.* domain +// prefix). Only these (plus the document/wiki role-threshold message wording) +// justify the dws drive permission apply-* guidance. Generic code names such +// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance +// (get-self-setting) and event-subscription tools have been observed returning +// NO_PERMISSION, so keying guidance on it would mislead non-document products +// exactly the way this classification tries to avoid. +var documentPermissionServerCodes = map[string]bool{ + "forbidden.no.auth": true, + "forbidden.accessDenied": true, +} + +// documentPermissionErrorText reports whether a permission-denied message is +// specific to document/wiki permission (drive/doc/wiki), e.g. the role +// threshold wording emitted by the member-management APIs or the document +// permission error codes embedded in the message text. +func documentPermissionErrorText(text string) bool { + if text == "" { + return false + } + lower := strings.ToLower(text) + return strings.Contains(lower, "forbidden.no.auth") || + strings.Contains(lower, "forbidden.accessdenied") || + strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色") +} + +// isDocumentPermissionError reports whether a permission-denied body is +// specific to document/wiki access, where applying for access via +// dws drive permission apply-* is the actionable next step. +func isDocumentPermissionError(body map[string]any) bool { + for _, key := range []string{"code", "errorCode", "server_error_code"} { + if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] { + return true + } + } + return documentPermissionErrorText(businessErrorMessage(body)) +} + +// genericPermissionSuggestion is the product-neutral suggestion for +// permission-denied errors that are not specific to document/wiki access. +const genericPermissionSuggestion = "Verify your account has permission for this resource" + +// permissionDeniedSuggestion picks between document apply-permission guidance +// and a product-neutral hint: only document/wiki-specific errors point at +// dws drive permission apply-*, so other products never get a misleading +// document-permission suggestion. +func permissionDeniedSuggestion(text string) string { + if documentPermissionErrorText(text) { + return permissionApplyGuidance + } + return genericPermissionSuggestion +} + +// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied +// body: document/wiki-specific errors get the apply-permission guidance; other +// products keep their product-specific suggestion (e.g. the mail mailbox hint) +// or fall back to the product-neutral hint. +func permissionDeniedSuggestionFor(body map[string]any) string { + if isDocumentPermissionError(body) { + return permissionApplyGuidance + } + if suggestion := suggestForBusinessError(body); suggestion != "" { + return suggestion + } + return genericPermissionSuggestion +} + // ClassifyToolResultContent checks a raw MCP tool result content map for // DWS gateway auth errors and PAT permission error codes. This is used as the // edition.Hooks.ClassifyToolResult callback so the framework's runner returns // a typed error before its generic business-error classification. // -// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission. +// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission > +// no-permission guidance. func ClassifyToolResultContent(content map[string]any) error { if _, ok := getDWSGatewayErrorCode(content); ok { raw, _ := json.Marshal(content) @@ -408,6 +512,20 @@ func ClassifyToolResultContent(content map[string]any) error { return &PATError{RawJSON: cleanPATJSON(content, code)} } } + + // Permission-denied business errors: surface apply-permission guidance before + // the framework's generic rendering swallows it. Guidance is limited to + // document/wiki-specific signals (forbidden.* codes or role-threshold + // wording); other products keep the product-neutral suggestion. + if isNoPermissionError(content) { + suggestion := permissionDeniedSuggestionFor(content) + raw, _ := json.Marshal(content) + return &CLIError{ + Code: CodeAuthPermission, + Message: businessErrorDisplayMessage(content, string(raw)), + Suggestion: suggestion, + } + } return nil } @@ -419,6 +537,23 @@ var patNoPermissionCodes = map[string]bool{ "PAT_HIGH_RISK_NO_PERMISSION": true, } +// noPermissionServerCodes are business-level permission-denied codes (from the +// MCP server / gateway) that should surface apply-permission guidance instead of +// being swallowed by the framework's generic business-error rendering. +var noPermissionServerCodes = map[string]bool{ + "NO_PERMISSION": true, + "FORBIDDEN": true, + "forbidden.no.auth": true, + "forbidden.accessDenied": true, +} + +// permissionApplySteps lists the two-step commands to apply for document access. +const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" + + " 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role --users <审批人userId>" + +// permissionApplyGuidance is the standard suggestion for permission-denied errors. +const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps + // ClassifyMCPResponseText classifies a text response returned by an MCP tool call. // Returns a typed error for known gateway auth failures, PAT interceptions, // and business-level errors embedded in HTTP-200 JSON bodies. @@ -452,10 +587,18 @@ func ClassifyMCPResponseText(text string) error { } } + if isNoPermissionError(body) { + return &CLIError{ + Code: CodeAuthPermission, + Message: text, + Suggestion: permissionDeniedSuggestionFor(body), + } + } + if isBusinessError(body) { return &CLIError{ Code: CodeMCPToolError, - Message: text, + Message: businessErrorDisplayMessage(body, text), Suggestion: suggestForBusinessError(body), } } diff --git a/internal/helpers/errors_test.go b/internal/helpers/errors_test.go index 35087399..c02d6a8b 100644 --- a/internal/helpers/errors_test.go +++ b/internal/helpers/errors_test.go @@ -123,10 +123,19 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te "User has no permission to access this email": "mailbox list", "频率超限": "rate limit", "rate limit": "rate limit", + "用户不存在": "--members", + "不属于当前组织": "--members", + "成员不存在": "--members", "未找到指定工具": "未注册", "MCP不存在": "未注册", "参数错误": "input parameters", "param error": "input parameters", + // 非文档类权限文本不再给出 drive permission apply 指引。 + "无权限访问": "Verify your account", + "权限不足": "Verify your account", + // 权限关键词叠加文档特征(文档权限码/角色门槛)时保留 apply 指引。 + "您没有权限访问该文档 (forbidden.no.auth)": "权限申请", + "无权限访问:需要您具备 MANAGER 及以上角色": "权限申请", } for input, want := range suggestions { if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) { @@ -152,17 +161,76 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te t.Errorf("PAT key %s classified as %#v", key, err) } } + // forbidden.accessDenied(新接口权限不足错误码)应分类为 AUTH_PERMISSION_DENIED + // 并携带权限申请指引,而不是被 generic business-error 渲染吞掉。 + for _, key := range []string{"code", "errorCode", "server_error_code"} { + err := ClassifyToolResultContent(map[string]any{key: "forbidden.accessDenied", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"}) + cli, ok := err.(*CLIError) + if !ok || cli.Code != CodeAuthPermission { + t.Errorf("accessDenied key %s classified as %#v, want AUTH_PERMISSION_DENIED", key, err) + continue + } + if !strings.Contains(cli.Message, "需要您具备") || !strings.Contains(cli.Suggestion, "permission apply") { + t.Errorf("accessDenied guidance missing: message=%q suggestion=%q", cli.Message, cli.Suggestion) + } + } + // 带 forbidden.* 域名的权限码是 drive 专属,单独出现即可拿到 apply 指引。 + for _, code := range []string{"forbidden.no.auth"} { + err := ClassifyToolResultContent(map[string]any{"code": code, "success": false, "errorMsg": "no permission"}) + cli, ok := err.(*CLIError) + if !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") { + t.Errorf("document code %s classified as %#v, want AUTH_PERMISSION_DENIED with apply guidance", code, err) + } + } + // 通用码名 NO_PERMISSION 不再单独触发文档 apply 指引(回归测试): + // 考勤 get-self-setting、事件订阅等非文档工具也会返回该码。 + nonDoc := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "no permission"}) + if cli, ok := nonDoc.(*CLIError); !ok || cli.Code != CodeAuthPermission || + !strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") { + t.Errorf("NO_PERMISSION classified as %#v, want product-neutral suggestion without apply guidance", nonDoc) + } + // NO_PERMISSION 叠加文档特征文本(角色门槛)时仍保留 apply 指引。 + docCombo := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"}) + if cli, ok := docCombo.(*CLIError); !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") { + t.Errorf("NO_PERMISSION with document wording classified as %#v, want apply guidance", docCombo) + } + // 非文档产品的权限错误保留产品专属建议(邮件)或通用提示, + // 不得被改写为 drive 文档权限申请指引。 + mail := ClassifyToolResultContent(map[string]any{"success": false, "errorMsg": "User has no permission to access this email"}) + if cli, ok := mail.(*CLIError); !ok || cli.Code != CodeAuthPermission || + !strings.Contains(cli.Suggestion, "mailbox list") || strings.Contains(cli.Suggestion, "permission apply") { + t.Errorf("mail permission error classified as %#v, want mailbox hint without apply guidance", mail) + } + generic := ClassifyToolResultContent(map[string]any{"success": false, "code": "FORBIDDEN", "errorMsg": "群权限不足"}) + if cli, ok := generic.(*CLIError); !ok || cli.Code != CodeAuthPermission || + !strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") { + t.Errorf("generic permission error classified as %#v, want product-neutral suggestion", generic) + } for _, tc := range []struct { - text string - kind string + text string + kind string + suggHas string }{ - {"not-json", "nil"}, - {`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"}, - {`{"error":"Missing service_id or access_key"}`, "cli"}, - {`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"}, - {`{"success":false,"errorMsg":"rate limit"}`, "cli"}, - {`{"success":true}`, "nil"}, + {"not-json", "nil", ""}, + {"null", "nil", ""}, + {`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli", ""}, + {`{"error":"Missing service_id or access_key"}`, "cli", ""}, + {`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat", ""}, + {`{"success":false,"errorMsg":"rate limit"}`, "cli", ""}, + {`{"success":false,"code":"forbidden.accessDenied","errorMsg":"需要您具备 MANAGER 及以上角色","logId":"abc123"}`, "authperm", "permission apply"}, + // 非文档产品的权限错误:分类为 AUTH_PERMISSION_DENIED,但建议保持 + // 产品专属(邮件 mailbox)或通用提示,不带文档 apply 指引。 + {`{"success":false,"errorMsg":"User has no permission to access this email"}`, "authperm", "mailbox list"}, + {`{"success":false,"code":"FORBIDDEN","errorMsg":"群权限不足"}`, "authperm", "Verify your account"}, + {`{"success":false,"code":"FORBIDDEN"}`, "authperm", "Verify your account"}, + {`{"success":false,"code":"NO_PERMISSION","errorMsg":"no permission"}`, "authperm", "Verify your account"}, + // 文档/知识库角色门槛文案与文档权限码文本:apply 指引保留。 + {`{"success":false,"errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"}, + {`{"success":false,"errorMsg":"forbidden.no.auth: 你无权访问该文档"}`, "authperm", "permission apply"}, + {`{"success":false,"code":"NO_PERMISSION","errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"}, + {`{"success":false,"errorMsg":"members[0].id 对应的用户不存在"}`, "cli", ""}, + {`{"success":true}`, "nil", ""}, } { err := ClassifyMCPResponseText(tc.text) switch tc.kind { @@ -174,6 +242,15 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te if _, ok := err.(*CLIError); !ok { t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err) } + case "authperm": + cli, ok := err.(*CLIError) + if !ok || cli.Code != CodeAuthPermission { + t.Errorf("ClassifyMCPResponseText(%s) = %#v, want AUTH_PERMISSION_DENIED", tc.text, err) + continue + } + if !strings.Contains(cli.Suggestion, tc.suggHas) { + t.Errorf("ClassifyMCPResponseText(%s) suggestion = %q, want it to contain %q", tc.text, cli.Suggestion, tc.suggHas) + } case "pat": if _, ok := err.(*PATError); !ok { t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err) @@ -235,3 +312,82 @@ func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) { t.Fatalf("stripClassFields slice = %s", got) } } + +// TestCrossPlatformCoverageBusinessErrorMessagePriority 同步自内部 MR 28965577: +// errorMessage 是新接口返回的用户/成员校验错误的字段,优先级介于 errorMsg 与 message 之间。 +func TestCrossPlatformCoverageBusinessErrorMessagePriority(t *testing.T) { + if msg := businessErrorMessage(map[string]any{"errorMsg": "first", "errorMessage": "second", "message": "third", "error": "fourth"}); msg != "first" { + t.Errorf("businessErrorMessage = %q, want %q (errorMsg has priority)", msg, "first") + } + want := "members[0].id 对应的用户不存在" + if msg := businessErrorMessage(map[string]any{"errorMessage": want, "message": "third", "error": "fourth"}); msg != want { + t.Errorf("businessErrorMessage = %q, want %q (errorMessage fallback)", msg, want) + } + if msg := businessErrorMessage(map[string]any{"message": "second", "error": "third"}); msg != "second" { + t.Errorf("businessErrorMessage = %q, want %q (message fallback)", msg, "second") + } + if msg := businessErrorMessage(map[string]any{}); msg != "" { + t.Errorf("businessErrorMessage(empty) = %q, want empty", msg) + } +} + +// TestCrossPlatformCoverageBusinessErrorDisplayMessage 验证 code/logId 附加以便排查。 +func TestCrossPlatformCoverageBusinessErrorDisplayMessage(t *testing.T) { + got := businessErrorDisplayMessage(map[string]any{"errorMessage": "权限不足", "logId": "abc123"}, "raw") + if got != "权限不足 (logId: abc123)" { + t.Errorf("businessErrorDisplayMessage = %q, want logId appended", got) + } + // errorCode/code 存在时附加在后,保证后端错误码可见 + got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败", "errorCode": "forbidden.document.sizeOverLimit"}, "raw") + if got != "失败 (code: forbidden.document.sizeOverLimit)" { + t.Errorf("businessErrorDisplayMessage = %q, want code appended", got) + } + got = businessErrorDisplayMessage(map[string]any{"message": "失败", "code": "forbidden.accessDenied", "logId": "l1"}, "raw") + if got != "失败 (code: forbidden.accessDenied, logId: l1)" { + t.Errorf("businessErrorDisplayMessage = %q, want code+logId appended", got) + } + // logId 已包含在 message 中时不重复追加 + got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败 logId:abc123", "logId": "abc123"}, "raw") + if got != "失败 logId:abc123" { + t.Errorf("businessErrorDisplayMessage = %q, want no duplicate logId", got) + } + // 无任何 message 字段时回退 rawText + if got := businessErrorDisplayMessage(map[string]any{"success": false}, "raw-fallback"); got != "raw-fallback" { + t.Errorf("businessErrorDisplayMessage = %q, want rawText fallback", got) + } +} + +// TestCrossPlatformCoverageUserNotInOrgErrors 验证「用户不存在/不属于当前组织/成员不存在」 +// 在 RESOURCE_NOT_FOUND 之前被拦截为 MCP_TOOL_ERROR 并携带 --members corpId 建议。 +func TestCrossPlatformCoverageUserNotInOrgErrors(t *testing.T) { + suggestion := suggestForBusinessError(map[string]any{"errorMessage": "members[0].id 对应的用户不存在或不属于当前组织(实际值:209499),请检查后重试。"}) + if !strings.Contains(suggestion, "--members") { + t.Errorf("suggestForBusinessError should mention --members for user/org error, got: %q", suggestion) + } + + for _, msg := range []string{ + "用户不存在", + "不属于当前组织", + "成员不存在", + } { + cli, ok := WrapErrorWithOperation(errors.New(msg), "wiki/add_member").(*CLIError) + if !ok { + t.Fatalf("WrapErrorWithOperation(%q) not a CLIError", msg) + } + if cli.Code != CodeMCPToolError { + t.Errorf("WrapErrorWithOperation(%q).Code = %s, want %s (not RESOURCE_NOT_FOUND)", msg, cli.Code, CodeMCPToolError) + } + if !strings.Contains(cli.Suggestion, "corpId") { + t.Errorf("WrapErrorWithOperation(%q).Suggestion should mention corpId, got: %q", msg, cli.Suggestion) + } + } + + // JSON 体形式:透传 errorMessage 且附 logId + cli, ok := WrapErrorWithOperation(errors.New(`{"errorMessage":"用户不存在","logId":"lid-1"}`), "wiki/add_member").(*CLIError) + if !ok { + t.Fatal("WrapErrorWithOperation(json) not a CLIError") + } + if cli.Message != "用户不存在 (logId: lid-1)" { + t.Errorf("WrapErrorWithOperation(json).Message = %q, want errorMessage + logId", cli.Message) + } +} diff --git a/internal/helpers/file_comment.go b/internal/helpers/file_comment.go index f573de94..425f2224 100644 --- a/internal/helpers/file_comment.go +++ b/internal/helpers/file_comment.go @@ -55,12 +55,12 @@ func fileCommentSpaceIDFlag() LeafFlag { // doc comment, sheet comment, and drive comment. The public command belongs to // Drive, while the implementation routes to the shared doc-comment MCP server. func newDriveFileCommentCmd() *cobra.Command { - commentCmd := &cobra.Command{ + commentCmd := newGroupCommand(&cobra.Command{ Use: "comment", Short: "普通文件评论管理", Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。", RunE: groupRunE, - } + }) listCmd := NewLeafCommand(LeafSpec{ Use: "list", diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index f2607027..fbd8208b 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -15,15 +15,16 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) -// Re-export cmdutil functions as package-level aliases so that existing product -// files continue to compile with their current (unexported) call sites. -// This avoids a mass-rename in 22 product files while still consolidating the -// implementations in pkg/cmdutil. +// Re-export shared command helpers as package-level aliases so existing product +// files continue to compile with their current (unexported) call sites. This +// avoids a mass-rename while keeping reusable command-resolution and flag +// utilities in cmdutil. var ( groupRunE = cmdutil.GroupRunE hintSubCmd = cmdutil.HintSubCmd @@ -38,6 +39,40 @@ var ( helperAfter = time.After ) +// newGroupCommand declares the ordinary navigation policy used by helper +// command containers. The unified framework compiles this declaration into +// Cobra behavior and command-resolution metadata. +func newGroupCommand(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + return command +} + +// newDeepGroupCommand declares a navigation container whose typo recovery may +// teach exact descendant paths (for example sheet read -> sheet range read). +func newDeepGroupCommand(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoveryDeep, + }) + return command +} + +// newHybridGroupCommand declares a business command that also owns children. +// Its existing RunE remains the command's default action. +func newHybridGroupCommand(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + return command +} + // Deps holds shared dependencies injected from the host application. type Deps struct { Caller edition.ToolCaller @@ -255,7 +290,11 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul } if isBusinessError(errBody) { return "", &CLIError{ - Code: CodeMCPToolError, + Code: CodeMCPToolError, + // 注意:这里必须保留原始响应 JSON。此路径是数据编排层 + // (如 drive list --depth 的限流重试)的输入,下游会从 + // Message 反解析 errorCode;人话提取(含 code/logId 附加) + // 只用于 callMCPToolInternalOptsContext 的终端展示路径。 Message: c.Text, Suggestion: suggestForBusinessError(errBody), } @@ -440,6 +479,20 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN // 尝试将返回文本解析为 JSON,进行错误分类 var errBody map[string]any if json.Unmarshal([]byte(c.Text), &errBody) == nil { + // errBody == nil 表示文本为 "null",服务端返回了空响应。 + // 仅对已确认“空响应=写成功”契约的 permission/member update/remove + // 工具适配为空对象 {},避免消费方解析 null 时出错;其它工具的 + // 合法 null 保持原样输出,不改变未版本化的机器输出契约。 + if errBody == nil { + if nullOnSuccessTools[toolName] { + if deps.Caller.Format() == "json" { + return printJSON(map[string]any{}) + } + deps.Out.PrintRaw("{}") + return nil + } + return renderLegacyMCPText(toolName, c.Text, unescapeHTML) + } // 网关层错误(如 token 过期) if _, ok := getDWSGatewayErrorCode(errBody); ok { return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()} @@ -454,7 +507,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN } // 业务逻辑错误 if isBusinessError(errBody) { - return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)} + return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)} } } @@ -465,6 +518,18 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN return printJSON(result) } +// nullOnSuccessTools lists MCP tools whose server contract is confirmed to +// return a literal JSON null for successful no-payload writes (permission / +// member update/remove). Only these get the null→{} adaptation; every other +// tool keeps its raw null output so the shared machine-output contract stays +// unchanged. +var nullOnSuccessTools = map[string]bool{ + "update_permission": true, + "remove_permission": true, + "update_member": true, + "remove_member": true, +} + // RenderLegacyMCPText renders an already-fetched MCP text response through the // exact legacy formatter. It lets dual validation execute the business request // once, validate a shadow unified result, and still preserve legacy bytes. @@ -765,15 +830,64 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) { // suggestForBusinessError returns a user-facing suggestion for known business // error patterns in a parsed JSON body, or "" if no specific suggestion applies. func suggestForBusinessError(body map[string]any) string { - msg := "" - if v, ok := body["errorMsg"].(string); ok { - msg = v - } else if v, ok := body["message"].(string); ok { - msg = v - } else if v, ok := body["error"].(string); ok { - msg = v + return suggestForBusinessErrorText(businessErrorMessage(body)) +} + +// businessErrorMessage extracts the human-readable message from a parsed error +// body, checking errorMsg > errorMessage > message > error. Returns "" if none present. +func businessErrorMessage(body map[string]any) string { + for _, k := range []string{"errorMsg", "errorMessage", "message", "error"} { + if v, ok := body[k].(string); ok && v != "" { + return v + } } - return suggestForBusinessErrorText(msg) + return "" +} + +// businessErrorDisplayMessage extracts the human-readable message from a parsed +// error body, appends the backend error code and logId if present (for +// traceability), and falls back to rawText if no message field is found. +func businessErrorDisplayMessage(body map[string]any, rawText string) string { + msg := businessErrorMessage(body) + if msg == "" { + return rawText + } + var extras []string + for _, k := range []string{"errorCode", "error_code", "code"} { + if code, ok := body[k].(string); ok && code != "" && !strings.Contains(msg, code) { + extras = append(extras, "code: "+code) + break + } + } + if logId, ok := body["logId"].(string); ok && logId != "" && !strings.Contains(msg, logId) { + extras = append(extras, "logId: "+logId) + } + if len(extras) > 0 { + msg = msg + " (" + strings.Join(extras, ", ") + ")" + } + return msg +} + +// isNoPermissionError reports whether a parsed error body represents a +// permission-denied error, by known server codes or message text. Used to +// surface apply-permission guidance before the framework's generic rendering. +func isNoPermissionError(body map[string]any) bool { + for _, key := range []string{"code", "errorCode", "server_error_code"} { + if code, ok := body[key].(string); ok && noPermissionServerCodes[code] { + return true + } + } + msg := businessErrorMessage(body) + if msg == "" { + return false + } + lower := strings.ToLower(msg) + return strings.Contains(msg, "无权限访问") || strings.Contains(msg, "没有访问权限") || + strings.Contains(msg, "没有权限") || strings.Contains(msg, "权限不足") || + strings.Contains(lower, "no permission") || strings.Contains(lower, "permission denied") || + strings.Contains(lower, "forbidden.no.auth") || + strings.Contains(lower, "forbidden.accessdenied") || + strings.Contains(msg, "需要您具备") || strings.Contains(msg, "及以上角色") } // confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that diff --git a/internal/helpers/hrbrain.go b/internal/helpers/hrbrain.go index 79a7e42b..4a60164a 100644 --- a/internal/helpers/hrbrain.go +++ b/internal/helpers/hrbrain.go @@ -29,7 +29,7 @@ func newHrbrainCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "hrbrain", Short: "组织大脑:人才池、员工档案与人才搜索", Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。 @@ -47,11 +47,11 @@ func newHrbrainCommand() *cobra.Command { dws hrbrain search employees-structured 使用高级条件搜索人员 dws hrbrain search fields 获取高级搜索字段列表`, RunE: groupRunE, - } + }) // ── talent-pool: 人才池管理 ──────────────────────────────── - talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE} + talentPoolCmd := newGroupCommand(&cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}) talentPoolListCmd := &cobra.Command{ Use: "list", @@ -236,7 +236,7 @@ func newHrbrainCommand() *cobra.Command { // ── profile: 员工档案管理 ────────────────────────────────── - profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE} + profileCmd := newGroupCommand(&cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}) profileMetadataCmd := &cobra.Command{ Use: "metadata", @@ -493,7 +493,7 @@ func newHrbrainCommand() *cobra.Command { // ── search: 人才搜索 ───────────────────────────────────── - searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE} + searchCmd := newGroupCommand(&cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}) employeeSearchCmd := &cobra.Command{ Use: "employees", diff --git a/internal/helpers/http_coverage_more_test.go b/internal/helpers/http_coverage_more_test.go index d7bf6fd4..2d4e01b5 100644 --- a/internal/helpers/http_coverage_more_test.go +++ b/internal/helpers/http_coverage_more_test.go @@ -147,9 +147,9 @@ func TestCrossPlatformCoverageRunDocReadJsonMLCoverage(t *testing.T) { `{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`, } { caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} - _ = runDocReadJsonML(nil, "node", "") + _ = runDocReadJsonML("node", "", "", 0, false) } caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}} - _ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json")) - _ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json")) + _ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "out.json"), "", 0, false) + _ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "missing", "out.json"), "pw", 3, true) } diff --git a/internal/helpers/interfaces.go b/internal/helpers/interfaces.go index 062b78e7..57881082 100644 --- a/internal/helpers/interfaces.go +++ b/internal/helpers/interfaces.go @@ -72,7 +72,8 @@ func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command out := make([]*cobra.Command, 0, len(factories)) for _, factory := range factories { handler := factory() - out = append(out, handler.Command(runner)) + command := handler.Command(runner) + out = append(out, command) } sort.Slice(out, func(i, j int) bool { return out[i].Use < out[j].Use diff --git a/internal/helpers/live.go b/internal/helpers/live.go index 9e6266ab..bb18fc7c 100644 --- a/internal/helpers/live.go +++ b/internal/helpers/live.go @@ -20,14 +20,14 @@ func newLiveCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "live", Short: "直播列表 / 信息", Long: `查看钉钉直播:列出我的直播记录。`, RunE: groupRunE, - } + }) - streamCmd := &cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE} + streamCmd := newGroupCommand(&cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE}) streamListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/mail.go b/internal/helpers/mail.go index 30793fd2..3c5fa5e1 100644 --- a/internal/helpers/mail.go +++ b/internal/helpers/mail.go @@ -113,14 +113,14 @@ func newMailCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "mail", Short: "邮箱 / 邮件收发", Long: `管理钉钉企业邮箱:查询邮箱地址、搜索邮件、查看邮件、发送邮件、获取会话(thread)、列举文件夹、列举标签。`, RunE: groupRunE, - } + }) - mailboxCmd := &cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE} + mailboxCmd := newGroupCommand(&cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE}) mailboxListCmd := &cobra.Command{ Use: "list", @@ -263,7 +263,7 @@ func newMailCommand() *cobra.Command { mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd) - messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE} + messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}) messageSearchCmd := &cobra.Command{ Use: "search", @@ -588,7 +588,7 @@ func newMailCommand() *cobra.Command { }, }) - folderCmd := &cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE} + folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE}) folderListCmd := &cobra.Command{ Use: "list", @@ -842,7 +842,7 @@ func newMailCommand() *cobra.Command { folderCmd.AddCommand(folderListCmd, folderCreateCmd, folderDeleteCmd, folderUpdateCmd) - tagCmd := &cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE} + tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE}) tagListCmd := &cobra.Command{ Use: "list", @@ -995,7 +995,7 @@ func newMailCommand() *cobra.Command { tagCmd.AddCommand(tagListCmd, tagCreateCmd, tagDeleteCmd, tagUpdateCmd) - threadCmd := &cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE} + threadCmd := newGroupCommand(&cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE}) threadListCmd := &cobra.Command{ Use: "list", @@ -1975,7 +1975,7 @@ internetMessageId 来源:message send / draft send / message reply / message r }, }) - attachmentCmd := &cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE} + attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE}) attachmentListCmd := &cobra.Command{ Use: "list", @@ -2443,7 +2443,7 @@ internetMessageId 来源:message send / draft send / message reply / message r messageReplyCmd, messageReplyAllCmd, messageForwardCmd, messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd) - sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE} + sentMessageCmd := newGroupCommand(&cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}) sentMessageRecallCmd := &cobra.Command{ Use: "recall", @@ -2600,10 +2600,10 @@ internetMessageId 来源:message send / draft send / message reply / message r _ = draftSendCmd.Flags().MarkHidden("sender") draftSendCmd.Flags().String("id", "", "草稿邮件 ID (必填)") - draftCmd := &cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE} + draftCmd := newGroupCommand(&cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE}) draftCmd.AddCommand(draftCreateCmd, draftUpdateCmd, draftSendCmd) - userCmd := &cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE} + userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE}) userSearchCmd := &cobra.Command{ Use: "search", @@ -2707,7 +2707,7 @@ user 对象字段: // ── template 子命令组 ────────────────────────────────── - templateCmd := &cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE}) templateCreateCmd := &cobra.Command{ Use: "create", @@ -3057,7 +3057,7 @@ user 对象字段: // ── contact 子命令组 ────────────────────────────────── - contactCmd := &cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE} + contactCmd := newGroupCommand(&cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE}) contactCreateCmd := &cobra.Command{ Use: "create", @@ -3315,7 +3315,7 @@ user 对象字段: contactCmd.AddCommand(contactCreateCmd, contactListCmd, contactUpdateCmd, contactBatchDeleteCmd) // ── auto-reply 自动回复 ────────────────────────────── - autoReplyCmd := &cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE} + autoReplyCmd := newGroupCommand(&cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE}) autoReplyGetCmd := &cobra.Command{ Use: "get", @@ -3393,7 +3393,7 @@ user 对象字段: autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd) // ── rule 收信规则 ──────────────────────────────────── - ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE} + ruleCmd := newGroupCommand(&cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}) ruleListCmd := &cobra.Command{ Use: "list", @@ -3578,7 +3578,7 @@ object 与 operation 合法组合: ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd) // ── allow-list 个人收信白名单 ──────────────────────────────── - allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE} + allowListCmd := newGroupCommand(&cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}) allowListListCmd := &cobra.Command{ Use: "list", @@ -3646,7 +3646,7 @@ object 与 operation 合法组合: allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd) // ── block-list 个人收信黑名单 ──────────────────────────────── - blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE} + blockListCmd := newGroupCommand(&cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}) blockListListCmd := &cobra.Command{ Use: "list", @@ -3713,7 +3713,7 @@ object 与 operation 合法组合: blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)") blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd) - calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE} + calendarCmd := newGroupCommand(&cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}) calendarListCmd := &cobra.Command{ Use: "list", Short: "列出用户可访问的日历列表", @@ -3762,7 +3762,7 @@ object 与 operation 合法组合: calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") calendarCmd.AddCommand(calendarListCmd) - calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE} + calendarEventCmd := newGroupCommand(&cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}) calendarEventListCmd := &cobra.Command{ Use: "list", Short: "查询指定日历时间范围内的日程", diff --git a/internal/helpers/markdown.go b/internal/helpers/markdown.go index 505126f6..d38943d8 100644 --- a/internal/helpers/markdown.go +++ b/internal/helpers/markdown.go @@ -46,12 +46,12 @@ func newMarkdownCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "markdown", Short: "Markdown 文件处理", Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。", RunE: groupRunE, - } + }) root.AddCommand( newMarkdownFetchCmd(), newMarkdownCreateCmd(), diff --git a/internal/helpers/minutes.go b/internal/helpers/minutes.go index e3f11ce8..f843ef73 100644 --- a/internal/helpers/minutes.go +++ b/internal/helpers/minutes.go @@ -28,7 +28,7 @@ func newMinutesCommand() *cobra.Command { }, }, }) - minutesListCmd := &cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE} + minutesListCmd := newGroupCommand(&cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE}) minutesListMineCmd := &cobra.Command{ Use: "mine", @@ -173,7 +173,7 @@ func newMinutesCommand() *cobra.Command { }, }) - minutesGetCmd := &cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE} + minutesGetCmd := newGroupCommand(&cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE}) minutesGetInfoCmd := &cobra.Command{ Use: "info", @@ -544,7 +544,7 @@ func newMinutesCommand() *cobra.Command { }, }) - minutesUpdateCmd := &cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE} + minutesUpdateCmd := newGroupCommand(&cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE}) minutesUpdateTitleCmd := &cobra.Command{ Use: "title", @@ -659,7 +659,7 @@ func newMinutesCommand() *cobra.Command { // 单个工具通过 cmd 参数覆盖 create/pause/resume/end 四种指令。 const listeningNoteCmdTool = "执行听记指令-发起AI听记录音" - minutesRecordCmd := &cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE} + minutesRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE}) minutesRecordStartCmd := &cobra.Command{ Use: "start", @@ -942,7 +942,7 @@ func newMinutesCommand() *cobra.Command { minutesUpdateCmd.AddCommand(minutesUpdateTitleCmd, minutesUpdateSummaryCmd) // ── mind-graph 子组 ───────────────────────────────────────── - mindGraphCmd := &cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE} + mindGraphCmd := newGroupCommand(&cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE}) mindGraphCreateCmd := &cobra.Command{ Use: "create", @@ -1059,7 +1059,7 @@ func newMinutesCommand() *cobra.Command { mindGraphCmd.AddCommand(mindGraphCreateCmd, mindGraphStatusCmd) // ── speaker 子组 ──────────────────────────────────────────── - speakerCmd := &cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE} + speakerCmd := newGroupCommand(&cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE}) speakerReplaceCmd := &cobra.Command{ Use: "replace", @@ -1127,7 +1127,7 @@ func newMinutesCommand() *cobra.Command { // 对应 MCP 工具 create_speaker_summary / get_speaker_summary // 批量按听记维度汇总每位发言人的段落总结 - speakerSummaryCmd := &cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE} + speakerSummaryCmd := newGroupCommand(&cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE}) speakerSummaryCreateCmd := &cobra.Command{ Use: "create", @@ -1243,7 +1243,7 @@ func newMinutesCommand() *cobra.Command { speakerCmd.AddCommand(speakerReplaceCmd, speakerSummaryCmd) // ── hot-word 子组 ─────────────────────────────────────────── - hotWordCmd := &cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE} + hotWordCmd := newGroupCommand(&cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE}) hotWordAddCmd := &cobra.Command{ Use: "add", @@ -1462,7 +1462,7 @@ func newMinutesCommand() *cobra.Command { // 完整流程:create → HTTP PUT → complete,或 create → cancel 取消。 // 注意:upload 子组的所有命令均使用 callMCPToolUnescaped 输出 JSON, // 避免 presignedUrl 中的 & 被 Go 标准库转义为 \u0026。 - uploadCmd := &cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE} + uploadCmd := newGroupCommand(&cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE}) // upload create — 对应 MCP 工具 create_upload_session // 必填参数:fileName(--file-name), fileSize(--file-size) @@ -1681,7 +1681,7 @@ func newMinutesCommand() *cobra.Command { // ── permission 子组 ───────────────────────────────────────── // 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。 // 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。 - permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE} + permissionCmd := newGroupCommand(&cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}) // permission add — 对应 MCP 工具 add_member_permission // 批量给多个听记增加成员,并设置成员的权限。 @@ -1951,7 +1951,7 @@ func newMinutesCommand() *cobra.Command { // 听记标签/分组管理:查询用户标签列表、按标签查询听记。 // 对应 MCP 工具 query_user_tag_list / query_minutes_by_tag_id。 // 标签/分组由用户在听记页面手动创建,此处仅提供查询能力。 - tagCmd := &cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE} + tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE}) // tag list — 对应 MCP 工具 query_user_tag_list // 无需传入参数,系统自动识别当前用户身份。 @@ -2075,7 +2075,7 @@ func newMinutesCommand() *cobra.Command { // 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。 // 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用 // callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。 - audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE} + audioMemoCmd := newGroupCommand(&cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}) audioMemoListCmd := &cobra.Command{ Use: "list", @@ -2179,12 +2179,12 @@ func newMinutesCommand() *cobra.Command { audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)") audioMemoCmd.AddCommand(audioMemoListCmd) - minutesCmd := &cobra.Command{ + minutesCmd := newGroupCommand(&cobra.Command{ Use: "minutes", Short: "AI 听记 / 会议纪要", Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`, RunE: groupRunE, - } + }) minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd) return minutesCmd } diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index c35c41cc..38e5a49f 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -369,11 +369,11 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { } func newOAAttachmentCommand() *cobra.Command { - attachmentCmd := &cobra.Command{ + attachmentCmd := newGroupCommand(&cobra.Command{ Use: "attachment", Short: "审批附件授权、上传、下载与链接管理", RunE: groupRunE, - } + }) downloadURLCmd := NewLeafCommand(LeafSpec{ Use: "download-url", @@ -724,14 +724,14 @@ func newOaCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "oa", Short: "OA 审批 / 同意 / 拒绝 / 撤销", Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`, RunE: groupRunE, - } + }) - approvalCmd := &cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE} + approvalCmd := newGroupCommand(&cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE}) approvalListPendingCmd := &cobra.Command{ Use: "list-pending", diff --git a/internal/helpers/permission_members_test.go b/internal/helpers/permission_members_test.go new file mode 100644 index 00000000..24f0a908 --- /dev/null +++ b/internal/helpers/permission_members_test.go @@ -0,0 +1,738 @@ +package helpers + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// 同步自闭源 MR 28965577(知识库/节点权限增删改查改造): +// 权限/成员 add/update/remove 支持 --members 新格式(USER/DEPT/CONVERSATION/TAG), +// list 支持 nextToken 翻页。以下用例覆盖 CLI → MCP 参数装配契约。 + +func TestCollectMembersParsesNewFormat(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + if err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", + "--members", `[{"type":"USER","id":"u1","roleId":"reader","corpId":"c1"},{"type":"TAG","id":"t1","roleId":"editor","corpId":"c1"}]`, + "--notify=false"); err != nil { + t.Fatalf("permission add --members: %v", err) + } + if caller.tool != "add_permission" { + t.Fatalf("tool=%q", caller.tool) + } + members, ok := caller.args["members"].([]map[string]any) + if !ok || len(members) != 2 { + t.Fatalf("members=%#v", caller.args["members"]) + } + if members[0]["roleId"] != "READER" || members[1]["roleId"] != "EDITOR" { + t.Fatalf("roleId normalize failed: %#v", members) + } + if notify, ok := caller.args["notify"].(bool); !ok || notify { + t.Fatalf("notify should be false when --notify=false: %#v", caller.args["notify"]) + } +} + +func TestCollectMembersValidation(t *testing.T) { + cases := []struct { + name string + members string + wantError string + }{ + {"invalid json", "[{", "JSON 解析失败"}, + {"empty array", "[]", "不能为空数组"}, + {"missing type", `[{"id":"u1","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 type"}, + {"missing id", `[{"type":"USER","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 id"}, + {"missing corpId", `[{"type":"USER","id":"u1","roleId":"READER"}]`, "需携带 corpId"}, + {"missing roleId", `[{"type":"USER","id":"u1","corpId":"c1"}]`, "缺少必填字段 roleId"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", "--members", tc.members) + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected error containing %q, got %v", tc.wantError, err) + } + }) + } +} + +func TestValidateMembersExclusivity(t *testing.T) { + t.Run("members and users are mutually exclusive", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", + "--users", "u1", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`) + if err == nil || !strings.Contains(err.Error(), "互斥") { + t.Fatalf("expected mutual exclusion error, got %v", err) + } + }) + t.Run("one of members or users is required", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1") + if err == nil || !strings.Contains(err.Error(), "之一") { + t.Fatalf("expected required error, got %v", err) + } + }) + t.Run("role is redundant with members", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", + "--role", "READER", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`) + if err == nil || !strings.Contains(err.Error(), "不需要 --role") { + t.Fatalf("expected no-role error, got %v", err) + } + }) +} + +func TestPermissionListPagination(t *testing.T) { + t.Run("drive permission list passes nextToken and pageSize", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", + "--limit", "50", "--next-token", "50"); err != nil { + t.Fatalf("permission list: %v", err) + } + if caller.tool != "list_permission" { + t.Fatalf("tool=%q", caller.tool) + } + if caller.args["pageSize"] != 50 { + t.Fatalf("pageSize=%#v", caller.args["pageSize"]) + } + if caller.args["nextToken"] != "50" { + t.Fatalf("nextToken=%#v", caller.args["nextToken"]) + } + }) + t.Run("doc permission list passes nextToken", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + if err := executePR868Command(t, newDocCommand(), "permission", "list", "--node", "n1", + "--next-token", "30"); err != nil { + t.Fatalf("doc permission list: %v", err) + } + if caller.args["nextToken"] != "30" { + t.Fatalf("nextToken=%#v", caller.args["nextToken"]) + } + }) + t.Run("wiki member list passes nextToken and pageSize", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + if err := executePR868Command(t, newWikiCommand(), "member", "list", "--workspace", "ws1", + "--limit", "50", "--next-token", "50"); err != nil { + t.Fatalf("wiki member list: %v", err) + } + if caller.tool != "list_member" { + t.Fatalf("tool=%q", caller.tool) + } + if caller.args["pageSize"] != 50 || caller.args["nextToken"] != "50" { + t.Fatalf("args=%#v", caller.args) + } + }) +} + +func TestPermissionRemoveWithMembers(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + // remove 是 destructive 入口:Safety confirmation=user_required,测试里 + // 注入根级 --yes 后直接确认执行(参数装配本身由下方断言验证)。 + root := newDriveCommand() + root.PersistentFlags().Bool("yes", false, "confirm high-risk operation") + if err := executePR868Command(t, root, "permission", "remove", "--node", "n1", + "--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes"); err != nil { + t.Fatalf("permission remove --members: %v", err) + } + if caller.tool != "remove_permission" { + t.Fatalf("tool=%q", caller.tool) + } + members, ok := caller.args["members"].([]map[string]any) + if !ok || len(members) != 1 || members[0]["id"] != "cid1" { + t.Fatalf("members=%#v", caller.args["members"]) + } + // remove 语义下 roleId 不应被要求 + if _, hasRole := members[0]["roleId"]; hasRole { + t.Fatalf("remove members should not require roleId: %#v", members[0]) + } +} + +func TestWikiMemberAddWithMembersRejectsOwner(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + err := executePR868Command(t, newWikiCommand(), "member", "add", "--workspace", "ws1", + "--members", `[{"type":"USER","id":"u1","roleId":"OWNER","corpId":"c1"}]`) + if err == nil || !strings.Contains(err.Error(), "OWNER") { + t.Fatalf("expected OWNER rejection, got %v", err) + } +} + +// ────────────────────────────────────────────────────── +// collectMembers / validateMembersExclusivity 单元级用例 +// 同步自内部 MR 28965577 补齐的覆盖:四类型混传、remove 语义、 +// 超过 30 个、corpId 按类型校验、--user 别名。 +// ────────────────────────────────────────────────────── + +// newMembersCmd 构造带 --members/--users/--user/--role 的最小命令, +// 与生产命令的 flag 注册保持一致。 +func newMembersCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("members", "", "") + cmd.Flags().String("users", "", "") + cmd.Flags().String("user", "", "") + cmd.Flags().String("role", "", "") + return cmd +} + +func TestCollectMembersMixedTypes(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"MANAGER","corpId":"c1"},{"type":"DEPT","id":"d1","roleId":"editor","corpId":"c1"},{"type":"CONVERSATION","id":"cid1","roleId":"READER"},{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`) + got, err := collectMembers(cmd, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 4 { + t.Fatalf("expected 4 members, got %d", len(got)) + } + if got[1]["roleId"] != "EDITOR" { + t.Errorf("member[1] roleId should be normalized to EDITOR, got %v", got[1]["roleId"]) + } + if got[0]["corpId"] != "c1" { + t.Errorf("corpId should be preserved, got %v", got[0]["corpId"]) + } +} + +func TestCollectMembersRemoveSemantics(t *testing.T) { + t.Run("remove only needs type and id", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"},{"type":"DEPT","id":"d1","corpId":"c1"}]`) + got, err := collectMembers(cmd, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 members, got %d", len(got)) + } + }) + t.Run("add still requires roleId", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"}]`) + if _, err := collectMembers(cmd, false); err == nil { + t.Fatal("expected error for missing roleId, got nil") + } + }) +} + +func TestCollectMembersOver30(t *testing.T) { + cmd := newMembersCmd() + members := "[" + for i := 0; i < 31; i++ { + if i > 0 { + members += "," + } + members += fmt.Sprintf(`{"type":"USER","id":"u%d","roleId":"READER","corpId":"c"}`, i) + } + members += "]" + _ = cmd.Flags().Set("members", members) + if _, err := collectMembers(cmd, false); err == nil { + t.Fatal("expected error for >30 members, got nil") + } +} + +func TestCollectMembersCorpIDByType(t *testing.T) { + t.Run("DEPT requires corpId", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"DEPT","id":"d1","roleId":"EDITOR"}]`) + if _, err := collectMembers(cmd, false); err == nil { + t.Fatal("expected error for DEPT missing corpId, got nil") + } + }) + t.Run("TAG requires corpId", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER"}]`) + if _, err := collectMembers(cmd, false); err == nil { + t.Fatal("expected error for TAG missing corpId, got nil") + } + }) + t.Run("CONVERSATION works without corpId", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"CONVERSATION","id":"cid1","roleId":"READER"}]`) + got, err := collectMembers(cmd, false) + if err != nil { + t.Fatalf("unexpected error for CONVERSATION without corpId: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 member, got %d", len(got)) + } + }) + t.Run("TAG with corpId passes", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`) + got, err := collectMembers(cmd, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 member, got %d", len(got)) + } + }) +} + +func TestValidateMembersExclusivityUserAlias(t *testing.T) { + t.Run("--user alias counts as users", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("user", "u1") + if err := validateMembersExclusivity(cmd); err != nil { + t.Errorf("unexpected error for --user alias: %v", err) + } + }) + t.Run("--user alias conflicts with --members", func(t *testing.T) { + cmd := newMembersCmd() + _ = cmd.Flags().Set("user", "u1") + _ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`) + if err := validateMembersExclusivity(cmd); err == nil { + t.Error("expected error when both --user and --members set, got nil") + } + }) +} + +// ────────────────────────────────────────────────────── +// isNoPermissionError — 新接口 forbidden.accessDenied 错误码 +// ────────────────────────────────────────────────────── + +func TestIsNoPermissionErrorAccessDenied(t *testing.T) { + t.Run("server code forbidden.accessDenied", func(t *testing.T) { + for _, key := range []string{"code", "errorCode", "server_error_code"} { + body := map[string]any{key: "forbidden.accessDenied"} + if !isNoPermissionError(body) { + t.Errorf("isNoPermissionError(%s=forbidden.accessDenied) = false, want true", key) + } + } + }) + t.Run("access-denied message text", func(t *testing.T) { + for _, msg := range []string{ + "需要您具备 EDITOR 及以上角色", + "需要您具备 MANAGER 及以上角色才能执行此操作", + "forbidden.accessDenied", + "Forbidden.AccessDenied", + } { + body := map[string]any{"errorMsg": msg} + if !isNoPermissionError(body) { + t.Errorf("isNoPermissionError(msg=%q) = false, want true", msg) + } + } + }) + t.Run("normal body is not permission error", func(t *testing.T) { + body := map[string]any{"success": true, "errorMsg": ""} + if isNoPermissionError(body) { + t.Error("isNoPermissionError(success body) = true, want false") + } + }) +} + +// ────────────────────────────────────────────────────── +// --notify bool flag 解析(NoArgs 防御 `--notify false` 空格形式) +// ────────────────────────────────────────────────────── + +// newNotifyCmd 构造与生产 add/update 命令一致的最小命令: +// --notify bool flag + Args: cobra.NoArgs。 +func newNotifyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "test", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("notify", false, "") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return cmd +} + +func TestNotifyFlagParsing(t *testing.T) { + t.Run("equals form sets false", func(t *testing.T) { + cmd := newNotifyCmd() + cmd.SetArgs([]string{"--notify=false"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, _ := cmd.Flags().GetBool("notify"); got { + t.Error("--notify=false: got true, want false") + } + if !cmd.Flags().Changed("notify") { + t.Error("--notify=false should mark flag as changed") + } + }) + t.Run("bare flag defaults true via NoOptDefVal", func(t *testing.T) { + cmd := newNotifyCmd() + cmd.SetArgs([]string{"--notify"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, _ := cmd.Flags().GetBool("notify"); !got { + t.Error("--notify (no value): got false, want true (NoOptDefVal)") + } + if !cmd.Flags().Changed("notify") { + t.Error("--notify should mark flag as changed") + } + }) + t.Run("space form rejected by NoArgs", func(t *testing.T) { + cmd := newNotifyCmd() + // "--notify false" 被 pflag 解析为 notify=true(NoOptDefVal)+ 位置参数 "false"。 + // Args: cobra.NoArgs 拒绝位置参数,防止用户误以为传了 false。 + cmd.SetArgs([]string{"--notify", "false"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for '--notify false' (space form) with NoArgs, got nil") + } + if got, _ := cmd.Flags().GetBool("notify"); !got { + t.Error("pflag should set notify=true via NoOptDefVal for bare --notify, got false") + } + }) + t.Run("omitted stays unchanged", func(t *testing.T) { + cmd := newNotifyCmd() + cmd.SetArgs([]string{}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Flags().Changed("notify") { + t.Error("notify should not be marked changed when omitted") + } + if got, _ := cmd.Flags().GetBool("notify"); got { + t.Error("omitted notify: got true, want false (default)") + } + }) +} + +// ────────────────────────────────────────────────────── +// pageSize 1..50 运行时边界(P2 意见:服务端 pageSize 上限 50) +// ────────────────────────────────────────────────────── + +func newPageSizeCmd(changed string, value int) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("limit", 30, "") + cmd.Flags().Int("max-results", 0, "") + _ = cmd.Flags().Set(changed, fmt.Sprintf("%d", value)) + return cmd +} + +func TestPermissionPageSizeFromFlagsBoundaries(t *testing.T) { + t.Run("rejects out-of-range values", func(t *testing.T) { + for _, flag := range []string{"limit", "max-results"} { + for _, size := range []int{-1, 0, 51, 100} { + if _, _, err := permissionPageSizeFromFlags(newPageSizeCmd(flag, size)); err == nil { + t.Errorf("--%s %d: expected error, got nil", flag, size) + } + } + } + }) + t.Run("accepts in-range values", func(t *testing.T) { + for _, size := range []int{1, 30, 50} { + got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", size)) + if err != nil { + t.Errorf("--limit %d: unexpected error %v", size, err) + continue + } + if !ok || got != size { + t.Errorf("--limit %d: got (%d, %v)", size, got, ok) + } + } + }) + t.Run("omitted returns ok=false", func(t *testing.T) { + // newPageSizeCmd 强制 Set(changed),先验证 Set 后 Changed 生效, + // 再用未设置任何 flag 的命令验证默认路径。 + if got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", 30)); err != nil || !ok || got != 30 { + t.Fatalf("setup sanity failed: got (%d, %v, %v), want (30, true, nil)", got, ok, err) + } + fresh := &cobra.Command{Use: "test"} + fresh.Flags().Int("limit", 30, "") + fresh.Flags().Int("max-results", 0, "") + if got, ok, err := permissionPageSizeFromFlags(fresh); err != nil || ok || got != 0 { + t.Errorf("omitted flags: got (%d, %v, %v), want (0, false, nil)", got, ok, err) + } + }) +} + +func TestPermissionListPageSizeRuntimeGuard(t *testing.T) { + type listCase struct { + name string + root func() *cobra.Command + path []string + nodeID []string + } + cases := []listCase{ + {"drive", newDriveCommand, []string{"permission", "list"}, []string{"--node", "n1"}}, + {"doc", newDocCommand, []string{"permission", "list"}, []string{"--node", "n1"}}, + {"wiki", newWikiCommand, []string{"member", "list"}, []string{"--workspace", "ws1"}}, + } + for _, lc := range cases { + t.Run(lc.name+" rejects --limit 51", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + args := append(append([]string{}, lc.path...), lc.nodeID...) + args = append(args, "--limit", "51") + err := executePR868Command(t, lc.root(), args...) + if err == nil || !strings.Contains(err.Error(), "1..50") { + t.Fatalf("expected 1..50 validation error, got %v", err) + } + if caller.calls != 0 { + t.Fatalf("list should not call MCP with invalid pageSize, called %d times", caller.calls) + } + }) + t.Run(lc.name+" accepts --limit 50", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + args := append(append([]string{}, lc.path...), lc.nodeID...) + args = append(args, "--limit", "50") + if err := executePR868Command(t, lc.root(), args...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if caller.args["pageSize"] != 50 { + t.Fatalf("pageSize=%#v, want 50", caller.args["pageSize"]) + } + }) + } +} + +// ────────────────────────────────────────────────────── +// add 的 --notify 声明默认值必须与实际透传行为一致 +// +// 三个 add 命令都用 `if Flags().Changed("notify")` 守卫来决定是否把 notify +// 写入 toolArgs,所以省略该 flag 时字段根本不会发给服务端,实测服务端按 +// 不通知处理。声明默认值因此必须是 false —— 若有人把它改回 true,help 会 +// 承诺一个从未透传的默认值(曾经的真实缺陷)。 +// ────────────────────────────────────────────────────── + +func TestPermissionAddNotifyDefaultMatchesWireBehavior(t *testing.T) { + type addCase struct { + name string + root func() *cobra.Command + path []string + target []string + } + cases := []addCase{ + {"drive", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}}, + {"doc", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}}, + {"wiki", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}}, + } + const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]` + + runAdd := func(t *testing.T, lc addCase, extra ...string) *scriptedToolCaller { + t.Helper() + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + args := append(append([]string{}, lc.path...), lc.target...) + args = append(args, "--members", members) + args = append(args, extra...) + if err := executePR868Command(t, lc.root(), args...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + return caller + } + + for _, lc := range cases { + t.Run(lc.name+" omitted sends no notify field", func(t *testing.T) { + caller := runAdd(t, lc) + if v, present := caller.args["notify"]; present { + t.Fatalf("notify must be absent from toolArgs when --notify is omitted, got %#v", v) + } + }) + t.Run(lc.name+" declared default is false", func(t *testing.T) { + // 声明默认值必须与上面的透传行为一致,否则 help 在说谎。 + root := lc.root() + leaf, _, err := root.Find(lc.path) + if err != nil { + t.Fatalf("find %v: %v", lc.path, err) + } + flag := leaf.Flags().Lookup("notify") + if flag == nil { + t.Fatal("notify flag not declared") + } + if flag.DefValue != "false" { + t.Errorf("notify DefValue=%q, want \"false\": 省略时不透传该字段,声明 true 会承诺一个未生效的默认值", flag.DefValue) + } + }) + t.Run(lc.name+" bare --notify opts in", func(t *testing.T) { + caller := runAdd(t, lc, "--notify") + if notify, ok := caller.args["notify"].(bool); !ok || !notify { + t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"]) + } + }) + t.Run(lc.name+" --notify=false is sent explicitly", func(t *testing.T) { + caller := runAdd(t, lc, "--notify=false") + notify, ok := caller.args["notify"].(bool) + if !ok || notify { + t.Fatalf("notify=%#v, want explicit false", caller.args["notify"]) + } + }) + } +} + +// ────────────────────────────────────────────────────── +// 服务端对已确认“空响应=写成功”契约的 permission/member update/remove +// 工具可能返回字面 "null"(操作成功但无返回数据)。CLI 仅对这几个 +// 工具将其渲染为空对象 {},避免 Agent/下游把 null 当作对象解析时失败; +// 其它工具的合法 null 保持原样输出,公共渲染器的机器输出契约不变。 +// ────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageNullToolResponseRendersEmptyObject(t *testing.T) { + // nullOnSuccessTools 集合内:null 适配为 {} + for _, format := range []string{"json", "raw"} { + t.Run("adapted_"+format, func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format} + installScriptedCaller(t, caller) + out := &bytes.Buffer{} + deps.Out.w = out + if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_permission", nil, false); err != nil { + t.Fatalf("null tool response should render as {}: %v", err) + } + if got := strings.TrimSpace(out.String()); got != "{}" { + t.Errorf("format %s renders %q, want {}", format, got) + } + }) + } + // 集合外的工具:null 原样输出,不再被公共渲染路径改写为 {} + for _, format := range []string{"json", "raw"} { + t.Run("passthrough_"+format, func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format} + installScriptedCaller(t, caller) + out := &bytes.Buffer{} + deps.Out.w = out + if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_node_permission", nil, false); err != nil { + t.Fatalf("null passthrough should not error: %v", err) + } + if got := strings.TrimSpace(out.String()); got != "null" { + t.Errorf("format %s renders %q, want null unchanged", format, got) + } + }) + } +} + +// ────────────────────────────────────────────────────── +// update / remove 的 --members 装配与 --users 解析失败分支 +// +// add 的 members+notify 透传已有 +// TestPermissionAddNotifyDefaultMatchesWireBehavior 覆盖;这里补齐 +// update(members + notify)与 doc/wiki remove(members)的同构分支, +// 以及各产品 add/update/remove 中 collectUserIDs 的错误路径: +// --users 传纯空白时 flagOrFallback 视为已提供(非空字符串), +// 但 parseCommentMentionIds 过滤空白后为空,collectUserIDs 必须报错 +// 而不是向服务端发送空 userIds。 +// ────────────────────────────────────────────────────── + +func TestCrossPlatformCoveragePermissionUpdateWithMembers(t *testing.T) { + type updateCase struct { + name string + root func() *cobra.Command + path []string + target []string + tool string + } + cases := []updateCase{ + {"drive", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"}, + {"doc", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"}, + {"wiki", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, "update_member"}, + } + const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]` + for _, uc := range cases { + t.Run(uc.name+" passes members and notify", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + args := append(append([]string{}, uc.path...), uc.target...) + args = append(args, "--members", members, "--notify") + if err := executePR868Command(t, uc.root(), args...); err != nil { + t.Fatalf("update --members: %v", err) + } + if caller.tool != uc.tool { + t.Fatalf("tool=%q, want %q", caller.tool, uc.tool) + } + got, ok := caller.args["members"].([]map[string]any) + if !ok || len(got) != 1 || got[0]["id"] != "u1" || got[0]["roleId"] != "READER" { + t.Fatalf("members=%#v", caller.args["members"]) + } + if notify, ok := caller.args["notify"].(bool); !ok || !notify { + t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"]) + } + }) + } +} + +func TestCrossPlatformCoveragePermissionRemoveWithMembersProducts(t *testing.T) { + // drive remove 已由 TestPermissionRemoveWithMembers 覆盖;这里补 doc/wiki。 + type removeCase struct { + name string + root func() *cobra.Command + path []string + target []string + tool string + } + cases := []removeCase{ + {"doc", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, "remove_permission"}, + {"wiki", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, "remove_member"}, + } + for _, rc := range cases { + t.Run(rc.name+" passes members", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + // remove 需用户确认(confirmation=user_required):注入根级 --yes。 + root := rc.root() + root.PersistentFlags().Bool("yes", false, "confirm high-risk operation") + args := append(append([]string{}, rc.path...), rc.target...) + args = append(args, "--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes") + if err := executePR868Command(t, root, args...); err != nil { + t.Fatalf("remove --members: %v", err) + } + if caller.tool != rc.tool { + t.Fatalf("tool=%q, want %q", caller.tool, rc.tool) + } + got, ok := caller.args["members"].([]map[string]any) + if !ok || len(got) != 1 || got[0]["id"] != "cid1" { + t.Fatalf("members=%#v", caller.args["members"]) + } + }) + } +} + +func TestCrossPlatformCoveragePermissionUsersBlankParseError(t *testing.T) { + type blankCase struct { + name string + root func() *cobra.Command + path []string + target []string + needRole bool + } + cases := []blankCase{ + {"drive add", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true}, + {"drive update", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true}, + {"drive remove", newDriveCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false}, + {"doc add", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true}, + {"doc update", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true}, + {"doc remove", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false}, + {"wiki add", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}, true}, + {"wiki update", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, true}, + {"wiki remove", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, false}, + } + for _, bc := range cases { + t.Run(bc.name+" rejects blank --users", func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + args := append(append([]string{}, bc.path...), bc.target...) + args = append(args, "--users", " ") + if bc.needRole { + // add/update 的旧格式分支在 collectUserIDs 之前校验必填 --role。 + args = append(args, "--role", "READER") + } + err := executePR868Command(t, bc.root(), args...) + if err == nil || !strings.Contains(err.Error(), "--users is required") { + t.Fatalf("expected --users is required error, got %v", err) + } + if caller.calls != 0 { + t.Fatalf("MCP must not be called when --users resolves empty, called %d times", caller.calls) + } + }) + } +} diff --git a/internal/helpers/permission_pagination_test.go b/internal/helpers/permission_pagination_test.go new file mode 100644 index 00000000..3e0c07af --- /dev/null +++ b/internal/helpers/permission_pagination_test.go @@ -0,0 +1,71 @@ +package helpers + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" +) + +// P2 评审要求(PR #1085):三个游标列表命令(drive/doc permission list、 +// wiki member list)必须声明 Contract.Pagination(cursor 分页,游标 flag 为 +// next-token),且 next-token 是该叶的 declared parameter。 +// +// wire 上的 pagination 投影由 output rollout 门控制(schema_runtime_registry +// 对未迁移统一结果 envelope 的命令不发布 result/pagination,避免 Schema 与 +// runtime 输出不一致)。本测试固化「声明已就绪」与「当前仍为 legacy 输出」 +// 两个事实;命令迁移 unified envelope 后 wire 投影自动生效,届时应更新此 +// 断言并用 dws schema 验证最终投影。 +// 命名携带 TestCrossPlatformCoverage 前缀:本测试覆盖本次改动的 +// Contract.Pagination 声明行,需在 macOS/Windows 平台覆盖率门禁上执行。 +func TestCrossPlatformCoveragePermissionListPaginationDeclaration(t *testing.T) { + cases := []struct { + name string + root *cobra.Command + path []string + }{ + {"drive permission list", newDriveCommand(), []string{"permission", "list"}}, + {"doc permission list", newDocCommand(), []string{"permission", "list"}}, + {"wiki member list", newWikiCommand(), []string{"member", "list"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, _, err := tc.root.Find(tc.path) + if err != nil || cmd == nil || cmd.Name() != tc.path[len(tc.path)-1] { + t.Fatalf("find leaf %v: cmd=%v err=%v", tc.path, cmd, err) + } + final, ok := contractfinal.RuntimeContractFinal(cmd) + if !ok { + t.Fatalf("missing RuntimeContractFinal on %v", tc.path) + } + pg := final.Pagination + if pg == nil { + t.Fatalf("Contract.Pagination not declared on %v", tc.path) + } + if pg.Kind != contract.PaginationKindCursor || pg.CursorParameter != "next-token" { + t.Fatalf("pagination = %+v, want cursor/next-token", pg) + } + if pg.MetaPath != contract.PaginationMetaPath || + pg.NextTokenPath != contract.PaginationNextTokenPath || + pg.EndpointExhaustedPath != contract.PaginationExhaustedPath { + t.Fatalf("pagination framework-owned paths not normalized: %+v", pg) + } + declared := false + for _, p := range final.Parameters { + if p.Name == "next-token" { + declared = true + break + } + } + if !declared { + t.Fatalf("cursor flag next-token must be a declared parameter on %v", tc.path) + } + if output.ActiveContract(cmd) != output.ContractLegacy { + t.Fatalf("%v migrated to unified result; update this test and verify wire projection via dws schema", tc.path) + } + }) + } +} diff --git a/internal/helpers/pr868_surfaces_coverage_test.go b/internal/helpers/pr868_surfaces_coverage_test.go index 5fc5d438..6d31ce26 100644 --- a/internal/helpers/pr868_surfaces_coverage_test.go +++ b/internal/helpers/pr868_surfaces_coverage_test.go @@ -206,8 +206,18 @@ func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) { if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil { t.Fatalf("permission list: %v", err) } - if caller.args["maxResults"] != 10 { - t.Fatalf("maxResults=%#v", caller.args["maxResults"]) + if caller.args["pageSize"] != 10 { + t.Fatalf("pageSize=%#v", caller.args["pageSize"]) + } + }) + t.Run("permission list next-token pagination", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + installScriptedCaller(t, caller) + if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--next-token", "50"); err != nil { + t.Fatalf("permission list --next-token: %v", err) + } + if caller.args["nextToken"] != "50" { + t.Fatalf("nextToken=%#v", caller.args["nextToken"]) } }) t.Run("cover file-id alias", func(t *testing.T) { diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index cb2979c9..3f8e2bb3 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -61,17 +61,17 @@ func newRecruitCommand() *cobra.Command { }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "recruit", Short: "钉钉招聘", Long: "查询和创建钉钉招聘中的职位信息。", RunE: groupRunE, - } - job := &cobra.Command{ + }) + job := newGroupCommand(&cobra.Command{ Use: "job", Short: "招聘职位管理", RunE: groupRunE, - } + }) job.AddCommand( newRecruitJobListCommand(), newRecruitJobGetCommand(), diff --git a/internal/helpers/report.go b/internal/helpers/report.go index fffbb505..d88168ef 100644 --- a/internal/helpers/report.go +++ b/internal/helpers/report.go @@ -64,7 +64,7 @@ func newReportCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "report", Aliases: []string{"log"}, Short: "钉钉日志(OA 周报应用 / 日志模版填报)", @@ -84,10 +84,10 @@ func newReportCommand() *cobra.Command { 别名:dws log 等价 dws report(注意:此处 log 特指 OA 周报应用,不是通用日志/记录)。`, RunE: groupRunE, - } + }) // === template subtree(template list 不变;新增 template get;template detail 转 deprecated alias)=== - templateCmd := &cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE}) templateListCmd := &cobra.Command{ Use: "list", @@ -179,7 +179,7 @@ func newReportCommand() *cobra.Command { templateCmd.AddCommand(templateListCmd, templateGetCmd, templateDetailCmd) // === entry subtree(单条日报操作 — get / stats / submit)=== - entryCmd := &cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE} + entryCmd := newGroupCommand(&cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE}) entryGetCmd := &cobra.Command{ Use: "get", @@ -386,9 +386,10 @@ func newReportCommand() *cobra.Command { addReportListFlags(inboxListCmd) inboxCmd.AddCommand(inboxListCmd) + newHybridGroupCommand(inboxCmd) // === outbox subtree(我发出的日报)=== - outboxCmd := &cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE} + outboxCmd := newGroupCommand(&cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE}) outboxListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 682c4f4f..fb0e9366 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -38,7 +38,7 @@ func newSheetCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newDeepGroupCommand(&cobra.Command{ Use: "sheet", Short: "钉钉表格管理", Long: `管理钉钉在线电子表格:创建表格、工作表管理、数据读写、单元格搜索、查找替换、单元格合并与取消合并、行列插入删除移动追加与属性更新、附件上传、浮动图片管理、筛选视图管理、下拉列表管理。 @@ -119,7 +119,7 @@ func newSheetCommand() *cobra.Command { dws sheet template list 获取表格模板列表 dws sheet template search 搜索表格模板 dws sheet template apply 应用表格模板创建新表格文档`, - } + }) // ── Build commands via factory functions ────────────────────────── workbookCmds := newWorkbookCmds() @@ -296,15 +296,6 @@ func newSheetCommand() *cobra.Command { attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint) } - // Guards for grouped parent commands - attachUnknownSubcommandGuard(root) - attachUnknownSubcommandGuard(rangeCmd) - attachUnknownSubcommandGuard(filterCmd) - attachUnknownSubcommandGuard(filterViewCmd) - attachUnknownSubcommandGuard(condFormatCmd) - attachUnknownSubcommandGuard(chartCmd) - attachUnknownSubcommandGuard(pivotTableCmd) - return root } @@ -315,73 +306,3 @@ func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHi } protectSheetMutationCommand(command, operation, targetHint) } - -// attachUnknownSubcommandGuard 为分组型命令挂上拼错子命令时的 did-you-mean 提示。 -// -// 背景:cobra 对父命令的 Args 校验发生在 ParseFlags 之后,而 pflag 默认把未知 flag -// 当作硬错误抛出。于是 `dws sheet read --sheet-id X` 会先报 `unknown flag: --sheet-id`, -// 真正的根因(read 不是 sheet 的直接子命令)被彻底掩盖;同时 `dws sheet reead` 会被 -// 当成位置参数静默吞掉、打印 help 后 exit=0,AI Agent 无法察觉命令执行失败。 -// -// 本函数通过三件套让分组命令在"没匹配到子命令"时给出明确的错误与建议: -// 1. FParseErrWhitelist.UnknownFlags=true —— pflag 不再因未知 flag 中断, -// 未知 flag 连同其值一起被静默消化; -// 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理; -// 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子 -// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回 cobra -// 自带的同级编辑距离建议;最终返回 error 以保证 exit!=0。 -// -// 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段 -// 精确匹配到的合法叶子命令。 -func attachUnknownSubcommandGuard(cmd *cobra.Command) { - cmd.Args = cobra.ArbitraryArgs - cmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true} - cmd.SilenceUsage = true - // cobra 仅在 root 自动把 SuggestionsMinimumDistance 兑成 2,子命令默认为 0, - // 会导致 `sheet range reead` 这样的同级近似拼写无法触发内置建议。 - if cmd.SuggestionsMinimumDistance <= 0 { - cmd.SuggestionsMinimumDistance = 2 - } - cmd.RunE = func(c *cobra.Command, args []string) error { - if len(args) == 0 { - return c.Help() - } - name := args[0] - var buf strings.Builder - fmt.Fprintf(&buf, "unknown command %q for %q", name, c.CommandPath()) - suggestions := deepSuggestSubcommand(c, name) - if len(suggestions) == 0 { - suggestions = c.SuggestionsFor(name) - } - if len(suggestions) > 0 { - buf.WriteString("\n\nDid you mean this?") - for _, s := range suggestions { - fmt.Fprintf(&buf, "\n\t%s %s", c.CommandPath(), s) - } - } - fmt.Fprintf(&buf, "\n\nRun '%s --help' for usage.", c.CommandPath()) - return fmt.Errorf("%s", buf.String()) - } -} - -// deepSuggestSubcommand 在所有后代命令里查找与 name 完全同名的可用子命令, -// 返回从 parent 出发的相对路径列表。用于把 `sheet read` 这样的平铺习惯引导到 -// 真实的深路径 `sheet range read`。 -func deepSuggestSubcommand(parent *cobra.Command, name string) []string { - var out []string - var walk func(c *cobra.Command, rel []string) - walk = func(c *cobra.Command, rel []string) { - for _, sub := range c.Commands() { - if !sub.IsAvailableCommand() { - continue - } - next := append(append([]string{}, rel...), sub.Name()) - if sub.Name() == name { - out = append(out, strings.Join(next, " ")) - } - walk(sub, next) - } - } - walk(parent, nil) - return out -} diff --git a/internal/helpers/sheet_chart.go b/internal/helpers/sheet_chart.go index 312e52d2..56958a81 100644 --- a/internal/helpers/sheet_chart.go +++ b/internal/helpers/sheet_chart.go @@ -120,7 +120,7 @@ func validateChartProperties(props map[string]any) error { // ─── Chart subcommands ────────────────────────────────────────────────────── func newChartCmd() *cobra.Command { - chartCmd := &cobra.Command{Use: "chart", Short: "浮动图表管理"} + chartCmd := newDeepGroupCommand(&cobra.Command{Use: "chart", Short: "浮动图表管理"}) // ── chart list ────────────────────────────────────────────────────── chartListCmd := &cobra.Command{ diff --git a/internal/helpers/sheet_comment.go b/internal/helpers/sheet_comment.go index b16ddf33..ddc961fe 100644 --- a/internal/helpers/sheet_comment.go +++ b/internal/helpers/sheet_comment.go @@ -7,12 +7,12 @@ import ( ) func newSheetCommentCmd() *cobra.Command { - commentCmd := &cobra.Command{ + commentCmd := newGroupCommand(&cobra.Command{ Use: "comment", Short: "表格评论 / 单元格评论管理", Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`, RunE: groupRunE, - } + }) commentListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_cond_format.go b/internal/helpers/sheet_cond_format.go index 21c911fe..5b503b48 100644 --- a/internal/helpers/sheet_cond_format.go +++ b/internal/helpers/sheet_cond_format.go @@ -9,7 +9,7 @@ import ( ) func newCondFormatCmd() *cobra.Command { - condFormatCmd := &cobra.Command{Use: "cond-format", Short: "条件格式管理"} + condFormatCmd := newDeepGroupCommand(&cobra.Command{Use: "cond-format", Short: "条件格式管理"}) condFormatListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_filter_view.go b/internal/helpers/sheet_filter_view.go index 75428354..25c65251 100644 --- a/internal/helpers/sheet_filter_view.go +++ b/internal/helpers/sheet_filter_view.go @@ -164,7 +164,7 @@ func runFilterViewGetCriteria(cmd *cobra.Command, _ []string) error { // ── filter + filter-view 命令定义 ────────────────────────────────────────────── func newFilterCmd() *cobra.Command { - filterCmd := &cobra.Command{Use: "filter", Short: "全局筛选管理"} + filterCmd := newDeepGroupCommand(&cobra.Command{Use: "filter", Short: "全局筛选管理"}) filterGetCmd := &cobra.Command{ Use: "get", @@ -523,7 +523,7 @@ func newFilterCmd() *cobra.Command { } func newFilterViewCmd() *cobra.Command { - filterViewCmd := &cobra.Command{Use: "filter-view", Short: "筛选视图管理"} + filterViewCmd := newDeepGroupCommand(&cobra.Command{Use: "filter-view", Short: "筛选视图管理"}) filterViewListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_float_image.go b/internal/helpers/sheet_float_image.go index 99cf85f5..1a15b774 100644 --- a/internal/helpers/sheet_float_image.go +++ b/internal/helpers/sheet_float_image.go @@ -15,18 +15,21 @@ func newFloatImageCmds() []*cobra.Command { 浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -使用流程: - 1. 先通过 media-upload 上传本地图片获取 resourceUrl - 2. 再通过 create-float-image 将图片以浮动方式放置到工作表上 +直接传 --file 可在一个命令中上传本地图片并创建浮动图片。 +已经通过 media-upload 获得 resourceUrl 时,也可以改传 --src;--file 与 --src 二选一。 --range 指定浮动图片锚定的单元格位置,使用 A1 表示法(如 A1、B3)。 --width / --height 为必填,单位像素,必须为正整数。 --offset-x / --offset-y 可选,表示相对锚点单元格左上角的偏移量(像素),默认 0。`, - Example: ` # 先上传图片获取 resourceUrl + Example: ` # 直接从本地文件创建 + dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \ + --file ./chart.png --range A1 --width 400 --height 300 + + # 高级用法:先上传图片获取 resourceUrl dws sheet media-upload --node NODE_ID --file ./chart.png # 输出: resourceUrl: /core/api/resources/img/xxxx... - # 再创建浮动图片(--src 传入 media-upload 返回的 resourceUrl) + # 再通过 --src 创建浮动图片 dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \ --src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300 @@ -34,7 +37,11 @@ func newFloatImageCmds() []*cobra.Command { dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \ --src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "node", "sheet-id", "src", "range"); err != nil { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { + return err + } + filePath, src, err := validateFloatImageCreateInput(cmd) + if err != nil { return err } width, err := cmd.Flags().GetInt("width") @@ -54,7 +61,6 @@ func newFloatImageCmds() []*cobra.Command { toolArgs := map[string]any{ "nodeId": mustGetFlag(cmd, "node"), "sheetId": mustGetFlag(cmd, "sheet-id"), - "src": mustGetFlag(cmd, "src"), "range": mustGetFlag(cmd, "range"), "width": width, "height": height, @@ -73,7 +79,11 @@ func newFloatImageCmds() []*cobra.Command { } toolArgs["offsetY"] = oy } - return callMCPTool("create_float_image", toolArgs) + if filePath != "" { + return runFloatImageFileMode(cmd, "create_float_image", filePath, toolArgs) + } + toolArgs["src"] = src + return callMCPToolContext(cmd.Context(), "create_float_image", toolArgs) }, } DeclareLeafMetadata(createFloatImageCmd, LeafSpec{ @@ -89,26 +99,30 @@ func newFloatImageCmds() []*cobra.Command { CLIPath: "sheet create-float-image", PrimaryCLIPath: "sheet create-float-image", }, - Description: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。", + Description: "从本地文件或已上传的 resourceUrl 创建浮动图片。", + DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false}, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_float_image"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。", + AgentSummary: "从本地文件或已上传的 resourceUrl 创建浮动图片。", UseWhen: []string{"需要在单元格上方悬浮图片、不占用单元格内容时"}, AvoidWhen: []string{"单元格内嵌图片用 write-image;更新/删除浮动图用 update/delete-float-image"}, Examples: []string{"dws sheet create-float-image --node --sheet-id --src \"/core/api/resources/img/...\" --range A1 --width 400 --height 300"}, }, Parameters: []contract.ParamDecl{ {Name: "node", Property: "nodeId"}, + {Name: "file", Required: boolPtr(false), RequiredWhen: "exactly one of --file or --src must be provided"}, + {Name: "src", Property: "src", Required: boolPtr(false)}, }, }, }) createFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") createFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") - createFloatImageCmd.Flags().String("src", "", "图片资源路径,通过 media-upload 获取的 resourceUrl (必填)") + createFloatImageCmd.Flags().String("file", "", "本地图片文件路径,与 --src 二选一") + createFloatImageCmd.Flags().String("src", "", "通过 media-upload 获取的 resourceUrl,与 --file 二选一") createFloatImageCmd.Flags().String("range", "", "锚点单元格,A1 表示法,如 A1、B3 (必填)") createFloatImageCmd.Flags().Int("width", 0, "图片宽度,像素 (必填)") createFloatImageCmd.Flags().Int("height", 0, "图片高度,像素 (必填)") @@ -217,8 +231,8 @@ floatImageId 可通过 list-float-images 获取。`, Short: "更新浮动图片属性", Long: `更新钉钉表格指定工作表中浮动图片的属性。 -可更新的属性包括:图片资源路径(src)、锚点位置、尺寸、偏移量。 -至少需要传入一个更新字段(--src / --range / --width / --height / --offset-x / --offset-y)。 +可直接传 --file 上传本地图片并替换浮动图片,也可传已上传图片的 --src。 +至少需要传入一个更新字段(--file / --src / --range / --width / --height / --offset-x / --offset-y),且 --file 与 --src 不能同时使用。 floatImageId 可通过 list-float-images 获取。`, Example: ` # 移动浮动图片到新位置 dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --range C5 @@ -226,10 +240,15 @@ floatImageId 可通过 list-float-images 获取。`, # 调整尺寸 dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --width 600 --height 400 - # 替换图片(需先 media-upload 新图片获取 resourceUrl) + # 直接用本地文件替换图片 + dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \ + --file ./replacement.png + + # 高级用法:用 media-upload 返回的 resourceUrl 替换图片 dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \ --src "/core/api/resources/img/xxxx..."`, RunE: func(cmd *cobra.Command, args []string) error { + fileChanged := cmd.Flags().Changed("file") srcChanged := cmd.Flags().Changed("src") rangeChanged := cmd.Flags().Changed("range") widthChanged := cmd.Flags().Changed("width") @@ -237,8 +256,17 @@ floatImageId 可通过 list-float-images 获取。`, oxChanged := cmd.Flags().Changed("offset-x") oyChanged := cmd.Flags().Changed("offset-y") - if !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged { - return fmt.Errorf("--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个") + if !fileChanged && !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged { + return fmt.Errorf("%s", floatImageUpdateFieldsError) + } + filePath, src, err := validateFloatImageUpdateInput(cmd) + if err != nil { + return err + } + if fileChanged { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "float-image-id"); err != nil { + return err + } } toolArgs := map[string]any{ @@ -247,7 +275,7 @@ floatImageId 可通过 list-float-images 获取。`, "floatImageId": mustGetFlag(cmd, "float-image-id"), } if srcChanged { - toolArgs["src"], _ = cmd.Flags().GetString("src") + toolArgs["src"] = src } if rangeChanged { toolArgs["range"], _ = cmd.Flags().GetString("range") @@ -280,7 +308,10 @@ floatImageId 可通过 list-float-images 获取。`, } toolArgs["offsetY"] = oy } - return callMCPTool("update_float_image", toolArgs) + if fileChanged { + return runFloatImageFileMode(cmd, "update_float_image", filePath, toolArgs) + } + return callMCPToolContext(cmd.Context(), "update_float_image", toolArgs) }, } DeclareLeafMetadata(updateFloatImageCmd, LeafSpec{ @@ -297,6 +328,7 @@ floatImageId 可通过 list-float-images 获取。`, PrimaryCLIPath: "sheet update-float-image", }, Description: "更新浮动图片锚点、尺寸、偏移或资源路径。", + DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false}, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", @@ -310,12 +342,15 @@ floatImageId 可通过 list-float-images 获取。`, }, Parameters: []contract.ParamDecl{ {Name: "node", Property: "nodeId"}, + {Name: "file", Required: boolPtr(false)}, + {Name: "src", Property: "src", Required: boolPtr(false)}, }, }, }) updateFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") updateFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") updateFloatImageCmd.Flags().String("float-image-id", "", "浮动图片 ID (必填)") + updateFloatImageCmd.Flags().String("file", "", "用于替换浮动图片的本地图片路径,与 --src 不能同时使用") updateFloatImageCmd.Flags().String("src", "", "新的图片资源路径,通过 media-upload 获取的 resourceUrl") updateFloatImageCmd.Flags().String("range", "", "新的锚点单元格,A1 表示法") updateFloatImageCmd.Flags().Int("width", 0, "新的图片宽度,像素") diff --git a/internal/helpers/sheet_float_image_file.go b/internal/helpers/sheet_float_image_file.go new file mode 100644 index 00000000..6e9ba0d9 --- /dev/null +++ b/internal/helpers/sheet_float_image_file.go @@ -0,0 +1,333 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +const ( + floatImageCreateInputError = "--file 与 --src 必须且只能提供一个" + floatImageUpdateInputError = "--file 与 --src 不能同时提供" + floatImageUpdateFieldsError = "--file、--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个" + floatImagePlannedResource = "" + floatImageUploadTimeout = 5 * time.Minute +) + +type floatImageLocalFile struct { + file *os.File + path string + name string + mimeType string + size int64 +} + +type floatImageUploadInfo struct { + uploadURL string + resourceID string + resourceURL string +} + +func validateFloatImageCreateInput(cmd *cobra.Command) (string, string, error) { + filePath, _ := cmd.Flags().GetString("file") + src, _ := cmd.Flags().GetString("src") + if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" { + return "", "", fmt.Errorf("--file 不能为空") + } + hasFile := strings.TrimSpace(filePath) != "" + hasSrc := strings.TrimSpace(src) != "" + if hasFile == hasSrc { + return "", "", fmt.Errorf("%s", floatImageCreateInputError) + } + return filePath, src, nil +} + +func validateFloatImageUpdateInput(cmd *cobra.Command) (string, string, error) { + filePath, _ := cmd.Flags().GetString("file") + src, _ := cmd.Flags().GetString("src") + if cmd.Flags().Changed("file") && cmd.Flags().Changed("src") { + return "", "", fmt.Errorf("%s", floatImageUpdateInputError) + } + if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" { + return "", "", fmt.Errorf("--file 不能为空") + } + return filePath, src, nil +} + +func runFloatImageFileMode(cmd *cobra.Command, toolName, filePath string, toolArgs map[string]any) error { + local, err := openFloatImageLocalFile(filePath) + if err != nil { + return err + } + defer local.file.Close() + + if deps.Caller.DryRun() { + return printFloatImageFileDryRun(toolName, toolArgs, local) + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + jsonMode := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取浮动图片上传凭证 (%s, %d bytes)...", local.name, local.size)) + } + uploadInfo, err := resolveFloatImageUploadInfo(ctx, toolArgs["nodeId"], local) + if err != nil { + return err + } + if !jsonMode { + deps.Out.PrintKeyValue("resourceId", uploadInfo.resourceID) + deps.Out.PrintKeyValue("resourceUrl", uploadInfo.resourceURL) + deps.Out.PrintInfo("[2/3] 上传本地图片...") + } + client := newFloatImageUploadClient() + if err := putFloatImageFileWithClient(ctx, client, uploadInfo.uploadURL, local); err != nil { + return err + } + + if !jsonMode { + deps.Out.PrintInfo("[3/3] 提交浮动图片变更...") + } + toolArgs["src"] = uploadInfo.resourceURL + if err := callMCPToolContext(ctx, toolName, toolArgs); err != nil { + return fmt.Errorf("图片已上传(resourceId=%s,resourceUrl=%s),但浮动图片操作失败;已有重试可能造成重复创建或更新,请先用 list/get/UI 核验后再重试: %w", uploadInfo.resourceID, uploadInfo.resourceURL, err) + } + return nil +} + +func openFloatImageLocalFile(path string) (*floatImageLocalFile, error) { + return openFloatImageLocalFileWith(path, os.Stat, os.Open) +} + +func openFloatImageLocalFileWith(path string, stat func(string) (os.FileInfo, error), open func(string) (*os.File, error)) (*floatImageLocalFile, error) { + info, err := stat(path) + if err != nil { + return nil, fmt.Errorf("无法读取 --file %q: %w", path, err) + } + if err := validateFloatImageFileInfo(info); err != nil { + return nil, err + } + file, err := open(path) + if err != nil { + return nil, fmt.Errorf("无法打开 --file %q: %w", path, err) + } + openedInfo, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("无法读取已打开的 --file %q: %w", path, err) + } + if err := validateFloatImageFileInfo(openedInfo); err != nil { + _ = file.Close() + return nil, err + } + return &floatImageLocalFile{ + file: file, + path: path, + name: filepath.Base(path), + mimeType: inferMimeType(filepath.Base(path)), + size: openedInfo.Size(), + }, nil +} + +func validateFloatImageFileInfo(info os.FileInfo) error { + if !info.Mode().IsRegular() { + return fmt.Errorf("--file 必须是普通文件") + } + if info.Size() <= 0 { + return fmt.Errorf("--file 不能为空文件") + } + return nil +} + +func printFloatImageFileDryRun(toolName string, toolArgs map[string]any, local *floatImageLocalFile) error { + arguments := make(map[string]any, len(toolArgs)+1) + for key, value := range toolArgs { + arguments[key] = value + } + arguments["src"] = floatImagePlannedResource + stages := []string{"validate_local_file", "get_upload_credentials", "upload_file", toolName} + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "tool": toolName, + "arguments": arguments, + "local_file": map[string]any{ + "path": local.path, "name": local.name, "mime_type": local.mimeType, "size": local.size, + }, + "stages": stages, + }) + } + deps.Out.PrintKeyValue("操作", "上传本地图片并提交浮动图片变更") + deps.Out.PrintKeyValue("文件", local.path) + deps.Out.PrintKeyValue("名称", local.name) + deps.Out.PrintKeyValue("类型", local.mimeType) + deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", local.size)) + deps.Out.PrintKeyValue("Tool", toolName) + deps.Out.PrintKeyValue("阶段", strings.Join(stages, " -> ")) + return nil +} + +func resolveFloatImageUploadInfo(ctx context.Context, nodeID any, local *floatImageLocalFile) (floatImageUploadInfo, error) { + result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{ + "nodeId": nodeID, + "fileName": local.name, + "fileSize": float64(local.size), + "mimeType": local.mimeType, + }) + if err != nil { + return floatImageUploadInfo{}, WrapError(err) + } + return parseFloatImageUploadInfo(result) +} + +func parseFloatImageUploadInfo(result *edition.ToolResult) (floatImageUploadInfo, error) { + if result == nil { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + text := "" + for _, content := range result.Content { + if content.Type == "text" && strings.TrimSpace(content.Text) != "" { + text = content.Text + break + } + } + if text == "" { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + type credential struct { + UploadURL string `json:"uploadUrl"` + ResourceID string `json:"resourceId"` + ResourceURL string `json:"resourceUrl"` + } + var envelope struct { + credential + Result *credential `json:"result"` + } + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + parsed := envelope.credential + if envelope.Result != nil { + parsed = *envelope.Result + } + parsed.UploadURL = strings.TrimSpace(parsed.UploadURL) + parsed.ResourceID = strings.TrimSpace(parsed.ResourceID) + parsed.ResourceURL = strings.TrimSpace(parsed.ResourceURL) + if parsed.UploadURL == "" || !validFloatImageResourceID(parsed.ResourceID) || !validFloatImageResourceURL(parsed.ResourceURL) { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + if err := validateFloatImageUploadURL(parsed.UploadURL); err != nil { + return floatImageUploadInfo{}, err + } + return floatImageUploadInfo{uploadURL: parsed.UploadURL, resourceID: parsed.ResourceID, resourceURL: parsed.ResourceURL}, nil +} + +func invalidFloatImageCredentialError() error { + return &CLIError{ + Code: CodeMCPToolError, + Message: "获取浮动图片上传凭证失败:响应格式无效", + Suggestion: "请重试;若持续失败,请检查 doc/get_doc_attachment_upload_info 服务", + Operation: "doc/get_doc_attachment_upload_info", + } +} + +func validFloatImageResourceID(value string) bool { + return value != "" && len(value) <= 4096 && strings.IndexFunc(value, unicode.IsControl) < 0 +} + +func validFloatImageResourceURL(value string) bool { + if value == "" || len(value) > 8192 || strings.IndexFunc(value, unicode.IsControl) >= 0 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") { + return false + } + parsed, err := url.Parse(value) + return err == nil && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == "" +} + +// Keep this policy aligned with internal/transport.Client.isEndpointTrusted. +// It stays Sheet-private in V1 so local-file float-image support does not +// refactor the shared transport boundary selected by unrelated products. +func validateFloatImageUploadURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return invalidFloatImageUploadURLError() + } + if strings.EqualFold(parsed.Scheme, "https") { + return nil + } + if !strings.EqualFold(parsed.Scheme, "http") || os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" || !isFloatImageLoopbackHost(parsed.Hostname()) { + return invalidFloatImageUploadURLError() + } + return nil +} + +func isFloatImageLoopbackHost(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func invalidFloatImageUploadURLError() error { + return &CLIError{ + Code: CodeInvalidParam, + Message: "浮动图片上传地址无效或不受信任", + Suggestion: "上传地址必须是 HTTPS;本地开发仅允许显式开启的 loopback HTTP", + Operation: "sheet/float-image-upload", + } +} + +func newFloatImageUploadClient() *http.Client { + return &http.Client{ + Timeout: floatImageUploadTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// This intentionally does not reuse doc.defaultHTTPPutFile or localio.PutFile: +// both have incompatible retry/status/host behavior, and the Doc helper can +// copy a signed URL or response body into errors. Keep this fixed-message PUT +// until a shared secure-upload contract can replace all upload implementations. +func putFloatImageFileWithClient(ctx context.Context, client *http.Client, rawURL string, local *floatImageLocalFile) error { + if client == nil { + return fmt.Errorf("浮动图片文件上传失败:HTTP 客户端不可用") + } + if _, err := local.file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("浮动图片文件上传失败:无法读取本地文件") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, local.file) + if err != nil { + return fmt.Errorf("浮动图片文件上传失败:无法创建请求") + } + request.ContentLength = local.size + request.Header.Set("Content-Type", local.mimeType) + response, err := client.Do(request) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("浮动图片文件上传已取消: %w", ctxErr) + } + if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) { + return fmt.Errorf("浮动图片文件上传失败:已超过传输时限") + } + return fmt.Errorf("浮动图片文件上传失败:网络请求失败") + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) + if response.StatusCode != http.StatusOK { + return fmt.Errorf("浮动图片文件上传失败:HTTP %d", response.StatusCode) + } + return nil +} diff --git a/internal/helpers/sheet_float_image_file_fifo_test.go b/internal/helpers/sheet_float_image_file_fifo_test.go new file mode 100644 index 00000000..a8fec8ed --- /dev/null +++ b/internal/helpers/sheet_float_image_file_fifo_test.go @@ -0,0 +1,20 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package helpers + +import ( + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestCrossPlatformCoverageFloatImageFIFORejectedBeforeOpen(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.fifo") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatalf("mkfifo: %v", err) + } + if _, err := openFloatImageLocalFile(path); err == nil || !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("FIFO error = %v", err) + } +} diff --git a/internal/helpers/sheet_float_image_file_test.go b/internal/helpers/sheet_float_image_file_test.go new file mode 100644 index 00000000..95282895 --- /dev/null +++ b/internal/helpers/sheet_float_image_file_test.go @@ -0,0 +1,695 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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 floatImageToolCall struct { + product string + tool string + args map[string]any +} + +type floatImageTestCaller struct { + dryRun bool + format string + calls []floatImageToolCall + call func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) +} + +func (c *floatImageTestCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + copyArgs := make(map[string]any, len(args)) + for key, value := range args { + copyArgs[key] = value + } + index := len(c.calls) + c.calls = append(c.calls, floatImageToolCall{product: product, tool: tool, args: copyArgs}) + if c.call != nil { + return c.call(ctx, product, tool, args, index) + } + return floatImageTextResult(`{"floatImage":{"id":"fi-1"}}`), nil +} + +func (c *floatImageTestCaller) Format() string { + if c.format == "" { + return "json" + } + return c.format +} + +func (c *floatImageTestCaller) DryRun() bool { return c.dryRun } +func (*floatImageTestCaller) Fields() string { return "" } +func (*floatImageTestCaller) JQ() string { return "" } + +type floatImageRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn floatImageRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func floatImageTextResult(text string) *edition.ToolResult { + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} +} + +func floatImageCredentialResult(uploadURL string, nested bool) *edition.ToolResult { + credential := map[string]any{ + "uploadUrl": uploadURL, "resourceId": "rid-1", "resourceUrl": "/core/api/resources/img/rid-1", + } + payload := any(credential) + if nested { + payload = map[string]any{"result": credential} + } + raw, _ := json.Marshal(payload) + return floatImageTextResult(string(raw)) +} + +func floatImageCommandForTest(t *testing.T, name string) *cobra.Command { + t.Helper() + for _, command := range newFloatImageCmds() { + if command.Name() == name { + return command + } + } + t.Fatalf("missing float-image command %s", name) + return nil +} + +func setFloatImageFlags(t *testing.T, command *cobra.Command, values map[string]string) { + t.Helper() + for name, value := range values { + if err := command.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } +} + +func installFloatImageDeps(t *testing.T, caller *floatImageTestCaller, output io.Writer) { + t.Helper() + if output == nil { + output = io.Discard + } + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: NewFormatterWithWriters(output, io.Discard)}) + previousArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = previousArgs }) +} + +func writeFloatImageFixture(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func openFloatImageFixture(t *testing.T, path string) *floatImageLocalFile { + t.Helper() + local, err := openFloatImageLocalFile(path) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + t.Cleanup(func() { _ = local.file.Close() }) + return local +} + +func TestCrossPlatformCoverageFloatImageInputValidation(t *testing.T) { + create := floatImageCommandForTest(t, "create-float-image") + if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) { + t.Fatalf("missing create input error = %v", err) + } + setFloatImageFlags(t, create, map[string]string{"file": "a.png", "src": "/img/a"}) + if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) { + t.Fatalf("conflicting create input error = %v", err) + } + createEmptyFile := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, createEmptyFile, map[string]string{"file": " ", "src": "/img/a"}) + if _, _, err := validateFloatImageCreateInput(createEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") { + t.Fatalf("empty create file error = %v", err) + } + + fileOnly := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, fileOnly, map[string]string{"file": " a.png "}) + if file, src, err := validateFloatImageCreateInput(fileOnly); err != nil || file != " a.png " || src != "" { + t.Fatalf("file-only input = %q/%q/%v", file, src, err) + } + srcOnly := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, srcOnly, map[string]string{"src": " /img/a "}) + if file, src, err := validateFloatImageCreateInput(srcOnly); err != nil || file != "" || src != " /img/a " { + t.Fatalf("src-only input = %q/%q/%v", file, src, err) + } + + updateBoth := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, updateBoth, map[string]string{"file": "a.png", "src": "/img/a"}) + if _, _, err := validateFloatImageUpdateInput(updateBoth); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) { + t.Fatalf("conflicting update input error = %v", err) + } + updateEmptyFile := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, updateEmptyFile, map[string]string{"file": " "}) + if _, _, err := validateFloatImageUpdateInput(updateEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") { + t.Fatalf("empty update file error = %v", err) + } + updateSrc := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, updateSrc, map[string]string{"src": ""}) + if file, src, err := validateFloatImageUpdateInput(updateSrc); err != nil || file != "" || src != "" { + t.Fatalf("src update compatibility = %q/%q/%v", file, src, err) + } +} + +func TestCrossPlatformCoverageFloatImageLocalFileValidation(t *testing.T) { + validPath := writeFloatImageFixture(t, "chart.png", "png-data") + local, err := openFloatImageLocalFile(validPath) + if err != nil { + t.Fatal(err) + } + if local.name != "chart.png" || local.mimeType != "image/png" || local.size != int64(len("png-data")) { + t.Fatalf("local metadata = %#v", local) + } + _ = local.file.Close() + + if _, err := openFloatImageLocalFile(filepath.Join(t.TempDir(), "missing.png")); err == nil || !strings.Contains(err.Error(), "无法读取") { + t.Fatalf("missing file error = %v", err) + } + if _, err := openFloatImageLocalFile(t.TempDir()); err == nil || !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("directory error = %v", err) + } + emptyPath := writeFloatImageFixture(t, "empty.png", "") + if _, err := openFloatImageLocalFile(emptyPath); err == nil || !strings.Contains(err.Error(), "不能为空文件") { + t.Fatalf("empty error = %v", err) + } + + validInfo, err := os.Stat(validPath) + if err != nil { + t.Fatal(err) + } + statValid := func(string) (os.FileInfo, error) { return validInfo, nil } + openFailure := errors.New("open sentinel") + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return nil, openFailure }); !errors.Is(err, openFailure) { + t.Fatalf("open error = %v", err) + } + closed, err := os.Open(validPath) + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return closed, nil }); err == nil || !strings.Contains(err.Error(), "已打开") { + t.Fatalf("opened stat error = %v", err) + } + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(t.TempDir()) }); err == nil || !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("descriptor type error = %v", err) + } + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(emptyPath) }); err == nil || !strings.Contains(err.Error(), "不能为空文件") { + t.Fatalf("descriptor size error = %v", err) + } +} + +func TestCrossPlatformCoverageFloatImageCredentialParsingAndURLPolicy(t *testing.T) { + invalidResults := []*edition.ToolResult{ + nil, + {}, + {Content: []edition.ContentBlock{{Type: "image", Text: "credential-secret"}}}, + floatImageTextResult("{"), + floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid"}`), + floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"bad\nvalue","resourceUrl":"/core/api/resources/img/rid"}`), + floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid","resourceUrl":"https://resource.invalid/rid"}`), + floatImageTextResult(`{"uploadUrl":"http://example.com/signed-secret","resourceId":"rid","resourceUrl":"/core/api/resources/img/rid"}`), + } + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "") + for index, result := range invalidResults { + _, err := parseFloatImageUploadInfo(result) + if err == nil { + t.Fatalf("invalid result %d succeeded", index) + } + if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), "credential-secret") { + t.Fatalf("invalid result %d leaked secret: %v", index, err) + } + } + + top := floatImageTextResult(`{"uploadUrl":"https://upload.invalid/path?signature=secret","resourceId":"rid-top","resourceUrl":"/core/api/resources/img/top"}`) + info, err := parseFloatImageUploadInfo(top) + if err != nil || info.resourceID != "rid-top" || info.resourceURL != "/core/api/resources/img/top" { + t.Fatalf("top-level credential = %#v, %v", info, err) + } + nested := &edition.ToolResult{Content: []edition.ContentBlock{ + {Type: "text", Text: " "}, + {Type: "image", Text: "ignored"}, + {Type: "text", Text: `{"result":{"uploadUrl":"https://upload.invalid/nested","resourceId":"rid-nested","resourceUrl":"/core/api/resources/img/nested"}}`}, + }} + info, err = parseFloatImageUploadInfo(nested) + if err != nil || info.resourceID != "rid-nested" { + t.Fatalf("nested credential = %#v, %v", info, err) + } + + for _, value := range []string{"", "//host/path", "/path?secret=x", "/path#fragment", "/bad\npath"} { + if validFloatImageResourceURL(value) { + t.Errorf("resource URL %q unexpectedly valid", value) + } + } + if !validFloatImageResourceURL("/core/api/resources/img/ok") || validFloatImageResourceID("") || validFloatImageResourceID("bad\x1bvalue") { + t.Fatal("resource identifier validation mismatch") + } + + for _, rawURL := range []string{"not a URL", "/relative", "https://user:pass@example.com/path", "https://example.com/path#fragment", "ftp://example.com/path", "http://example.com/path"} { + if err := validateFloatImageUploadURL(rawURL); err == nil { + t.Errorf("upload URL %q unexpectedly valid", rawURL) + } + } + if err := validateFloatImageUploadURL("https://example.com/path?signature=secret"); err != nil { + t.Fatalf("HTTPS URL rejected: %v", err) + } + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + for _, rawURL := range []string{"http://localhost/path", "http://127.0.0.1/path", "http://[::1]/path"} { + if err := validateFloatImageUploadURL(rawURL); err != nil { + t.Errorf("loopback URL %q rejected: %v", rawURL, err) + } + } + if err := validateFloatImageUploadURL("http://192.0.2.1/path"); err == nil || isFloatImageLoopbackHost("not-an-ip") || isFloatImageLoopbackHost("127.0.0.2") { + t.Fatalf("non-loopback policy mismatch: %v", err) + } +} + +func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) { + path := writeFloatImageFixture(t, "chart.png", "streamed-image") + + t.Run("nil client", func(t *testing.T) { + local := openFloatImageFixture(t, path) + if err := putFloatImageFileWithClient(context.Background(), nil, "https://upload.invalid", local); err == nil { + t.Fatal("nil client succeeded") + } + }) + t.Run("closed file", func(t *testing.T) { + local := openFloatImageFixture(t, path) + _ = local.file.Close() + if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, "https://upload.invalid", local); err == nil || !strings.Contains(err.Error(), "无法读取本地文件") { + t.Fatalf("closed file error = %v", err) + } + }) + t.Run("invalid request", func(t *testing.T) { + local := openFloatImageFixture(t, path) + if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, ":", local); err == nil || !strings.Contains(err.Error(), "无法创建请求") { + t.Fatalf("request error = %v", err) + } + }) + t.Run("transport secret redacted", func(t *testing.T) { + local := openFloatImageFixture(t, path) + client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport signed-secret https://upload.invalid") + })} + err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/signed-secret", local) + if err == nil || strings.Contains(err.Error(), "signed-secret") || !strings.Contains(err.Error(), "网络请求失败") { + t.Fatalf("transport error = %v", err) + } + }) + t.Run("cancel", func(t *testing.T) { + local := openFloatImageFixture(t, path) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + client := &http.Client{Transport: floatImageRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return nil, request.Context().Err() + })} + err := putFloatImageFileWithClient(ctx, client, "https://upload.invalid/path", local) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error = %v", err) + } + }) + t.Run("client deadline", func(t *testing.T) { + local := openFloatImageFixture(t, path) + client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + })} + err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/path", local) + if err == nil || !strings.Contains(err.Error(), "超过传输时限") { + t.Fatalf("deadline error = %v", err) + } + }) + t.Run("non-200 body redacted", func(t *testing.T) { + local := openFloatImageFixture(t, path) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(response, "response-body-secret") + })) + defer server.Close() + err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL+"/signed-secret", local) + if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "response-body-secret") || strings.Contains(err.Error(), "signed-secret") { + t.Fatalf("status error = %v", err) + } + }) + t.Run("stream success", func(t *testing.T) { + local := openFloatImageFixture(t, path) + var method, contentType, body string + var contentLength int64 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + method = request.Method + contentType = request.Header.Get("Content-Type") + contentLength = request.ContentLength + raw, _ := io.ReadAll(request.Body) + body = string(raw) + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + if err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL, local); err != nil { + t.Fatal(err) + } + if method != http.MethodPut || contentType != "image/png" || contentLength != int64(len("streamed-image")) || body != "streamed-image" { + t.Fatalf("request = %s %s %d %q", method, contentType, contentLength, body) + } + }) + t.Run("redirect not followed", func(t *testing.T) { + local := openFloatImageFixture(t, path) + destinationCalls := 0 + destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { destinationCalls++ })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Location", destination.URL+"/location-secret") + response.WriteHeader(http.StatusFound) + })) + defer source.Close() + client := newFloatImageUploadClient() + if client.Timeout != floatImageUploadTimeout { + t.Fatalf("timeout = %v", client.Timeout) + } + err := putFloatImageFileWithClient(context.Background(), client, source.URL, local) + if err == nil || !strings.Contains(err.Error(), "HTTP 302") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 { + t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls) + } + }) +} + +func TestCrossPlatformCoverageFloatImageDryRun(t *testing.T) { + path := writeFloatImageFixture(t, "chart.png", "image") + for _, format := range []string{"json", "table"} { + t.Run(format, func(t *testing.T) { + caller := &floatImageTestCaller{dryRun: true, format: format} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{ + "node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80", + }) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run remote calls = %d", len(caller.calls)) + } + if format == "json" { + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode dry-run: %v; output=%s", err, output.String()) + } + arguments, _ := payload["arguments"].(map[string]any) + if payload["tool"] != "create_float_image" || arguments["src"] != floatImagePlannedResource || payload["dry_run"] != true || payload["executed"] != false { + t.Fatalf("dry-run payload = %#v", payload) + } + if _, exists := payload["preview_kind"]; exists { + t.Fatalf("request preview unexpectedly declares preview_kind: %#v", payload) + } + } else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> create_float_image") { + t.Fatalf("pretty dry-run = %s", output.String()) + } + }) + } + for _, format := range []string{"json", "table"} { + t.Run("update-"+format, func(t *testing.T) { + caller := &floatImageTestCaller{dryRun: true, format: format} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, command, map[string]string{ + "node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "file": path, "width": "120", + }) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("update dry-run remote calls = %d", len(caller.calls)) + } + if format == "json" { + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode update dry-run: %v; output=%s", err, output.String()) + } + arguments, _ := payload["arguments"].(map[string]any) + if payload["tool"] != "update_float_image" || arguments["src"] != floatImagePlannedResource || arguments["floatImageId"] != "fi-1" || payload["dry_run"] != true || payload["executed"] != false { + t.Fatalf("update dry-run payload = %#v", payload) + } + } else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> update_float_image") { + t.Fatalf("update pretty dry-run = %s", output.String()) + } + }) + } +} + +func TestCrossPlatformCoverageFloatImageCommandWorkflows(t *testing.T) { + path := writeFloatImageFixture(t, "chart.png", "command-image") + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + + t.Run("create src", func(t *testing.T) { + caller := &floatImageTestCaller{} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{ + "node": "node-1", "sheet-id": "sheet-1", "src": "/core/api/resources/img/existing", "range": "A1", "width": "100", "height": "80", + }) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].product != "sheet" || caller.calls[0].tool != "create_float_image" || caller.calls[0].args["src"] != "/core/api/resources/img/existing" { + t.Fatalf("src calls = %#v", caller.calls) + } + }) + + t.Run("create and update file", func(t *testing.T) { + var uploadBodies []string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + raw, _ := io.ReadAll(request.Body) + uploadBodies = append(uploadBodies, string(raw)) + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + for _, commandName := range []string{"create-float-image", "update-float-image"} { + t.Run(commandName, func(t *testing.T) { + caller := &floatImageTestCaller{call: func(_ context.Context, product, tool string, _ map[string]any, index int) (*edition.ToolResult, error) { + if index == 0 { + if product != "doc" || tool != "get_doc_attachment_upload_info" { + t.Fatalf("credential route = %s/%s", product, tool) + } + return floatImageCredentialResult(server.URL+"/signed-secret", commandName == "update-float-image"), nil + } + return floatImageTextResult(`{"floatImage":{"id":"fi-ok"}}`), nil + }} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, commandName) + flags := map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path} + if commandName == "create-float-image" { + flags["range"], flags["width"], flags["height"] = "A1", "100", "80" + } else { + flags["float-image-id"], flags["width"] = "fi-1", "120" + } + setFloatImageFlags(t, command, flags) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[1].product != "sheet" || caller.calls[1].tool != strings.ReplaceAll(commandName, "-", "_") { + t.Fatalf("workflow calls = %#v", caller.calls) + } + if caller.calls[0].args["fileName"] != "chart.png" || caller.calls[0].args["mimeType"] != "image/png" || caller.calls[1].args["src"] != "/core/api/resources/img/rid-1" { + t.Fatalf("workflow args = %#v", caller.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil || payload["floatImage"] == nil { + t.Fatalf("final output = %s, %v", output.String(), err) + } + }) + } + if len(uploadBodies) != 2 || uploadBodies[0] != "command-image" || uploadBodies[1] != "command-image" { + t.Fatalf("upload bodies = %#v", uploadBodies) + } + }) + + t.Run("pretty progress", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) })) + defer server.Close() + caller := &floatImageTestCaller{format: "table", call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) { + if index == 0 { + return floatImageCredentialResult(server.URL, false), nil + } + return floatImageTextResult(`{"floatImage":{"id":"fi-pretty"}}`), nil + }} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + for _, marker := range []string{"[1/3]", "resourceId", "[2/3]", "[3/3]"} { + if !strings.Contains(output.String(), marker) { + t.Fatalf("pretty output missing %q: %s", marker, output.String()) + } + } + }) + + t.Run("validation and local error", func(t *testing.T) { + caller := &floatImageTestCaller{} + installFloatImageDeps(t, caller, io.Discard) + create := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, create, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "range": "A1", "width": "100", "height": "80"}) + if err := create.RunE(create, nil); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) { + t.Fatalf("create validation = %v", err) + } + missing := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, missing, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": filepath.Join(t.TempDir(), "missing.png"), "range": "A1", "width": "100", "height": "80"}) + if err := missing.RunE(missing, nil); err == nil || !strings.Contains(err.Error(), "无法读取") { + t.Fatalf("local error = %v", err) + } + update := floatImageCommandForTest(t, "update-float-image") + if err := update.RunE(update, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateFieldsError) { + t.Fatalf("update fields error = %v", err) + } + conflict := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, conflict, map[string]string{"file": path, "src": "/img/a"}) + if err := conflict.RunE(conflict, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) { + t.Fatalf("update conflict = %v", err) + } + missingIdentity := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, missingIdentity, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path}) + if err := missingIdentity.RunE(missingIdentity, nil); err == nil || !strings.Contains(err.Error(), "float-image-id") || len(caller.calls) != 0 { + t.Fatalf("update identity error/calls = %v/%d", err, len(caller.calls)) + } + }) + + t.Run("credential and put failures", func(t *testing.T) { + credentialCause := errors.New("credential transport") + caller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return nil, credentialCause + }} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); !errors.Is(err, credentialCause) { + t.Fatalf("credential error = %v", err) + } + + secret := "credential-response-secret" + malformed := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return floatImageTextResult(secret), nil + }} + installFloatImageDeps(t, malformed, io.Discard) + command = floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("malformed credential error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(response, "put-body-secret") + })) + defer server.Close() + putCaller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return floatImageCredentialResult(server.URL+"/signed-secret", false), nil + }} + installFloatImageDeps(t, putCaller, io.Discard) + command = floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); err == nil || len(putCaller.calls) != 1 || strings.Contains(err.Error(), "put-body-secret") || strings.Contains(err.Error(), "signed-secret") { + t.Fatalf("put error/calls = %v/%d", err, len(putCaller.calls)) + } + }) + + t.Run("partial final failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) })) + defer server.Close() + finalCause := errors.New("final operation sentinel") + caller := &floatImageTestCaller{call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) { + if index == 0 { + return floatImageCredentialResult(server.URL+"/signed-secret", false), nil + } + return nil, finalCause + }} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + err := command.RunE(command, nil) + if !errors.Is(err, finalCause) || !strings.Contains(err.Error(), "resourceId=rid-1") || !strings.Contains(err.Error(), "list/get/UI") || strings.Contains(err.Error(), "signed-secret") { + t.Fatalf("partial failure = %v", err) + } + }) + + t.Run("update move only", func(t *testing.T) { + caller := &floatImageTestCaller{} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "range": "C5"}) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].args["range"] != "C5" { + t.Fatalf("move-only calls = %#v", caller.calls) + } + }) +} + +func TestCrossPlatformCoverageResolveFloatImageUploadInfo(t *testing.T) { + path := writeFloatImageFixture(t, "chart.webp", "webp") + local := openFloatImageFixture(t, path) + caller := &floatImageTestCaller{call: func(ctx context.Context, product, tool string, args map[string]any, _ int) (*edition.ToolResult, error) { + if ctx == nil || product != "doc" || tool != "get_doc_attachment_upload_info" { + t.Fatalf("route/context = %s/%s/%v", product, tool, ctx) + } + if args["nodeId"] != "node-1" || args["fileName"] != "chart.webp" || args["mimeType"] != "image/webp" || args["fileSize"] != float64(4) { + t.Fatalf("credential args = %#v", args) + } + return floatImageCredentialResult("https://upload.invalid/path", false), nil + }} + installFloatImageDeps(t, caller, io.Discard) + info, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local) + if err != nil || info.resourceID != "rid-1" { + t.Fatalf("resolve info = %#v/%v", info, err) + } + + cause := fmt.Errorf("timeout synthetic") + caller.call = func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return nil, cause + } + if _, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local); !errors.Is(err, cause) { + t.Fatalf("resolve error = %v", err) + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + caller.call = func(ctx context.Context, _ string, _ string, _ map[string]any, _ int) (*edition.ToolResult, error) { + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("credential context error = %v", ctx.Err()) + } + return nil, ctx.Err() + } + if _, err := resolveFloatImageUploadInfo(cancelled, "node-1", local); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled credential error = %v", err) + } +} + +func TestCrossPlatformCoverageFloatImageUploadDeadlineDocumented(t *testing.T) { + client := newFloatImageUploadClient() + if client.Timeout != 5*time.Minute || client.CheckRedirect == nil { + t.Fatalf("upload client = %#v", client) + } +} diff --git a/internal/helpers/sheet_import.go b/internal/helpers/sheet_import.go index d07c58ac..6df7057e 100644 --- a/internal/helpers/sheet_import.go +++ b/internal/helpers/sheet_import.go @@ -156,6 +156,7 @@ func newSheetImportCmdWithConfig(cfg importFlowConfig) *cobra.Command { }) importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)") importCmd.AddCommand(importCreateCmd, importGetCmd) + newHybridGroupCommand(importCmd) return importCmd } diff --git a/internal/helpers/sheet_pivot_table.go b/internal/helpers/sheet_pivot_table.go index ac1a07bc..d588e97f 100644 --- a/internal/helpers/sheet_pivot_table.go +++ b/internal/helpers/sheet_pivot_table.go @@ -134,11 +134,11 @@ func readPivotProperties(raw string, requireValues bool) (map[string]any, error) } func newPivotTableCmd() *cobra.Command { - root := &cobra.Command{ + root := newDeepGroupCommand(&cobra.Command{ Use: "pivot-table", Short: "透视表管理", RunE: groupRunE, - } + }) listCmd := &cobra.Command{ Use: "list", @@ -370,6 +370,5 @@ func newPivotTableCmd() *cobra.Command { deleteCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)") root.AddCommand(listCmd, createCmd, updateCmd, deleteCmd) - attachUnknownSubcommandGuard(root) return root } diff --git a/internal/helpers/sheet_range_ops.go b/internal/helpers/sheet_range_ops.go index 1c3a8fb5..02ebafb0 100644 --- a/internal/helpers/sheet_range_ops.go +++ b/internal/helpers/sheet_range_ops.go @@ -12,7 +12,7 @@ import ( // (read/update/clear/sort/fill/copy-to/move-to). // set-style, batch-set-style, and batch-clear are added by newSheetCommand(). func newRangeCmd() *cobra.Command { - rangeCmd := &cobra.Command{Use: "range", Short: "数据区域操作"} + rangeCmd := newDeepGroupCommand(&cobra.Command{Use: "range", Short: "数据区域操作"}) rangeReadCmd := &cobra.Command{ Use: "read", diff --git a/internal/helpers/sheet_read_completion_test.go b/internal/helpers/sheet_read_completion_test.go index dca4bfdc..9b0d4e03 100644 --- a/internal/helpers/sheet_read_completion_test.go +++ b/internal/helpers/sheet_read_completion_test.go @@ -144,17 +144,25 @@ func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T // Large-CP failure is not a pageable success. Both the raw csv-get path and // both get_cell_infos paths must retain the backend code and safe user message // exactly, matching the generic get_all_sheets error-classification path. +// +// 两条路径的 Message 形态不同:csv-get / cell-infos-direct 走终端展示路径 +// (callMCPToolInternalOptsContext),业务错误展示为提取的 errorMessage 加 +// "(code: ...)" 后缀——后端错误码必须保持可见;range read 走数据编排路径 +// (parseMCPToolTextResult),下游(如 drive list --depth 的限流重试)需要 +// 从 Message 反解析 errorCode,因此必须保留原始 JSON payload。 func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) { const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}` + const wantDisplay = "The workbook data is too large to process. Use a smaller copy or split the workbook, then try again. (code: forbidden.document.sizeOverLimit)" tests := []struct { name string args []string tool string direct bool + want string }{ - {name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"}, - {name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"}, - {name: "cell-infos-direct", tool: "get_cell_infos", direct: true}, + {name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv", want: wantDisplay}, + {name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos", want: response}, + {name: "cell-infos-direct", tool: "get_cell_infos", direct: true, want: wantDisplay}, } for _, test := range tests { @@ -180,8 +188,8 @@ func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) { if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError { t.Fatalf("error = %T %v, want MCP tool error", err, err) } - if cliErr.Message != response { - t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response) + if cliErr.Message != test.want { + t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, test.want) } for _, want := range []string{ "forbidden.document.sizeOverLimit", diff --git a/internal/helpers/sheet_template.go b/internal/helpers/sheet_template.go index ee0de5dd..c7e2c42c 100644 --- a/internal/helpers/sheet_template.go +++ b/internal/helpers/sheet_template.go @@ -9,11 +9,11 @@ import ( // newSheetTemplateCmd builds the `dws sheet template` command group. func newSheetTemplateCmd() *cobra.Command { - templateCmd := &cobra.Command{ + templateCmd := newDeepGroupCommand(&cobra.Command{ Use: "template", Short: "表格模板管理", RunE: groupRunE, - } + }) templateListCmd := &cobra.Command{ Use: "list", @@ -205,7 +205,5 @@ func newSheetTemplateCmd() *cobra.Command { RegisterCrossProductAliases(child) } - attachUnknownSubcommandGuard(templateCmd) - return templateCmd } diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go index a1a8c7c4..70692e56 100644 --- a/internal/helpers/sheet_version.go +++ b/internal/helpers/sheet_version.go @@ -8,12 +8,12 @@ import ( ) func newSheetVersionCmd() *cobra.Command { - versionCmd := &cobra.Command{ + versionCmd := newGroupCommand(&cobra.Command{ Use: "version", Short: "表格历史版本管理", Long: `管理钉钉在线电子表格的历史版本:手动保存、查看版本列表、回滚到指定版本。`, RunE: groupRunE, - } + }) versionSaveCmd := &cobra.Command{ Use: "save", diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go index d34b57eb..05603b6c 100644 --- a/internal/helpers/small_remaining_coverage_test.go +++ b/internal/helpers/small_remaining_coverage_test.go @@ -4,10 +4,13 @@ import ( "context" "errors" "net/http" + "slices" "strings" "testing" "time" + 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" ) @@ -20,9 +23,37 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T group := &cobra.Command{Use: "range", Run: func(*cobra.Command, []string) {}} group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) root.AddCommand(group) - attachUnknownSubcommandGuard(root) - if err := root.RunE(root, []string{"read"}); err == nil || !strings.Contains(err.Error(), "range read") { - t.Fatalf("deep suggestion err=%v", err) + err := cmdutil.NewCommandResolution( + root, + "read", + cmdutil.ResolutionUnknownSubcommand, + cmdutil.SuggestDescendantSubcommands(root, "read"), + "", + ).Err() + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "range read") { + t.Fatalf("deep suggestion err=%#v", err) + } + + bounded := &cobra.Command{Use: "sheet"} + for _, name := range []string{"alpha", "beta", "delta", "gamma"} { + group := &cobra.Command{Use: name} + group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + bounded.AddCommand(group) + } + err = cmdutil.NewCommandResolution( + bounded, + "read", + cmdutil.ResolutionUnknownSubcommand, + cmdutil.SuggestDescendantSubcommands(bounded, "read"), + "", + ).Err() + if !errors.As(err, &structured) { + t.Fatalf("bounded deep suggestion error = %T %v", err, err) + } + suggestions, ok := structured.Details["suggestions"].([]string) + if !ok || !slices.Equal(suggestions, []string{"alpha read", "beta read", "delta read"}) { + t.Fatalf("bounded deep suggestions = %#v", structured.Details["suggestions"]) } installScriptedCaller(t, &scriptedToolCaller{dry: true}) diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 5332513a..ca488373 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -69,14 +69,14 @@ func newTodoCommand() *cobra.Command { }, }, }) - todoCmd := &cobra.Command{ + todoCmd := newGroupCommand(&cobra.Command{ Use: "todo", Short: "待办任务管理", Long: `管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。`, RunE: groupRunE, - } + }) - todoTaskCmd := &cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE} + todoTaskCmd := newGroupCommand(&cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE}) todoTaskCreateCmd := &cobra.Command{ Use: "create", @@ -96,7 +96,10 @@ func newTodoCommand() *cobra.Command { return err } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } toolArgs := map[string]any{ "PersonalTodoCreateVO": map[string]any{ "subject": flagOrFallback(cmd, "title", "subject", "content"), @@ -111,9 +114,11 @@ func newTodoCommand() *cobra.Command { toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms } if v, _ := cmd.Flags().GetString("priority"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n + n, err := parseTodoPriority(v) + if err != nil { + return err } + toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n } if v, _ := cmd.Flags().GetString("recurrence"); v != "" { toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v @@ -154,7 +159,7 @@ func newTodoCommand() *cobra.Command { }, Parameters: []contract.ParamDecl{ {Name: "due", Property: "PersonalTodoCreateVO.dueTime"}, - {Name: "executors", Property: "PersonalTodoCreateVO.executorIds"}, + {Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "priority", Property: "PersonalTodoCreateVO.priority"}, {Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"}, {Name: "title", Property: "PersonalTodoCreateVO.subject"}, @@ -190,7 +195,10 @@ func newTodoCommand() *cobra.Command { } } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } toolArgs := map[string]any{ "PersonalTodoCreateVO": map[string]any{ "subject": flagOrFallback(cmd, "title", "subject", "content"), @@ -206,9 +214,11 @@ func newTodoCommand() *cobra.Command { toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms } if v, _ := cmd.Flags().GetString("priority"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n + n, err := parseTodoPriority(v) + if err != nil { + return err } + toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n } if v, _ := cmd.Flags().GetString("recurrence"); v != "" { toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v @@ -249,7 +259,7 @@ func newTodoCommand() *cobra.Command { }, Parameters: []contract.ParamDecl{ {Name: "due", Property: "PersonalTodoCreateVO.dueTime"}, - {Name: "executors", Property: "PersonalTodoCreateVO.executorIds"}, + {Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"}, {Name: "priority", Property: "PersonalTodoCreateVO.priority"}, {Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"}, @@ -376,9 +386,11 @@ func newTodoCommand() *cobra.Command { inner["dueTime"] = ms } if v, _ := cmd.Flags().GetString("priority"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - inner["priority"] = n + n, err := parseTodoPriority(v) + if err != nil { + return err } + inner["priority"] = n } if v, _ := cmd.Flags().GetString("done"); v != "" { inner["isDone"] = v == "true" @@ -630,7 +642,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 return err } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } return callMCPTool("add_task_executors", map[string]any{ "todoExecutorsAddRequest": map[string]any{ "taskId": mustGetFlag(cmd, "task-id"), @@ -672,7 +687,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 }, }, Parameters: []contract.ParamDecl{ - {Name: "executors", Property: "todoExecutorsAddRequest.executorIds"}, + {Name: "executors", Property: "todoExecutorsAddRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "task-id", Property: "todoExecutorsAddRequest.taskId"}, }, }, @@ -688,7 +703,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 return err } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } return callMCPTool("remove_task_executors", map[string]any{ "todoExecutorsRemoveRequest": map[string]any{ "taskId": mustGetFlag(cmd, "task-id"), @@ -729,7 +747,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 }, }, Parameters: []contract.ParamDecl{ - {Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds"}, + {Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "task-id", Property: "todoExecutorsRemoveRequest.taskId"}, }, }, @@ -1189,14 +1207,14 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 } todoTaskCreateCmd.Flags().String("title", "", "待办标题 (必填)") - todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskCreateCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)") todoTaskCreateCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急") todoTaskCreateCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1") todoTaskCreateSubCmd.Flags().String("parent-id", "", "父待办任务 ID (必填)") todoTaskCreateSubCmd.Flags().String("title", "", "子待办标题 (必填)") - todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskCreateSubCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)") todoTaskCreateSubCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急") todoTaskCreateSubCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1") @@ -1235,9 +1253,9 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 _ = todoTaskCmd.PersistentFlags().MarkHidden("remind-at") todoTaskAddExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)") - todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskRemoveExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)") - todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskAddParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)") todoTaskAddParticipantCmd.Flags().String("participants", "", "参与人 userId 列表 (必填)") todoTaskRemoveParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)") @@ -1285,7 +1303,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 // dws todo comment — 待办评论 // 对应 MCP:add_todo_comment / list_todo_comment / delete_todo_comment // ────────────────────────────────────────────────────────── - todoCommentCmd := &cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE} + todoCommentCmd := newGroupCommand(&cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE}) todoCommentAddCmd := &cobra.Command{ Use: "add", @@ -1456,7 +1474,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 // dws todo tag — 待办标签 // 对应 MCP:tag_todo / delete_todo_tag / update_todo_tag / list_todo_tags / create_todo_tag // ────────────────────────────────────────────────────────── - todoTagCmd := &cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE} + todoTagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE}) todoTagAddCmd := &cobra.Command{ Use: "add", @@ -1893,6 +1911,34 @@ func parseExecutorIds(s string) []string { return ids } +func parseRequiredTodoIDs(flagName, value string) ([]string, error) { + ids := parseExecutorIds(value) + if len(ids) > 0 { + return ids, nil + } + return nil, apperrors.NewValidation( + fmt.Sprintf("--%s 至少需要一个非空 userId", flagName), + apperrors.WithReason("invalid_"+strings.ReplaceAll(flagName, "-", "_")), + apperrors.WithHint(fmt.Sprintf("使用 --%s [,...] 指定至少一个执行人", flagName)), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) +} + +func parseTodoPriority(value string) (int, error) { + priority, err := strconv.Atoi(strings.TrimSpace(value)) + if err == nil { + return priority, nil + } + return 0, apperrors.NewValidation( + "--priority 必须是整数", + apperrors.WithReason("invalid_priority"), + apperrors.WithHint("常用优先级映射:10=低、20=普通、30=较高、40=紧急"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) +} + type todoLocalFileMeta struct { LocalPath string FileName string diff --git a/internal/helpers/todo_command_edges_more_test.go b/internal/helpers/todo_command_edges_more_test.go index fc74a977..336e48d7 100644 --- a/internal/helpers/todo_command_edges_more_test.go +++ b/internal/helpers/todo_command_edges_more_test.go @@ -6,8 +6,11 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error { @@ -44,9 +47,11 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { errorCases := [][]string{ {"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate}, {"task", "create", "--title", "x", "--executors", "u", "--due", "bad"}, + {"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"}, + {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"}, {"task", "list", "--role-types", "invalid"}, {"task", "list", "--plan-finish-date-start", "bad"}, {"task", "list", "--plan-finish-date-end", "bad"}, @@ -59,9 +64,8 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { validCases := [][]string{ {"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"}, - {"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"}, + {"task", "create", "--subject", "x", "--executors", "u", "--priority", "20"}, {"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"}, - {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"}, {"task", "list", "--size", "bad"}, {"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate}, } @@ -75,6 +79,63 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { } } +func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndNonNumericPriorityBeforeCall(t *testing.T) { + tests := []struct { + name string + reason string + args []string + }{ + {name: "create empty executors", reason: "invalid_executors", args: []string{"task", "create", "--title", "x", "--executors", ",,,"}}, + {name: "create-sub empty executors", reason: "invalid_executors", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", " , , "}}, + {name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}}, + {name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}}, + {name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeTodoEdge(t, caller, test.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != test.reason { + t.Fatalf("error = %T %v, want reason %q", err, err, test.reason) + } + if typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("execution_started = %v, want false", typed.ExecutionStarted) + } + if caller.calls != 0 { + t.Fatalf("invalid input made %d MCP call(s)", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageTodoCreateAcceptsNumericPriority(t *testing.T) { + for _, priority := range []string{"20", "25"} { + t.Run(priority, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", priority); err != nil { + t.Fatalf("create priority %s: %v", priority, err) + } + if caller.calls != 1 || caller.tool != "create_personal_todo" { + t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool) + } + request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any) + if !ok || request["priority"] != mustTodoAtoi(t, priority) { + t.Fatalf("request = %#v, want priority %s", caller.args, priority) + } + }) + } +} + +func mustTodoAtoi(t *testing.T, value string) int { + t.Helper() + parsed, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("parse %q: %v", value, err) + } + return parsed +} + func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) { errorCases := [][]string{ {"task", "create"}, @@ -143,6 +204,7 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) { errorCases := [][]string{ {"task", "update", "--task-id", "1", "--remind-at", validDate}, {"task", "update", "--task-id", "1", "--due", "bad"}, + {"task", "update", "--task-id", "1", "--priority", "bad"}, {"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"}, {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`}, {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`}, @@ -155,7 +217,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) { } validCases := [][]string{ {"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"}, - {"task", "update", "--task-id", "1", "--priority", "bad"}, {"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"}, {"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate}, } diff --git a/internal/helpers/whiteboard.go b/internal/helpers/whiteboard.go index 9ecdc388..7a731c0c 100644 --- a/internal/helpers/whiteboard.go +++ b/internal/helpers/whiteboard.go @@ -45,7 +45,7 @@ func newWhiteboardCommand() *cobra.Command { AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"}, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "whiteboard", Short: "钉钉文档内嵌白板管理", Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。 @@ -53,7 +53,7 @@ func newWhiteboardCommand() *cobra.Command { 当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID; 本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`, RunE: groupRunE, - } + }) queryCmd := &cobra.Command{ Use: "query", diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 8d3bac3e..4f81c74e 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -160,14 +160,14 @@ func newWikiCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "wiki", Short: "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询", Long: `管理钉钉文档知识库:空间管理(创建/查看/列出/搜索/删除)、节点管理(列出/创建/复制/移动/删除)、成员管理(添加/更新/列出/移除)、动态查询(知识库活动动态)。`, RunE: groupRunE, - } + }) - spaceCmd := &cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE} + spaceCmd := newGroupCommand(&cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE}) spaceCreateCmd := &cobra.Command{ Use: "create", @@ -519,21 +519,30 @@ func newWikiCommand() *cobra.Command { spaceCmd.AddCommand(spaceCreateCmd, spaceGetCmd, spaceListCmd, spaceSearchCmd, spaceDeleteCmd) // ── member (知识库成员管理) ─────────────────────────────── - memberCmd := &cobra.Command{ + memberCmd := newGroupCommand(&cobra.Command{ Use: "member", Short: "知识库成员管理", Long: `管理钉钉知识库的成员:添加成员、更新成员权限、查询成员列表、移除成员。`, RunE: groupRunE, - } + }) memberAddCmd := &cobra.Command{ Use: "add", Short: "添加知识库成员", + Args: cobra.NoArgs, Long: `为指定知识库添加一个或多个成员,并授予指定角色。 -通过 --users 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。 +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId -支持的角色 (--role)(必须大写): +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织) + DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织) + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型 + +支持的角色(大小写不敏感): MANAGER 管理员,可读写、管理成员 EDITOR 编辑者,可查看、编辑、上传内容 DOWNLOADER 查看下载者,可查看并下载内容 @@ -541,36 +550,58 @@ func newWikiCommand() *cobra.Command { 注意: - OWNER 角色不可通过此接口添加,知识库创建者默认为所有者。 -- 操作者需具备知识库的 OWNER 或 MANAGER 权限。 +- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。 - 单次请求最多 30 个成员,超出请分批调用。 +- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。 支持通过 --workspace 传入知识库 ID 或知识库 URL,系统自动识别。 用户 uid 可通过「钉钉通讯录」相关命令检索,如: dws contact user search --keyword "姓名"`, Example: ` dws wiki member add --workspace --users uid1 --role READER dws wiki member add --workspace --users uid1,uid2,uid3 --role EDITOR - dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER`, + dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER + dws wiki member add --workspace --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify + dws wiki member add --workspace --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id") if err != nil { return err } - if err := validateRequiredFlags(cmd, "role"); err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - role := normalizePermissionRole(mustGetFlag(cmd, "role")) - if role == "OWNER" { - return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加") + toolArgs := map[string]any{"workspaceId": workspaceID} + members, mErr := collectMembers(cmd, false) + if mErr != nil { + return mErr } - userIds, err := collectUserIDs(cmd) - if err != nil { - return err + if len(members) > 0 { + for _, m := range members { + if r, _ := m["roleId"].(string); r == "OWNER" { + return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加") + } + } + toolArgs["members"] = members + if cmd.Flags().Changed("notify") { + notify, _ := cmd.Flags().GetBool("notify") + toolArgs["notify"] = notify + } + } else { + if err := validateRequiredFlags(cmd, "role"); err != nil { + return err + } + role := normalizePermissionRole(mustGetFlag(cmd, "role")) + if role == "OWNER" { + return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加") + } + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["roleId"] = role + toolArgs["userIds"] = userIds } - return callMCPTool("add_member", map[string]any{ - "workspaceId": workspaceID, - "roleId": role, - "userIds": userIds, - }) + return callMCPTool("add_member", toolArgs) }, } DeclareLeafMetadata(memberAddCmd, LeafSpec{ @@ -605,6 +636,8 @@ func newWikiCommand() *cobra.Command { }, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, + {Name: "notify", Property: "notify"}, {Name: "role", Property: "roleId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -613,17 +646,30 @@ func newWikiCommand() *cobra.Command { }) memberAddCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)") - memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)") + memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)") memberAddCmd.Flags().String("user", "", "") _ = memberAddCmd.Flags().MarkHidden("user") - memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)") + memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)") + memberAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥") + memberAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)") memberUpdateCmd := &cobra.Command{ Use: "update", Short: "更新知识库成员权限", + Args: cobra.NoArgs, Long: `更新指定知识库已有成员的角色。 -支持的角色 (--role)(必须大写): +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId + DEPT 部门,id 为部门 ID,需携带 corpId + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId + +支持的角色(大小写不敏感): MANAGER 管理员 EDITOR 编辑者 DOWNLOADER 查看下载者 @@ -632,28 +678,45 @@ func newWikiCommand() *cobra.Command { 注意: - OWNER 角色不可通过此接口变更。 - 同一成员在同一知识库只能拥有一个角色,变更后旧角色自动替换。 -- 操作者需具备知识库的 OWNER 或 MANAGER 权限。 +- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。 +- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。 仅可更新已存在成员关系的成员,新增成员请使用 dws wiki member add。`, Example: ` dws wiki member update --workspace --users uid1 --role EDITOR - dws wiki member update --workspace --users uid1,uid2 --role READER`, + dws wiki member update --workspace --users uid1,uid2 --role READER + dws wiki member update --workspace --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false + dws wiki member update --workspace --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id") if err != nil { return err } - if err := validateRequiredFlags(cmd, "role"); err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { - return err + toolArgs := map[string]any{"workspaceId": workspaceID} + members, mErr := collectMembers(cmd, false) + if mErr != nil { + return mErr } - return callMCPTool("update_member", map[string]any{ - "workspaceId": workspaceID, - "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")), - "userIds": userIds, - }) + if len(members) > 0 { + toolArgs["members"] = members + if cmd.Flags().Changed("notify") { + notify, _ := cmd.Flags().GetBool("notify") + toolArgs["notify"] = notify + } + } else { + if err := validateRequiredFlags(cmd, "role"); err != nil { + return err + } + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role")) + toolArgs["userIds"] = userIds + } + return callMCPTool("update_member", toolArgs) }, } DeclareLeafMetadata(memberUpdateCmd, LeafSpec{ @@ -682,6 +745,8 @@ func newWikiCommand() *cobra.Command { Examples: []string{"dws wiki member update --workspace --users uid1 --role EDITOR --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, + {Name: "notify", Property: "notify"}, {Name: "role", Property: "roleId"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, @@ -690,10 +755,12 @@ func newWikiCommand() *cobra.Command { }) memberUpdateCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)") - memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)") + memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)") memberUpdateCmd.Flags().String("user", "", "") _ = memberUpdateCmd.Flags().MarkHidden("user") - memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)") + memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)") + memberUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥") + memberUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)") memberListCmd := &cobra.Command{ Use: "list", @@ -701,12 +768,15 @@ func newWikiCommand() *cobra.Command { Short: "查询知识库成员列表", Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。 -注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。 -若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围; +底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。 +出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。 +当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。 +操作者需满足该知识库配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。 ORG 类型授权不会出现在查询结果中。`, Example: ` dws wiki member list --workspace - dws wiki member list --workspace --limit 100 - dws wiki member list --workspace --filter-role MANAGER,EDITOR`, + dws wiki member list --workspace --limit 50 + dws wiki member list --workspace --filter-role MANAGER,EDITOR + dws wiki member list --workspace --next-token <上次返回的 nextToken>`, RunE: func(cmd *cobra.Command, args []string) error { workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id") if err != nil { @@ -715,17 +785,13 @@ ORG 类型授权不会出现在查询结果中。`, toolArgs := map[string]any{ "workspaceId": workspaceID, } - limit := 0 - if cmd.Flags().Changed("limit") { - limit, _ = cmd.Flags().GetInt("limit") - } else if cmd.Flags().Changed("max-results") { - limit, _ = cmd.Flags().GetInt("max-results") + if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil { + return err + } else if ok { + toolArgs["pageSize"] = size } - if limit > 0 { - if limit > 50 { - return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页") - } - toolArgs["maxResults"] = limit + if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" { + toolArgs["nextToken"] = v } if v := mustGetFlag(cmd, "filter-role"); v != "" { toolArgs["filterRoleIds"] = parseRoleList(v) @@ -763,50 +829,79 @@ ORG 类型授权不会出现在查询结果中。`, }, Parameters: []contract.ParamDecl{ {Name: "filter-role", Property: "filterRoleIds"}, - {Name: "limit", Property: "maxResults"}, + // limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize, + // 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。 + {Name: "limit"}, + {Name: "next-token", Property: "nextToken"}, {Name: "workspace", Property: "workspaceId"}, }, + Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"}, }, }) memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)") - memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页") + memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50") memberListCmd.Flags().Int("max-results", 0, "") _ = memberListCmd.Flags().MarkHidden("max-results") memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER") + memberListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken") memberRemoveCmd := &cobra.Command{ Use: "remove", Short: "移除知识库成员", - Long: `从指定知识库中移除一个或多个成员(仅支持 USER 类型)。 + Long: `从指定知识库中移除一个或多个成员。 + +两种传参方式(互斥): + 旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型) + 新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId) + +成员类型说明: + USER 用户,id 为用户 userId,需携带 corpId + DEPT 部门,id 为部门 ID,需携带 corpId + CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId + TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId 移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。 注意: - OWNER 角色不可通过此接口移除。 -- 操作者需具备知识库的 OWNER 或 MANAGER 权限。 +- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。 - 单次请求最多 30 个成员,超出请分批调用。`, Example: ` dws wiki member remove --workspace --users uid1 - dws wiki member remove --workspace --users uid1,uid2,uid3`, + dws wiki member remove --workspace --users uid1,uid2,uid3 + dws wiki member remove --workspace --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`, RunE: func(cmd *cobra.Command, args []string) error { workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id") if err != nil { return err } - userIds, err := collectUserIDs(cmd) - if err != nil { + if err := validateMembersExclusivity(cmd); err != nil { return err } - return callMCPTool("remove_member", map[string]any{ - "workspaceId": workspaceID, - "userIds": userIds, - }) + toolArgs := map[string]any{"workspaceId": workspaceID} + members, mErr := collectMembers(cmd, true) + if mErr != nil { + return mErr + } + if len(members) > 0 { + toolArgs["members"] = members + } else { + userIds, err := collectUserIDs(cmd) + if err != nil { + return err + } + toolArgs["userIds"] = userIds + } + return callMCPTool("remove_member", toolArgs) }, } DeclareLeafMetadata(memberRemoveCmd, LeafSpec{ Safety: contract.SafetySpec{ + // 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销整个 + // 知识库容器级别的成员访问,部门/群聊/角色组还可能间接影响大量用户, + // 与删除同级的 destructive 入口,必须经过用户确认(--yes 或交互 yes)。 Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "unknown", + Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -816,14 +911,14 @@ ORG 类型授权不会出现在查询结果中。`, CLIPath: "wiki member remove", PrimaryCLIPath: "wiki member remove", }, - Description: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)", + Description: "从指定知识库中移除一个或多个成员", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "wiki", RPCName: "remove_member"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)", + AgentSummary: "从指定知识库中移除一个或多个成员", UseWhen: []string{"从知识库移除成员访问(离职/清理)时"}, AvoidWhen: []string{ "改角色用 update;节点级权限用 drive permission remove", @@ -832,15 +927,17 @@ ORG 类型授权不会出现在查询结果中。`, Examples: []string{"dws wiki member remove --workspace --users uid1 --format json"}, }, Parameters: []contract.ParamDecl{ + {Name: "members", Property: "members"}, {Name: "users", Property: "userIds"}, {Name: "workspace", Property: "workspaceId"}, }, }, }) memberRemoveCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)") - memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)") + memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)") memberRemoveCmd.Flags().String("user", "", "") _ = memberRemoveCmd.Flags().MarkHidden("user") + memberRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥") // member 子命令的 --workspace-id 隐藏别名(LLMs derive from API field "workspaceId") memberAliasCmds := []*cobra.Command{memberAddCmd, memberUpdateCmd, memberListCmd, memberRemoveCmd} @@ -857,12 +954,12 @@ ORG 类型授权不会出现在查询结果中。`, // ── node (知识库节点管理) ───────────────────────────────── // 对齐飞书 cli-lark wiki node 设计:内建 list/create/copy/move/delete, // 一跳直达 doc MCP server,不再经过 proxy chain。 - nodeCmd := &cobra.Command{ + nodeCmd := newGroupCommand(&cobra.Command{ Use: "node", Short: "知识库节点管理", Long: `管理钉钉知识库中的节点(文档/文件夹/表格等):列出、创建、复制、移动、删除。`, RunE: groupRunE, - } + }) nodeListCmd := &cobra.Command{ Use: "list", @@ -1318,12 +1415,12 @@ ORG 类型授权不会出现在查询结果中。`, root.AddCommand(nodeCmd) // ── feed (知识库动态查询) ───────────────────────────────── - feedCmd := &cobra.Command{ + feedCmd := newGroupCommand(&cobra.Command{ Use: "feed", Short: "知识库动态查询", Long: `查询知识库的动态:谁在什么时间更新/上传/评论了哪些文档。`, RunE: groupRunE, - } + }) feedListCmd := &cobra.Command{ Use: "list", @@ -1439,14 +1536,14 @@ ORG 类型授权不会出现在查询结果中。`, ) // ── [PROXY] wiki file/doc * → doc * (兼容旧路径) ── - fileGroup := &cobra.Command{Use: "file", Hidden: true, RunE: groupRunE} + fileGroup := newGroupCommand(&cobra.Command{Use: "file", Hidden: true, RunE: groupRunE}) fileGroup.AddCommand( proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki file list → doc list proxySubCmd("search", "doc", "search", map[string]string{"workspace": "workspace-ids"}), // [PROXY] wiki file search → doc search ) root.AddCommand(fileGroup) - docGroup := &cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE} + docGroup := newGroupCommand(&cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE}) docGroup.AddCommand( proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki doc list → doc list proxySubCmd("read", "doc", "read", nil), // [PROXY] wiki doc read → doc read diff --git a/internal/helpers/wukong_sync_commands_test.go b/internal/helpers/wukong_sync_commands_test.go index 74fd5beb..6fd1c379 100644 --- a/internal/helpers/wukong_sync_commands_test.go +++ b/internal/helpers/wukong_sync_commands_test.go @@ -179,6 +179,7 @@ func TestWukongSyncAgoalCommands(t *testing.T) { {[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}}, {[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}}, {[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}}, + {[]string{"scorecard", "search-entities"}, []string{"keyword", "page", "page-size", "request-id"}}, {[]string{"user", "rules"}, []string{"user-id", "request-id"}}, {[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}}, {[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}}, diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go index 62205fba..3c479420 100644 --- a/internal/interfacesnapshot/command_migrations.go +++ b/internal/interfacesnapshot/command_migrations.go @@ -21,6 +21,7 @@ const ( CommandMigrationMove = "command_move" CommandMigrationFlagExtraction = "flag_extraction" + CommandMigrationAvailability = "schema_availability_hardening" ) // CommandMigrationManifest governs compatibility-preserving surface moves that @@ -64,6 +65,12 @@ type CommandMigrationSchema struct { SourceToolID string `json:"source_tool_id"` ReplacementToolID string `json:"replacement_tool_id"` Parameters []CommandParameterMigration `json:"parameters"` + Availability *CommandAvailabilityChange `json:"availability,omitempty"` +} + +type CommandAvailabilityChange struct { + Before string `json:"before"` + After string `json:"after"` } type CommandParameterMigration struct { @@ -126,14 +133,23 @@ func (m CommandMigrationManifest) Validate() error { } func (m CommandMigration) validate() error { - if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction { + if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction && m.Kind != CommandMigrationAvailability { return fmt.Errorf("invalid kind %q", m.Kind) } - if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { - return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") - } - if m.Legacy.Command == m.Replacement.Command { - return fmt.Errorf("legacy and replacement command paths must differ") + if m.Kind == CommandMigrationAvailability { + if !isExactCommandPath(m.Legacy.Command) { + return fmt.Errorf("legacy must be an exact command path rooted at dws") + } + if m.Replacement != (CommandMigrationSide{}) { + return fmt.Errorf("schema_availability_hardening must not declare replacement") + } + } else { + if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { + return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") + } + if m.Legacy.Command == m.Replacement.Command { + return fmt.Errorf("legacy and replacement command paths must differ") + } } if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) { return fmt.Errorf("migration must include a non-empty trimmed reason") @@ -141,21 +157,26 @@ func (m CommandMigration) validate() error { if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed { return fmt.Errorf("invalid state %q", m.State) } - for label, state := range map[string]CommandMigrationState{ - "legacy before": m.Legacy.Before, - "legacy after": m.Legacy.After, - "replacement before": m.Replacement.Before, - "replacement after": m.Replacement.After, - } { + states := map[string]CommandMigrationState{ + "legacy before": m.Legacy.Before, + "legacy after": m.Legacy.After, + } + if m.Kind != CommandMigrationAvailability { + states["replacement before"] = m.Replacement.Before + states["replacement after"] = m.Replacement.After + } + for label, state := range states { if err := state.validate(label); err != nil { return err } } - if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden || - !m.Legacy.After.Present || !m.Legacy.After.Runnable { + if m.Kind != CommandMigrationAvailability && + (!m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden || + !m.Legacy.After.Present || !m.Legacy.After.Runnable) { return fmt.Errorf("legacy command must remain runnable and start visible") } - if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden { + if m.Kind != CommandMigrationAvailability && + (m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden) { return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable") } if err := m.Schema.validate(m.Kind); err != nil { @@ -214,6 +235,13 @@ func (m CommandMigration) validate() error { if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt { return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt) } + case CommandMigrationAvailability: + if !isVisibleToHiddenAvailabilityMigration(m) && !isCompatibilityVisibleAvailabilityMigration(m) { + return fmt.Errorf("schema_availability_hardening legacy command must migrate exactly from visible to hidden or remain compatibility-visible") + } + if m.LegacyFlag != (CommandMigrationFlag{}) { + return fmt.Errorf("schema_availability_hardening must not declare legacy_flag") + } } return nil } @@ -259,9 +287,8 @@ func (f CommandMigrationFlag) validate() error { func (s CommandMigrationSchema) validate(kind string) error { for label, value := range map[string]string{ - "product_id": s.ProductID, - "source_tool_id": s.SourceToolID, - "replacement_tool_id": s.ReplacementToolID, + "product_id": s.ProductID, + "source_tool_id": s.SourceToolID, } { if !isExactSchemaIdentifier(value) { return fmt.Errorf("schema %s must be an exact identifier", label) @@ -270,6 +297,21 @@ func (s CommandMigrationSchema) validate(kind string) error { if s.Parameters == nil { return fmt.Errorf("schema parameters must be an array") } + if kind == CommandMigrationAvailability { + if s.ReplacementToolID != "" || len(s.Parameters) != 0 { + return fmt.Errorf("schema_availability_hardening must not declare a replacement tool or parameter mappings") + } + if s.Availability == nil || s.Availability.Before != "available" || s.Availability.After != "unavailable" { + return fmt.Errorf("schema_availability_hardening requires availability available to unavailable") + } + return nil + } + if !isExactSchemaIdentifier(s.ReplacementToolID) { + return fmt.Errorf("schema replacement_tool_id must be an exact identifier") + } + if s.Availability != nil { + return fmt.Errorf("%s must not declare schema availability", kind) + } seenFrom := map[string]bool{} seenTo := map[string]bool{} seenConstantProperty := map[string]bool{} @@ -344,6 +386,9 @@ func (m CommandMigration) key() string { } func (m CommandMigration) displayKey() string { + if m.Kind == CommandMigrationAvailability { + return fmt.Sprintf("%s %q", m.Kind, m.Legacy.Command) + } return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command) } @@ -401,7 +446,7 @@ func AuthorizeCommandMigrations( func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error { commands := commandIndex(snapshot) for _, migration := range manifest.Migrations { - if migration.Kind != CommandMigrationMove { + if migration.Kind != CommandMigrationMove && migration.Kind != CommandMigrationAvailability { continue } if _, present := commands[migration.Legacy.Command]; !present { @@ -409,7 +454,7 @@ func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigratio } for _, command := range snapshot.Commands { if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") { - return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command) + return fmt.Errorf("%s legacy command %q must be a leaf", migration.Kind, migration.Legacy.Command) } } } @@ -438,17 +483,42 @@ func evaluateCommandMigrationLifecycle( for _, approved := range authority.Migrations { basePhase := matchCommandMigrationPhase(mergeBase, approved) wantBase := commandMigrationBefore - if approved.State == CommandMigrationConsumed { + if approved.State == CommandMigrationConsumed && !isCompatibilityVisibleAvailabilityMigration(approved) { wantBase = commandMigrationAfter } if basePhase != wantBase { return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State) } proposed, exists := candidateByKey[approved.key()] - if exists && !sameCommandMigrationApproval(approved, proposed) { + if exists && !sameCommandMigrationApproval(approved, proposed) && + !isPendingAvailabilityCompatibilityRefinement(approved, proposed) { return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey()) } currentPhase := matchCommandMigrationPhase(current, approved) + if isCompatibilityVisibleAvailabilityMigration(approved) { + if currentPhase != commandMigrationBefore { + return nil, fmt.Errorf("candidate drifted from compatibility-visible command migration %s", approved.displayKey()) + } + if !exists { + return nil, fmt.Errorf("candidate must retain compatibility-visible command migration %s", approved.displayKey()) + } + switch approved.State { + case CommandMigrationPending: + switch proposed.State { + case CommandMigrationPending: + continue + case CommandMigrationConsumed: + authorizations = append(authorizations, approved) + continue + } + case CommandMigrationConsumed: + if proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate changed consumed compatibility-visible command migration %s back to pending", approved.displayKey()) + } + authorizations = append(authorizations, approved) + continue + } + } switch approved.State { case CommandMigrationPending: if !exists { @@ -521,9 +591,40 @@ func sameCommandMigrationApproval(left, right CommandMigration) bool { return reflect.DeepEqual(left, right) } +func isVisibleToHiddenAvailabilityMigration(migration CommandMigration) bool { + return migration.Kind == CommandMigrationAvailability && + migration.Legacy.Before == (CommandMigrationState{Present: true, Runnable: true}) && + migration.Legacy.After == (CommandMigrationState{Present: true, Runnable: true, Hidden: true}) +} + +func isCompatibilityVisibleAvailabilityMigration(migration CommandMigration) bool { + visible := CommandMigrationState{Present: true, Runnable: true} + return migration.Kind == CommandMigrationAvailability && + migration.Legacy.Before == visible && migration.Legacy.After == visible +} + +func isPendingAvailabilityCompatibilityRefinement(approved, proposed CommandMigration) bool { + if approved.State != CommandMigrationPending || proposed.State != CommandMigrationPending || + !isVisibleToHiddenAvailabilityMigration(approved) || + !isCompatibilityVisibleAvailabilityMigration(proposed) { + return false + } + proposed.Legacy.After = approved.Legacy.After + return reflect.DeepEqual(approved, proposed) +} + func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase { commands := commandIndex(snapshot) legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command) + if migration.Kind == CommandMigrationAvailability { + if legacy == migration.Legacy.Before { + return commandMigrationBefore + } + if legacy == migration.Legacy.After { + return commandMigrationAfter + } + return commandMigrationPartial + } replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command) before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before after := legacy == migration.Legacy.After && replacement == migration.Replacement.After @@ -629,6 +730,10 @@ func commandMigrationAuthorizesChange(current, reference Snapshot, change Change if change.Kind == "command_became_hidden" && change.Flag == "" { return true } + case CommandMigrationAvailability: + if change.Kind == "command_became_hidden" && change.Flag == "" { + return true + } case CommandMigrationFlagExtraction: if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name { return true diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index a7d5965e..3c39c5dd 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -186,6 +186,7 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { want string }{ {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"}, + {"replacement identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ReplacementToolID = "bad/id" }, "replacement_tool_id"}, {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"}, {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"}, {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"}, @@ -241,6 +242,153 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { } } +func TestCrossPlatformCoverageSchemaAvailabilityMigrationLifecycle(t *testing.T) { + pending := availabilityCommandMigrationManifest(CommandMigrationPending) + consumed := availabilityCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + before := testSnapshot( + testCommand("dws"), + testCommand("dws devapp"), + testCommand("dws devapp +event-list"), + ) + after := testSnapshot( + testCommand("dws"), + testCommand("dws devapp"), + Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true}, + ) + + got, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || len(got) != 1 { + t.Fatalf("availability lifecycle authorizations=%#v error=%v", got, err) + } + report, err := CompareAllWithInterfaceMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}, + FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("availability interface report compatible=%v error=%v report=%#v", report.Compatible, err, report) + } + if got, err := AuthorizeCommandMigrations( + before, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err != nil || len(got) != 0 { + t.Fatalf("candidate-added pending migration must remain inert: %#v, %v", got, err) + } + + valid := pending.Migrations[0] + compatibilityVisible := cloneCommandMigration(valid) + compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before + if err := compatibilityVisible.validate(); err != nil { + t.Fatalf("compatibility-visible availability migration must validate: %v", err) + } + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"legacy path", func(m *CommandMigration) { m.Legacy.Command = "dws devapp *" }, "legacy must be an exact"}, + {"replacement", func(m *CommandMigration) { m.Replacement.Command = "dws devapp other" }, "must not declare replacement"}, + {"absent after", func(m *CommandMigration) { m.Legacy.After = CommandMigrationState{} }, "visible to hidden or remain compatibility-visible"}, + {"legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "legacy" }, "must not declare legacy_flag"}, + {"missing availability", func(m *CommandMigration) { m.Schema.Availability = nil }, "requires availability"}, + {"wrong availability", func(m *CommandMigration) { m.Schema.Availability.After = "available" }, "requires availability"}, + {"parameter mapping", func(m *CommandMigration) { + m.Schema.Parameters = []CommandParameterMigration{{From: "cursor", To: "next-token"}} + }, "must not declare"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := cloneCommandMigration(valid) + if valid.Schema.Availability != nil { + change := *valid.Schema.Availability + migration.Schema.Availability = &change + } + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("availability migration validation error=%v, want %q", err, test.want) + } + }) + } + if got := valid.displayKey(); !strings.Contains(got, CommandMigrationAvailability) { + t.Fatalf("availability display key = %q", got) + } + if got := matchCommandMigrationPhase(testSnapshot(testCommand("dws")), valid); got != commandMigrationPartial { + t.Fatalf("missing availability command phase = %q", got) + } + parent := before + parent.Commands = append(append([]Command(nil), before.Commands...), testCommand("dws devapp +event-list detail")) + if err := validateCommandMoveLegacyLeaves(parent, pending); err == nil || !strings.Contains(err.Error(), "must be a leaf") { + t.Fatalf("availability parent command error = %v", err) + } + moveSchema := commandMigrationManifest(CommandMigrationPending).Migrations[0].Schema + moveSchema.Availability = &CommandAvailabilityChange{Before: "available", After: "unavailable"} + if err := moveSchema.validate(CommandMigrationMove); err == nil || !strings.Contains(err.Error(), "must not declare schema availability") { + t.Fatalf("ordinary move availability error = %v", err) + } +} + +func TestCrossPlatformCoverageCompatibilityVisibleAvailabilityMigrationLifecycle(t *testing.T) { + classicPending := availabilityCommandMigrationManifest(CommandMigrationPending) + steadyPending := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending) + steadyConsumed := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + visible := testSnapshot( + testCommand("dws"), + testCommand("dws devapp"), + testCommand("dws devapp +event-list"), + ) + hidden := testSnapshot( + testCommand("dws"), + testCommand("dws devapp"), + Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true}, + ) + references := map[string]Snapshot{"merge-base": visible, "stable": visible} + + if got, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyPending); err != nil || len(got) != 0 { + t.Fatalf("pending compatibility refinement authorizations=%#v error=%v", got, err) + } + if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyPending); err != nil || len(got) != 0 { + t.Fatalf("pending compatibility receipt authorizations=%#v error=%v", got, err) + } + if got, err := AuthorizeCommandMigrations(visible, references, steadyPending, steadyConsumed); err != nil || len(got) != 1 { + t.Fatalf("consumed compatibility receipt authorizations=%#v error=%v", got, err) + } + if got, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyConsumed); err != nil || len(got) != 1 { + t.Fatalf("retained consumed compatibility receipt authorizations=%#v error=%v", got, err) + } + if got, err := AuthorizeCommandMigrations(visible, references, empty, steadyPending); err != nil || len(got) != 0 { + t.Fatalf("candidate-added compatibility receipt authorizations=%#v error=%v", got, err) + } + + modified := compatibilityVisibleAvailabilityCommandMigrationManifest(CommandMigrationPending) + modified.Migrations[0].Reason = "Changed reason." + if _, err := AuthorizeCommandMigrations(visible, references, classicPending, modified); err == nil || !strings.Contains(err.Error(), "modified base-owned") { + t.Fatalf("modified compatibility refinement error=%v", err) + } + if _, err := AuthorizeCommandMigrations(visible, references, classicPending, steadyConsumed); err == nil || !strings.Contains(err.Error(), "modified base-owned") { + t.Fatalf("refinement and consumption in one change error=%v", err) + } + if _, err := AuthorizeCommandMigrations(hidden, references, steadyPending, steadyPending); err == nil || !strings.Contains(err.Error(), "drifted from compatibility-visible") { + t.Fatalf("hidden compatibility command error=%v", err) + } + if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, empty); err == nil || !strings.Contains(err.Error(), "must retain compatibility-visible") { + t.Fatalf("removed consumed compatibility receipt error=%v", err) + } + if _, err := AuthorizeCommandMigrations(visible, references, steadyConsumed, steadyPending); err == nil || !strings.Contains(err.Error(), "back to pending") { + t.Fatalf("consumed compatibility receipt reverted error=%v", err) + } +} + func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { for _, test := range []struct { name string @@ -686,7 +834,10 @@ func flagExtractionCommandMigrationManifest(state string) CommandMigrationManife func cloneCommandMigration(source CommandMigration) CommandMigration { cloned := source - cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...) + if source.Schema.Parameters != nil { + cloned.Schema.Parameters = make([]CommandParameterMigration, len(source.Schema.Parameters)) + copy(cloned.Schema.Parameters, source.Schema.Parameters) + } for index, parameter := range source.Schema.Parameters { if parameter.ReplacementConstant == nil { continue @@ -703,6 +854,34 @@ func singleCommandMigrationManifest(state string) CommandMigrationManifest { return manifest } +func availabilityCommandMigrationManifest(state string) CommandMigrationManifest { + return CommandMigrationManifest{ + Version: CommandMigrationManifestVersion, + Migrations: []CommandMigration{{ + Kind: CommandMigrationAvailability, + Legacy: CommandMigrationSide{ + Command: "dws devapp +event-list", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Schema: CommandMigrationSchema{ + ProductID: "devapp", + SourceToolID: "devapp.shortcut_event_list", + Parameters: []CommandParameterMigration{}, + Availability: &CommandAvailabilityChange{Before: "available", After: "unavailable"}, + }, + State: state, + Reason: "Fail closed until terminal pagination evidence is available.", + }}, + } +} + +func compatibilityVisibleAvailabilityCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := availabilityCommandMigrationManifest(state) + manifest.Migrations[0].Legacy.After = manifest.Migrations[0].Legacy.Before + return manifest +} + func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest { modified := source modified.Migrations = append([]CommandMigration(nil), source.Migrations...) diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go index ce519e82..eff964db 100644 --- a/internal/pat/browser_policy.go +++ b/internal/pat/browser_policy.go @@ -71,6 +71,13 @@ func patConfigDir() string { return filepath.Join(homeDir, ".dws") } +// BrowserPolicyConfigDir returns the exact configuration directory used by +// the native PAT command. Shortcut adapters use this narrow seam so their +// write/readback cycle cannot drift to a different policy file. +func BrowserPolicyConfigDir() string { + return patConfigDir() +} + func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) { path := patPolicyPath(configDir) data, err := patPolicyReadFile(path) @@ -203,6 +210,32 @@ func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (Browse }, nil } +// ReadStoredBrowserPolicy reads the exact default or explicitly named agent +// entry without consulting the process agent-code environment. This is the +// verification companion to SetBrowserPolicy: callers can prove the same +// target was persisted rather than observing an environment-selected fallback. +func ReadStoredBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) { + agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode) + if err != nil { + return BrowserPolicySelection{}, err + } + policy, err := LoadBrowserPolicy(configDir) + if err != nil { + return BrowserPolicySelection{}, err + } + if agentCode != "" { + entry, ok := policy.Agents[agentCode] + if !ok { + return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy agent entry was not persisted") + } + return BrowserPolicySelection{Scope: "agent", AgentCode: agentCode, OpenBrowser: entry.OpenBrowser, Source: "agent"}, nil + } + if policy.Default == nil { + return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy default entry was not persisted") + } + return BrowserPolicySelection{Scope: "default", OpenBrowser: policy.Default.OpenBrowser, Source: "default"}, nil +} + func newBrowserPolicyCommand() *cobra.Command { cmd := &cobra.Command{ Use: "browser-policy", diff --git a/internal/pat/coverage_edges_test.go b/internal/pat/coverage_edges_test.go index 103c619a..9256adbb 100644 --- a/internal/pat/coverage_edges_test.go +++ b/internal/pat/coverage_edges_test.go @@ -105,6 +105,9 @@ func TestCrossPlatformCoverageBrowserPolicyInjectedEdges(t *testing.T) { if _, err := LoadBrowserPolicy("x"); !errors.Is(err, failure) { t.Fatalf("read error = %v", err) } + if _, err := ReadStoredBrowserPolicy("x", ""); !errors.Is(err, failure) { + t.Fatalf("readback error = %v", err) + } patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{`), nil } if _, err := LoadBrowserPolicy("x"); err == nil { t.Fatal("invalid policy decoded") @@ -179,6 +182,53 @@ func TestCrossPlatformCoverageBrowserPolicyCommandSetError(t *testing.T) { } } +func TestCrossPlatformCoverageBrowserPolicyReadbackAndSelectionEdges(t *testing.T) { + configDir := t.TempDir() + if got := BrowserPolicyConfigDir(); strings.TrimSpace(got) == "" { + t.Fatal("browser policy config directory is empty") + } + if _, err := ReadStoredBrowserPolicy(configDir, "bad code"); err == nil { + t.Fatal("invalid readback agent code succeeded") + } + if _, err := ReadStoredBrowserPolicy(configDir, "missing"); err == nil { + t.Fatal("missing agent readback succeeded") + } + if _, err := ReadStoredBrowserPolicy(configDir, ""); err == nil { + t.Fatal("missing default readback succeeded") + } + if _, err := SetBrowserPolicy(configDir, "agent", false); err != nil { + t.Fatal(err) + } + agent, err := ReadStoredBrowserPolicy(configDir, "agent") + if err != nil || agent.Scope != "agent" || agent.AgentCode != "agent" || agent.OpenBrowser { + t.Fatalf("agent readback = %#v, %v", agent, err) + } + resolvedAgent, err := ResolveBrowserPolicy(configDir, "agent") + if err != nil || resolvedAgent.Source != "agent" || resolvedAgent.OpenBrowser { + t.Fatalf("agent selection = %#v, %v", resolvedAgent, err) + } + if _, err := SetBrowserPolicy(configDir, "", true); err != nil { + t.Fatal(err) + } + defaultPolicy, err := ReadStoredBrowserPolicy(configDir, "") + if err != nil || defaultPolicy.Scope != "default" || !defaultPolicy.OpenBrowser { + t.Fatalf("default readback = %#v, %v", defaultPolicy, err) + } + resolvedDefault, err := ResolveBrowserPolicy(configDir, "unknown") + if err != nil || resolvedDefault.Source != "default" || !resolvedDefault.OpenBrowser { + t.Fatalf("default selection = %#v, %v", resolvedDefault, err) + } + empty, err := ResolveBrowserPolicy(t.TempDir(), "") + if err != nil || empty.Source == "" || !empty.OpenBrowser { + t.Fatalf("built-in selection = %#v, %v", empty, err) + } + + cmd := newBrowserPolicyCommand() + if err := cmd.Execute(); err == nil { + t.Fatal("browser policy command accepted missing --enabled") + } +} + func TestCrossPlatformCoveragePATPureHelperEdges(t *testing.T) { if got := collectChmodProductCodes([]string{"", "a,a", " b "}, []string{"b", "c"}); strings.Join(got, ",") != "a,b,c" { t.Fatalf("product codes = %#v", got) diff --git a/internal/pat/pat.go b/internal/pat/pat.go index d4a17c36..4e1a6e27 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -18,8 +18,9 @@ package pat import ( "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -86,10 +87,26 @@ DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用): 它不参与 claw-type 派生,也不参与 host-owned PAT 判定。 DWS_CHANNEL 只用于上游 channelCode。`, - RunE: cmdutil.GroupRunE, } + corecmd.ApplyGroupPolicy(patCmd, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) patCmd.AddCommand(newChmodCommand(c)) patCmd.AddCommand(newBrowserPolicyCommand()) + // Shortcuts are mounted before this open-source PAT tree in the app. Fold + // that pre-existing service group into the native PAT parent so Cobra and + // Schema both see exactly one top-level path while preserving PAT's group + // behavior and adding every +leaf alongside chmod/browser-policy. + for _, existing := range root.Commands() { + if existing.Name() != patCmd.Name() { + continue + } + cobracmd.MergeCommandTree(patCmd, existing) + root.RemoveCommand(existing) + break + } root.AddCommand(patCmd) } diff --git a/internal/pat/pat_register_test.go b/internal/pat/pat_register_test.go new file mode 100644 index 00000000..67389beb --- /dev/null +++ b/internal/pat/pat_register_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pat + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) { + root := &cobra.Command{Use: "dws"} + shortcutGroup := cobracmd.NewGroupCommand("pat", "pat shortcuts") + shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"}) + root.AddCommand(&cobra.Command{Use: "aaa-other"}) + root.AddCommand(shortcutGroup) + + RegisterCommands(root, &fakeToolCaller{}) + + var patGroups []*cobra.Command + for _, command := range root.Commands() { + if command.Name() == "pat" { + patGroups = append(patGroups, command) + } + } + if len(patGroups) != 1 { + t.Fatalf("top-level PAT groups = %d, want 1", len(patGroups)) + } + for _, child := range []string{"+fixture", "chmod", "browser-policy"} { + if found, _, err := patGroups[0].Find([]string{child}); err != nil || found == nil || found.Name() != child { + t.Errorf("merged PAT child %q missing: found=%v err=%v", child, found, err) + } + } + if patGroups[0].Short != "行为授权管理" { + t.Fatalf("native PAT group metadata was not preserved: %q", patGroups[0].Short) + } +} diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index 0cd35e4b..f20b4817 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -32,10 +32,10 @@ import ( // SetArgs so that Cobra's subsequent ExecuteC uses the corrected // values. // -// 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. +// Explicit +shortcut tokens and groups whose typed GroupPolicy enables +// recovery 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 @@ -64,7 +64,7 @@ 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, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs)) + target, remaining, err := root.Traverse(argsForCommandTraversalForEngine(root, engine, normalizedArgs)) if err != nil { return nil, nil } @@ -129,11 +129,20 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co } func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { - args, _ := argsForCommandTraversalWithPositions(root, rawArgs) + args, _ := argsForCommandTraversalWithPositionsForEngine(root, nil, rawArgs) + return args +} + +func argsForCommandTraversalForEngine(root *cobra.Command, engine *Engine, rawArgs []string) []string { + args, _ := argsForCommandTraversalWithPositionsForEngine(root, engine, rawArgs) return args } func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) { + return argsForCommandTraversalWithPositionsForEngine(root, nil, rawArgs) +} + +func argsForCommandTraversalWithPositionsForEngine(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, []int) { if root == nil || len(rawArgs) == 0 { positions := make([]int, len(rawArgs)) for index := range positions { @@ -153,6 +162,14 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) matcher := newFlagTokenMatcher(flags) out := make([]string, 0, len(rawArgs)) positions := make([]int, 0, len(rawArgs)) + target := root + commandPathOpen := true + unresolvedCommandToken := "" + unresolvedCommandPosition := -1 + commandTokens := make([]string, 0, len(rawArgs)) + commandPositions := make([]int, 0, len(rawArgs)) + commandFlagMatchers := make(map[*cobra.Command]*flagTokenMatcher) + commandFlagValueIndex := -1 for index := 0; index < len(rawArgs); index++ { argument := rawArgs[index] if argument == "--" { @@ -162,12 +179,86 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) } break } + if index == commandFlagValueIndex { + out = append(out, argument) + positions = append(positions, index) + commandFlagValueIndex = -1 + continue + } flag, inlineValue, matched := matcher.matchTraversalToken(argument) if !matched { out = append(out, argument) positions = append(positions, index) + if commandPathOpen { + if strings.HasPrefix(argument, "-") { + commandMatcher := commandFlagMatchers[target] + if commandMatcher == nil { + commandMatcher = newFlagTokenMatcher(target.Flags(), target.InheritedFlags()) + commandFlagMatchers[target] = commandMatcher + } + commandFlag, commandInlineValue, commandMatched := commandMatcher.matchTraversalToken(argument) + if commandMatched { + if index+1 < len(rawArgs) { + if _, ok := separatedBoolValue(argument, rawArgs[index+1], commandFlag, commandInlineValue); ok || + (!commandInlineValue && commandFlag.NoOptDefVal == "") { + commandFlagValueIndex = index + 1 + } + } + continue + } + } + child := exactTraversalChild(target, argument) + if child == nil { + commandPathOpen = false + if !strings.HasPrefix(argument, "-") { + unresolvedCommandToken = argument + unresolvedCommandPosition = index + } + } else { + target = child + commandTokens = append(commandTokens, argument) + commandPositions = append(commandPositions, index) + } + } continue } + // Fuzzy matching is useful for a misspelled root boolean before a + // command (`--dry-rnu calendar ...`), but after a product has already + // resolved the same spelling may be an unknown local value flag. For + // example, `aisearch --types value` is deliberately protected from + // alias correction, while root traversal can otherwise fuzz `--types` + // to the global boolean `--yes` and expose `value` as a fake command. + // Preserve the token when the owning command's reviewed semantic table + // protects that spelling. Protection wins even for detached boolean + // literals and inline values: treating `--types false` as fuzzy + // `--yes false` would otherwise bypass the reviewed parent guard. An + // ordinary unprotected fuzzy root boolean such as `group --yess child` + // remains traversable. + if strings.HasPrefix(argument, "--") && flag != nil && flag.NoOptDefVal != "" && + matcher.matchLongToken(argument).fuzzy && target != root { + name := strings.TrimPrefix(argument, "--") + if separator := strings.IndexByte(name, '='); separator >= 0 { + name = name[:separator] + } + _, protected := engine.resolveFlagProtection(target.CommandPath(), cmdutil.Morph(name)) + if protected { + // Freeze traversal at the exact command prefix. Passing the + // protected unknown flag to Cobra can itself fail (notably when + // its value is a boolean literal), while retaining earlier local + // flags can let their values masquerade as children. The synthetic + // terminator affects only this traversal copy; handlers and Cobra's + // real parse still receive the original argv. + out = append(out[:0], commandTokens...) + positions = append(positions[:0], commandPositions...) + if unresolvedCommandToken != "" { + out = append(out, unresolvedCommandToken) + positions = append(positions, unresolvedCommandPosition) + } + out = append(out, "--") + positions = append(positions, index) + break + } + } if index+1 < len(rawArgs) { if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok { index++ @@ -181,6 +272,15 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) return out, positions } +func exactTraversalChild(parent *cobra.Command, token string) *cobra.Command { + for _, child := range parent.Commands() { + if child.Name() == token || child.HasAlias(token) { + return child + } + } + return nil +} + // separatedBoolValue recognises model-friendly `--flag false` and exact // shorthand `-f false` spellings without mistaking an already attached value // or a shorthand cluster for a detached value. pflag otherwise treats a bare @@ -204,6 +304,7 @@ type longFlagMatch struct { value string hasValue bool recognized bool + fuzzy bool } type flagTokenMatcher struct { @@ -253,6 +354,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true} } + fuzzyMatch := false if normalized, ok := NormalizeFlagToken(argument, m.known); ok { canonical = normalized } else if split, ok := SplitStickyFlag(argument, m.specByName); ok { @@ -260,6 +362,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true} } else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok { canonical = fuzzy + fuzzyMatch = true } else { return longFlagMatch{} } @@ -267,7 +370,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { body = strings.TrimPrefix(canonical, "--") name, value, hasValue = strings.Cut(body, "=") flag := m.byName[name] - return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil} + return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil, fuzzy: fuzzyMatch} } func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) { diff --git a/internal/pipeline/command_fallback.go b/internal/pipeline/command_fallback.go index 8d47e7e9..3395a007 100644 --- a/internal/pipeline/command_fallback.go +++ b/internal/pipeline/command_fallback.go @@ -24,7 +24,7 @@ func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs [ if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 { return rawArgs, nil, nil } - traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs) + traversalArgs, positions := argsForCommandTraversalWithPositionsForEngine(root, engine, rawArgs) entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs) if !ok { return rawArgs, nil, nil diff --git a/internal/pipeline/command_fallback_test.go b/internal/pipeline/command_fallback_test.go index 9c555807..9072421c 100644 --- a/internal/pipeline/command_fallback_test.go +++ b/internal/pipeline/command_fallback_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" 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" @@ -40,6 +41,24 @@ func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags( } } +func TestCrossPlatformCoverageCommandFallbackPrecedesLaterProtectedFlag(t *testing.T) { + root, _ := commandFallbackPipelineRoot() + root.PersistentFlags().Bool("yes", false, "") + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"}, + }) + engine.Register(reviewedProtectionTestHandler{}) + raw := []string{"chat", "+bad", "--types", "value"} + ctx, err := RunPreParseArgs(root, engine, raw) + if err != nil { + t.Fatal(err) + } + want := []string{"chat", "+good", "--types", "value"} + if ctx == nil || ctx.Command != "dws chat +good" || !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("fallback before protected flag = %#v, want args=%v", ctx, want) + } +} + func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) { root, executed := commandFallbackPipelineRoot() engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ @@ -346,6 +365,21 @@ func commandFallbackPipelineRoot() (*cobra.Command, *string) { group := &cobra.Command{Use: "group"} group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search")) chat.AddCommand(good, other, search, group) + corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + corecmd.ApplyGroupPolicy(chat, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) root.AddCommand(chat) return root, executed } diff --git a/internal/pipeline/command_resolution.go b/internal/pipeline/command_resolution.go index 6f15137d..5e92d6a1 100644 --- a/internal/pipeline/command_resolution.go +++ b/internal/pipeline/command_resolution.go @@ -4,28 +4,21 @@ 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/internal/corecmd" "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. +// Only groups whose typed framework policy rejects positionals and enables +// recovery are handled here. Commands that legitimately accept positionals +// remain Cobra's responsibility, without inference from tree shape or RunE. func validateUnresolvedCommand(target *cobra.Command, remaining []string) error { if target == nil || len(remaining) == 0 { return nil @@ -34,110 +27,27 @@ func validateUnresolvedCommand(target *cobra.Command, remaining []string) error 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 { + // Cobra registers its built-in `help [command]` lazily during ExecuteC, + // after this pre-parse traversal runs. Preserve that reserved root entry so + // `dws help auth` reaches Cobra instead of being classified as a typo. + if target == target.Root() && candidate == "help" { return nil } - minimumDistance := parent.SuggestionsMinimumDistance - if minimumDistance <= 0 { - minimumDistance = 2 + policy, declared, err := corecmd.GroupPolicyFor(target) + if err != nil { + return err } - 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) + if !declared || policy.Positionals != corecmd.PositionalsReject || policy.Recovery == corecmd.RecoveryDisabled { + return nil + } + + reason := cmdutil.ClassifyCommandResolution(target, candidate) + suggestions := cmdutil.SuggestSubcommands(target, candidate) + if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == corecmd.RecoveryDeep { + if deep := cmdutil.SuggestDescendantSubcommands(target, candidate); len(deep) > 0 { + suggestions = deep } } - sort.Strings(suggestions) - return suggestions + // ValidateGroupPolicy and the guard above leave only sibling/deep recovery. + return cmdutil.NewCommandResolution(target, candidate, reason, suggestions, "").Err() } diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index 31d7bff4..09056ba6 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -5,14 +5,45 @@ package pipeline import ( stderrors "errors" + "slices" "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" 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" ) +type reviewedProtectionTestHandler struct{} + +func (reviewedProtectionTestHandler) Name() string { return "reviewed-protection-test" } + +func (reviewedProtectionTestHandler) Phase() Phase { return PreParse } + +func (reviewedProtectionTestHandler) ResolveFlagProtection(command, flag string) (FlagProtection, bool) { + if (command == "dws aisearch" || command == "dws chat") && flag == "types" { + return FlagProtectionBlocked, true + } + return "", false +} + +func (reviewedProtectionTestHandler) Handle(ctx *Context) error { + if ctx.Command == "dws aisearch" || ctx.Command == "dws chat" { + ctx.ProtectFlag("types", FlagProtectionBlocked) + } + if ctx.Command == "dws aisearch enterprise" { + for index, argument := range ctx.Args { + if argument != "--leaf-typz" { + continue + } + ctx.Args[index] = "--leaf-type" + ctx.AddCorrection("reviewed-protection-test", PreParse, "leaf-type", argument, "--leaf-type", "test") + } + } + return nil +} + func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) { root := &cobra.Command{Use: "dws"} chat := &cobra.Command{Use: "chat"} @@ -21,6 +52,7 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF &cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}}, &cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}}, ) + applyTestNavigationGroup(chat) root.AddCommand(chat) err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"}) @@ -28,22 +60,29 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF 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"?` { + if structured.Hint != `Did you mean "dws chat +chat-messages"? (Run 'dws chat --help' for the full list)` { t.Fatalf("Hint = %q", structured.Hint) } - if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" { + if len(structured.Actions) != 2 || structured.Actions[0] != "Run 'dws chat --help' for the full list" || + structured.Actions[1] != "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) } + if structured.Details["input"] != "+chat-mesages" { + t.Fatalf("Details.input = %#v", structured.Details["input"]) + } + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || len(suggestions) != 1 || suggestions[0] != "+chat-messages" { + t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"]) + } } func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) { root := &cobra.Command{Use: "dws"} dev := &cobra.Command{Use: "dev"} app := &cobra.Command{Use: "app"} - cmdutil.MarkGroup(app) + applyTestNavigationGroup(app) app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) dev.AddCommand(app) root.AddCommand(dev) @@ -53,21 +92,37 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon 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" { + if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' for the full list" { t.Fatalf("Actions = %#v", structured.Actions) } + if structured.Details["input"] != "search" { + t.Fatalf("Details.input = %#v", structured.Details["input"]) + } + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || len(suggestions) != 0 { + t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"]) + } + legacy := &cobra.Command{Use: "legacy", RunE: cmdutil.GroupRunE} + applyTestNavigationGroup(legacy) + legacy.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(legacy) + requireCommandResolutionError(t, validateUnresolvedCommand(legacy, []string{"lisst", "--query", "x"}), "unknown_subcommand") positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}} root.AddCommand(positional) + positionalWithChild := &cobra.Command{Use: "query [term]", Args: cobra.ExactArgs(1), Run: func(*cobra.Command, []string) {}} + positionalWithChild.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(positionalWithChild) 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}, + "cobra help command": {target: root, remaining: []string{"help", "chat"}}, + "legitimate positional": {target: positional, remaining: []string{"+chat-messages"}}, + "positional close to child command": {target: positionalWithChild, remaining: []string{"lis"}}, + "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 { @@ -77,11 +132,25 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon } } +func TestCrossPlatformCoverageValidateUnresolvedCommandRejectsInvalidGroupPolicyMetadata(t *testing.T) { + group := &cobra.Command{Use: "broken"} + applyTestNavigationGroup(group) + for key := range group.Annotations { + group.Annotations[key] = "navigation_only|reject|unexpected" + } + + err := validateUnresolvedCommand(group, []string{"typo"}) + if err == nil || !strings.Contains(err.Error(), "invalid group recovery policy") { + 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) {}}) + applyTestNavigationGroup(chat) root.AddCommand(chat) ctx, err := RunPreParseArgs(root, nil, []string{ @@ -98,27 +167,164 @@ func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAnd validRoot := &cobra.Command{Use: "dws"} validChat := &cobra.Command{Use: "chat"} validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}) + applyTestNavigationGroup(validChat) 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 TestCrossPlatformCoverageRunPreParseArgsKeepsUnknownFlagValuesOutOfCommandResolution(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + aisearch := &cobra.Command{Use: "aisearch", RunE: func(*cobra.Command, []string) error { return nil }} + aisearch.Flags().String("query", "", "") + aisearch.AddCommand(&cobra.Command{Use: "enterprise", Run: func(*cobra.Command, []string) {}}) + corecmd.ApplyGroupPolicy(aisearch, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + root.AddCommand(aisearch) + engine := NewEngine() + engine.Register(reviewedProtectionTestHandler{}) + frozenParentTraversal := []string{"aisearch", "--"} + if got := argsForCommandTraversal(root, []string{"aisearch", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("default command traversal = %v", got) + } + + guarded := []string{"aisearch", "--types", "FIXTURE_VALUE"} + if got := argsForCommandTraversalForEngine(root, engine, guarded); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded flag traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guarded); err != nil || ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") { + t.Fatalf("guarded flag value entered command resolution: context=%#v error=%v", ctx, err) + } + + // A protected flag value can also be spelled exactly like a child. Command + // traversal must not guess which meaning the user intended. + guardedChildValue := []string{"aisearch", "--types", "enterprise"} + if got := argsForCommandTraversalForEngine(root, engine, guardedChildValue); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded child-name value traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guardedChildValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" { + t.Fatalf("guarded child-name value entered command resolution: context=%#v error=%v", ctx, err) + } + for _, guardedBooleanValue := range [][]string{ + {"aisearch", "--types", "false", "enterprise"}, + {"aisearch", "--types=false", "enterprise"}, + } { + if got := argsForCommandTraversalForEngine(root, engine, guardedBooleanValue); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded boolean-like value traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guardedBooleanValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" { + t.Fatalf("guarded boolean-like value selected wrong command: context=%#v error=%v", ctx, err) + } + } + guardedAfterLocalValue := []string{"aisearch", "--unknown-query", "Alice", "--types", "enterprise"} + if got := argsForCommandTraversalForEngine(root, engine, guardedAfterLocalValue); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded flag after local value traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guardedAfterLocalValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" { + t.Fatalf("guarded flag after local value selected wrong command: context=%#v error=%v", ctx, err) + } + legitimateParentFlag := []string{"aisearch", "--query", "Alice", "enterprise", "--types", "document", "--leaf-typz", "value"} + if ctx, err := RunPreParseArgs(root, engine, legitimateParentFlag); err != nil || ctx == nil || ctx.Command != "dws aisearch enterprise" || + !slices.Contains(ctx.Args, "--leaf-type") { + t.Fatalf("parent flag/value prevented leaf resolution: context=%#v error=%v", ctx, err) + } + + // Leading fuzzy root flags remain useful because no command path is + // ambiguous yet. Once a group has resolved, the same fuzzy spelling is + // preserved for the semantic flag pipeline instead of being consumed here. + if got := argsForCommandTraversalForEngine(root, engine, []string{"--yess", "aisearch", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("leading fuzzy boolean traversal = %v", got) + } + if got := argsForCommandTraversalForEngine(root, engine, []string{"aisearch", "--yess", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("fuzzy boolean after group traversal = %v", got) + } + if got := argsForCommandTraversalForEngine(root, engine, []string{"aisearch", "--yess", "false", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("fuzzy boolean value before child traversal = %v", got) + } + leafArgs := []string{"aisearch", "--yess", "enterprise", "--leaf-typz", "value"} + if ctx, err := RunPreParseArgs(root, engine, leafArgs); err != nil || ctx == nil || ctx.Command != "dws aisearch enterprise" || + !slices.Contains(ctx.Args, "--leaf-type") { + t.Fatalf("fuzzy root flag skipped leaf handler: context=%#v error=%v", ctx, err) + } + + _, err := RunPreParseArgs(root, engine, []string{"aisearch", "--yes", "enterprize"}) + requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + _, err = RunPreParseArgs(root, engine, []string{"aisearch", "enterprize", "--types", "enterprise"}) + structured := requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(suggestions, []string{"enterprise"}) { + t.Fatalf("typo before protected flag suggestions = %#v", structured.Details["suggestions"]) + } + _, err = RunPreParseArgs(root, engine, []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise"}) + structured = requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(suggestions, []string{"enterprise"}) { + t.Fatalf("typo after parent flag suggestions = %#v", structured.Details["suggestions"]) + } +} + +func TestCrossPlatformCoverageRunPreParseArgsUsesTypedDeepRecoveryPolicy(t *testing.T) { + root := &cobra.Command{Use: "dws"} + sheet := &cobra.Command{Use: "sheet"} + rangeGroup := &cobra.Command{Use: "range"} + rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + corecmd.ApplyGroupPolicy(rangeGroup, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + sheet.AddCommand( + rangeGroup, + &cobra.Command{Use: "ready", Run: func(*cobra.Command, []string) {}}, + ) + corecmd.ApplyGroupPolicy(sheet, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoveryDeep, + }) + root.AddCommand(sheet) + + ctx, err := RunPreParseArgs(root, nil, []string{"sheet", "read", "--sheet-id", "sheet-1"}) + if ctx == nil || ctx.Command != "dws sheet" { + t.Fatalf("Context = %#v, want unresolved target dws sheet", ctx) + } + structured := requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + if structured.Message != `unknown subcommand "read" for "dws sheet"` { + t.Fatalf("Message = %q", structured.Message) + } + if structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` { + t.Fatalf("Hint = %q", structured.Hint) + } + if !slices.Equal(structured.Actions, []string{"Run 'dws sheet --help' for the full list"}) { + t.Fatalf("Actions = %#v", structured.Actions) + } + if structured.Details["input"] != "read" { + t.Fatalf("Details.input = %#v", structured.Details["input"]) + } + suggestions, ok := structured.Details["suggestions"].([]string) + if !ok || !slices.Equal(suggestions, []string{"range read"}) { + t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"]) + } +} + 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"} { + for _, name := range []string{"aaa", "alpha", "alphi", "alphx"} { 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) + hint := cmdutil.FormatSubcommandSuggestionHint(group, cmdutil.SuggestSubcommands(group, "alpho"), "fallback") + if strings.Count(hint, `"dws demo `) != cmdutil.MaxCommandSuggestions || strings.Contains(hint, `"dws demo aaa"`) || !strings.Contains(hint, `"dws demo alphx"`) { + t.Fatalf("nearest bounded hint = %q", hint) } empty := &cobra.Command{Use: "empty"} root.AddCommand(empty) - if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" { + if got := cmdutil.FormatSubcommandSuggestionHint(empty, cmdutil.SuggestSubcommands(empty, "missing"), "fallback"); got != "fallback" { t.Fatalf("fallback hint = %q", got) } } @@ -130,14 +336,21 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat service.AddCommand(group) root.AddCommand(service) - if isExplicitShortcutCandidate(root, "+missing") { + if got := cmdutil.ClassifyCommandResolution(root, "+missing"); got != cmdutil.ResolutionUnknownSubcommand { t.Fatal("root command was treated as a shortcut service") } - if isExplicitShortcutCandidate(group, "+missing") { + if got := cmdutil.ClassifyCommandResolution(group, "+missing"); got != cmdutil.ResolutionUnknownSubcommand { 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.AddCommand(&cobra.Command{Use: "+known", Aliases: []string{"+alias"}, Run: func(*cobra.Command, []string) {}}) + if got := cmdutil.ClassifyCommandResolution(service, "+missing"); got != cmdutil.ResolutionUnknownShortcut { + t.Fatalf("top-level shortcut typo reason = %q", got) + } + if got := cmdutil.ClassifyCommandResolution(service, "+known"); got != cmdutil.ResolutionUnknownSubcommand { + t.Fatalf("exact shortcut child was reclassified as unresolved shortcut: %q", got) + } + if got := cmdutil.SuggestSubcommands(nil, "missing"); got != nil { + t.Fatalf("SuggestSubcommands(nil) = %#v", got) } service.SuggestionsMinimumDistance = 1 @@ -146,11 +359,64 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat SuggestFor: []string{"invented"}, Run: func(*cobra.Command, []string) {}, }) - if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" { + if got := cmdutil.SuggestSubcommands(service, "invented"); len(got) != 1 || got[0] != "canonical" { t.Fatalf("explicit SuggestFor suggestions = %#v", got) } } +func TestCrossPlatformCoverageHintSubCmdReturnsTypedRecovery(t *testing.T) { + root := &cobra.Command{Use: "dws"} + parent := &cobra.Command{Use: "chat"} + hint := cmdutil.HintSubCmd("send", "use: dws chat message send") + root.AddCommand(parent) + parent.AddCommand(hint) + + err := hint.RunE(hint, nil) + var structured *apperrors.Error + if !stderrors.As(err, &structured) { + t.Fatalf("HintSubCmd error = %T %v", err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" { + t.Fatalf("HintSubCmd error = %#v", structured) + } + if !strings.Contains(structured.Hint, "dws chat message send") || !strings.Contains(structured.Hint, "dws chat --help") { + t.Fatalf("HintSubCmd hint = %q", structured.Hint) + } + if !cmdutil.IsHintOnlyCommand(hint) { + t.Fatal("HintSubCmd lost hint-only identity") + } +} + +func TestCrossPlatformCoverageHintSubCmdAndGroupRunEDefensiveBranches(t *testing.T) { + standaloneHint := cmdutil.HintSubCmd("send", " \t ") + structured := requireCommandResolutionError(t, standaloneHint.RunE(standaloneHint, nil), "unknown_subcommand") + if structured.Hint != "Run 'send --help' for the full list" { + t.Fatalf("standalone empty hint = %q", structured.Hint) + } + + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + group.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(group) + structured = requireCommandResolutionError(t, cmdutil.GroupRunE(group, []string{"lisst"}), "unknown_subcommand") + if structured.Details["input"] != "lisst" { + t.Fatalf("GroupRunE() details.input = %#v", structured.Details["input"]) + } + suggestions, ok := structured.Details["suggestions"].([]string) + if !ok || len(suggestions) != 1 || suggestions[0] != "list" { + t.Fatalf("GroupRunE() details.suggestions = %#v", structured.Details["suggestions"]) + } + + var help strings.Builder + group.SetOut(&help) + if err := cmdutil.GroupRunE(group, nil); err != nil { + t.Fatalf("GroupRunE() help error = %v", err) + } + if output := help.String(); !strings.Contains(output, "Usage:") || !strings.Contains(output, "list") { + t.Fatalf("GroupRunE() help output = %q", output) + } +} + func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error { t.Helper() var structured *apperrors.Error @@ -162,3 +428,11 @@ func requireCommandResolutionError(t *testing.T, err error, reason string) *appe } return structured } + +func applyTestNavigationGroup(command *cobra.Command) { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) +} diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go index fe5d050b..2be3018c 100644 --- a/internal/pipeline/engine.go +++ b/internal/pipeline/engine.go @@ -106,6 +106,22 @@ func (e *Engine) HasHandlers(phase Phase) bool { return len(e.handlers[phase]) > 0 } +func (e *Engine) resolveFlagProtection(rawCommandPath, morphedFlag string) (FlagProtection, bool) { + if e == nil || rawCommandPath == "" || morphedFlag == "" { + return "", false + } + for _, handler := range e.handlers[PreParse] { + resolver, ok := handler.(FlagProtectionResolver) + if !ok { + continue + } + if protection, protected := resolver.ResolveFlagProtection(rawCommandPath, morphedFlag); protected { + return protection, true + } + } + return "", false +} + // RunPhase executes all handlers registered for the given phase in // chain order. If any handler returns an error, execution stops // immediately and the error is returned with the handler name as diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go index 02ed6c25..5bc8a70f 100644 --- a/internal/pipeline/engine_test.go +++ b/internal/pipeline/engine_test.go @@ -30,6 +30,20 @@ type stubHandler struct { callSeq *[]string } +type stubProtectionResolver struct { + *stubHandler + command string + flag string + protection FlagProtection +} + +func (h *stubProtectionResolver) ResolveFlagProtection(command, flag string) (FlagProtection, bool) { + if command == h.command && flag == h.flag { + return h.protection, true + } + return "", false +} + func (h *stubHandler) Name() string { return h.name } func (h *stubHandler) Phase() Phase { return h.phase } func (h *stubHandler) Handle(ctx *Context) error { @@ -79,6 +93,41 @@ func TestCrossPlatformCoverageCommandPathFallbackLookupHandlesNilEngine(t *testi } } +func TestCrossPlatformCoverageEngineResolvesReviewedFlagProtection(t *testing.T) { + var nilEngine *Engine + if protection, ok := nilEngine.resolveFlagProtection("dws demo", "types"); ok || protection != "" { + t.Fatalf("nil engine protection = %q, %t", protection, ok) + } + + engine := NewEngine() + engine.Register(newStub("ordinary", PreParse, nil)) + engine.Register(&stubProtectionResolver{ + stubHandler: newStub("protection", PreParse, nil), + command: "dws demo", + flag: "types", + protection: FlagProtectionBlocked, + }) + for _, test := range []struct { + name string + command string + flag string + want FlagProtection + protected bool + }{ + {name: "empty command", flag: "types"}, + {name: "empty flag", command: "dws demo"}, + {name: "unreviewed", command: "dws demo", flag: "other"}, + {name: "protected", command: "dws demo", flag: "types", want: FlagProtectionBlocked, protected: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, ok := engine.resolveFlagProtection(test.command, test.flag) + if got != test.want || ok != test.protected { + t.Fatalf("resolveFlagProtection(%q, %q) = %q, %t; want %q, %t", test.command, test.flag, got, ok, test.want, test.protected) + } + }) + } +} + func TestRegisterAndHandlerCount(t *testing.T) { e := NewEngine() e.Register(newStub("a", PreParse, nil)) diff --git a/internal/pipeline/handler.go b/internal/pipeline/handler.go index 7bd2a963..0de4edae 100644 --- a/internal/pipeline/handler.go +++ b/internal/pipeline/handler.go @@ -35,3 +35,12 @@ type Handler interface { // the chain, or nil to continue. Handle(ctx *Context) error } + +// FlagProtectionResolver exposes reviewed blocked/ambiguous flag names to +// command traversal. A PreParse handler that owns those decisions implements +// this optional interface so traversal and the handler chain use one semantic +// source instead of independently guessing whether an unknown flag may carry a +// value. +type FlagProtectionResolver interface { + ResolveFlagProtection(rawCommandPath, morphedFlag string) (FlagProtection, bool) +} diff --git a/internal/pipeline/handlers/semantic_alias.go b/internal/pipeline/handlers/semantic_alias.go index 2bd92396..fa3e3226 100644 --- a/internal/pipeline/handlers/semantic_alias.go +++ b/internal/pipeline/handlers/semantic_alias.go @@ -41,6 +41,31 @@ type SemanticAliasHandler struct { func (SemanticAliasHandler) Name() string { return "semantic-alias" } func (SemanticAliasHandler) Phase() pipeline.Phase { return pipeline.PreParse } +// ResolveFlagProtection lets command traversal consult the same reviewed +// blocked/ambiguous table used by Handle. This is intentionally a read-only +// projection: the handler remains responsible for recording the decision on +// the final Context. +func (h SemanticAliasHandler) ResolveFlagProtection(rawCommandPath, morphedFlag string) (pipeline.FlagProtection, bool) { + if h.Lookup == nil || rawCommandPath == "" || morphedFlag == "" { + return "", false + } + _, blocked, ambiguous, ok := h.Lookup(rawCommandPath) + if !ok { + return "", false + } + for _, name := range ambiguous { + if name == morphedFlag { + return pipeline.FlagProtectionAmbiguous, true + } + } + for _, name := range blocked { + if name == morphedFlag { + return pipeline.FlagProtectionBlocked, true + } + } + return "", false +} + func (h SemanticAliasHandler) Handle(ctx *pipeline.Context) error { if h.Lookup == nil || ctx.Command == "" || len(ctx.Args) == 0 { return nil diff --git a/internal/pipeline/handlers/semantic_alias_test.go b/internal/pipeline/handlers/semantic_alias_test.go index f59f4681..346c15b6 100644 --- a/internal/pipeline/handlers/semantic_alias_test.go +++ b/internal/pipeline/handlers/semantic_alias_test.go @@ -62,6 +62,34 @@ func TestSemanticAliasHandlerNameAndPhase(t *testing.T) { } } +func TestCrossPlatformCoverageSemanticAliasHandlerResolvesReviewedProtection(t *testing.T) { + h := newSemanticHandler() + if protection, ok := (SemanticAliasHandler{}).ResolveFlagProtection("dws demo cmd", "count"); ok || protection != "" { + t.Fatalf("nil lookup protection = %q, %t", protection, ok) + } + for _, test := range []struct { + name string + command string + flag string + want pipeline.FlagProtection + protected bool + }{ + {name: "empty command", flag: "count"}, + {name: "empty flag", command: "dws demo cmd"}, + {name: "blocked", command: "dws demo cmd", flag: "count", want: pipeline.FlagProtectionBlocked, protected: true}, + {name: "ambiguous", command: "dws demo cmd", flag: "user-id", want: pipeline.FlagProtectionAmbiguous, protected: true}, + {name: "unreviewed", command: "dws demo cmd", flag: "missing"}, + {name: "other command", command: "dws other", flag: "count"}, + } { + t.Run(test.name, func(t *testing.T) { + got, ok := h.ResolveFlagProtection(test.command, test.flag) + if got != test.want || ok != test.protected { + t.Fatalf("ResolveFlagProtection(%q, %q) = %q, %t; want %q, %t", test.command, test.flag, got, ok, test.want, test.protected) + } + }) + } +} + func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) { ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}} if err := newSemanticHandler().Handle(ctx); err != nil { diff --git a/internal/shortcut/agoal/agoal.go b/internal/shortcut/agoal/agoal.go new file mode 100644 index 00000000..1d258cbd --- /dev/null +++ b/internal/shortcut/agoal/agoal.go @@ -0,0 +1,750 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package agoal declares reviewed shortcuts for DingTalk Agoal. The public +// surface intentionally stays smaller than the atomic helper surface: only +// reads with strict response contracts and safe live proof are available. +package agoal + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const productAgoal = "agoal" + +var agoalLoadLocation = time.LoadLocation + +func agoalResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func requireAgoalSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, agoalResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明查询成功或结果确实为空") + } + rawSuccess, present := data["success"] + if !present { + return nil, agoalResponseError(operation, "missing_success", "响应缺少 success 布尔终态") + } + success, ok := rawSuccess.(bool) + if !ok { + return nil, agoalResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message, _ := data["message"].(string) + if strings.TrimSpace(message) == "" { + message = "服务明确返回 success=false" + } + return nil, agoalResponseError(operation, "remote_failure", message) + } + for _, key := range []string{"error", "errorCode", "error_code"} { + if value, found := data[key]; found && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" && fmt.Sprint(value) != "0" { + return nil, agoalResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key)) + } + } + return data, nil +} + +func requireAgoalList(data map[string]any, operation string, value any, idKeys ...string) ([]map[string]any, error) { + items, ok := value.([]any) + if !ok { + return nil, agoalResponseError(operation, "malformed_collection", "成功响应的业务集合不是数组") + } + out := make([]map[string]any, 0, len(items)) + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, agoalResponseError(operation, "malformed_item", fmt.Sprintf("业务集合第 %d 项不是对象", index)) + } + if agoalFirstString(item, idKeys...) == "" { + return nil, agoalResponseError(operation, "missing_item_id", fmt.Sprintf("业务集合第 %d 项缺少稳定身份字段", index)) + } + out = append(out, item) + } + return out, nil +} + +func agoalFirstString(item map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func agoalNonNegativeInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, typed >= 0 + case int64: + if typed < 0 || int64(int(typed)) != typed { + return 0, false + } + return int(typed), true + case float64: + if typed < 0 || math.Trunc(typed) != typed || typed > float64(^uint(0)>>1) { + return 0, false + } + return int(typed), true + case json.Number: + parsed, err := typed.Int64() + if err != nil || parsed < 0 || int64(int(parsed)) != parsed { + return 0, false + } + return int(parsed), true + default: + return 0, false + } +} + +func agoalReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func agoalUnavailableSafety(write bool) contract.SafetySpec { + if write { + return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} + } + return agoalReadSafety() +} + +func agoalListResult(collection, description string, paged bool, sensitivePaths ...string) *contract.ResultSpec { + properties := map[string]any{ + "count": map[string]any{"type": "integer", "description": "当前结果中的业务记录数量"}, + collection: map[string]any{ + "type": "array", "description": description, + "items": map[string]any{"type": "object", "description": "经稳定身份验证的 Agoal 业务记录", "additionalProperties": true}, + }, + } + required := []string{"count", collection} + if paged { + properties["page"] = map[string]any{"type": "integer", "description": "服务端确认的当前页码"} + properties["pageSize"] = map[string]any{"type": "integer", "description": "服务端确认的当前页容量"} + properties["totalCount"] = map[string]any{"type": "integer", "description": "服务端确认的全部匹配记录数"} + required = append(required, "page", "pageSize", "totalCount") + } + schema, _ := json.Marshal(map[string]any{ + "type": "object", "description": description, "additionalProperties": false, + "properties": properties, "required": required, + }) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: schema, + SensitivePaths: append([]string(nil), sensitivePaths...), + } +} + +var ReportStatisticsList = shortcut.Shortcut{ + Service: productAgoal, + Command: "+report-statistics-list", + Product: productAgoal, + Description: "查询周月报规则提交统计", + Intent: "当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_report_statistics_list", + CanonicalPath: "agoal.shortcut_report_statistics_list", CLIPath: "agoal +report-statistics-list", PrimaryCLIPath: "agoal +report-statistics-list", + }, + Description: "查询周月报规则提交统计", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed Agoal adapter validates the terminal success marker, collection type, every stable templateId, and the legal empty result before unified output.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询周月报规则提交统计", + UseWhen: []string{"当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。"}, + AvoidWhen: []string{"需要查看某一规则下的人员级提交详情时不要使用;该能力尚未完成隐私与分页闭环"}, + Examples: []string{"dws agoal +report-statistics-list --keyword "}, + }, + Result: agoalListResult("statistics", "周月报规则提交统计记录", false, + "statistics.title", "statistics.lastModifier", "statistics.content.name"), + }, + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "规则名称关键词;传入保证不匹配的关键词可得到合法空集合"}}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + raw, err := rt.CallMCPData(productAgoal, "list_report_statistics", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_report_statistics"); err != nil { + return err + } + items, err := requireAgoalList(raw, "agoal/list_report_statistics", raw["content"], "templateId") + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "statistics": items}) + }, +} + +var ObjectTemplateList = shortcut.Shortcut{ + Service: productAgoal, + Command: "+obj-template-list", + Product: productAgoal, + Description: "分页查询 Agoal 目标模板", + Intent: "当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_obj_template_list", + CanonicalPath: "agoal.shortcut_obj_template_list", CLIPath: "agoal +obj-template-list", PrimaryCLIPath: "agoal +obj-template-list", + }, + Description: "分页查询 Agoal 目标模板", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed Agoal adapter validates success, result array, stable template IDs, and explicit page/pageSize/totalCount facts without inventing cursor pagination.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询 Agoal 目标模板", + UseWhen: []string{"当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。"}, + AvoidWhen: []string{"需要新增或覆盖更新模板时不要使用;写能力尚未具备安全回滚合同"}, + Examples: []string{"dws agoal +obj-template-list --keyword --page 1 --page-size 20"}, + }, + Result: agoalListResult("templates", "Agoal 目标模板查询结果", true, + "templates.title", "templates.creator", "templates.dimensions.title"), + }, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "模板关键词;传入保证不匹配的关键词可得到合法空集合"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于等于 1") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + wantPage := rt.Int("page") + wantSize := rt.Int("page-size") + params := map[string]any{"page": wantPage, "pageSize": wantSize} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + raw, err := rt.CallMCPData(productAgoal, "list_obj_template", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_obj_template"); err != nil { + return err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return agoalResponseError("agoal/list_obj_template", "malformed_business_result", "成功响应 content 不是分页对象") + } + items, err := requireAgoalList(raw, "agoal/list_obj_template", content["result"], "id", "templateId") + if err != nil { + return err + } + page, pageOK := agoalNonNegativeInt(content["page"]) + pageSize, pageSizeOK := agoalNonNegativeInt(content["pageSize"]) + total, totalOK := agoalNonNegativeInt(content["totalCount"]) + if !pageOK || !pageSizeOK || !totalOK { + return agoalResponseError("agoal/list_obj_template", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount") + } + if page != wantPage || pageSize != wantSize { + return agoalResponseError("agoal/list_obj_template", "pagination_mismatch", "服务端分页回显与请求不一致") + } + if total < len(items) { + return agoalResponseError("agoal/list_obj_template", "impossible_total", "totalCount 小于当前页记录数") + } + return rt.Output(map[string]any{ + "count": len(items), "templates": items, + "page": page, "pageSize": pageSize, "totalCount": total, + }) + }, +} + +var ContractFields = shortcut.Shortcut{ + Service: productAgoal, Command: "+contract-fields", Product: productAgoal, + Description: "查询经营合约字段配置", + Intent: "当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_contract_fields", + CanonicalPath: "agoal.shortcut_contract_fields", CLIPath: "agoal +contract-fields", PrimaryCLIPath: "agoal +contract-fields", + }, + Description: "查询并严格筛选经营合约字段配置", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, an explicit content array, and a stable id on every field; the optional keyword is applied locally across reviewed scalar field metadata so known and guaranteed-zero cases are provable.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询经营合约字段配置", + UseWhen: []string{"当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。"}, + AvoidWhen: []string{"查询具体经营合约不要使用;当前合约列表和详情缺少非空安全 fixture"}, + Examples: []string{"dws agoal +contract-fields --keyword "}, + }, + Result: agoalListResult("fields", "经营合约字段配置", false), + }, + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "字段 ID、编码、标题、分类或类型关键词;本地大小写不敏感筛选"}}, + Execute: func(rt *shortcut.RuntimeContext) error { + raw, err := rt.CallMCPData(productAgoal, "list_op_contract_fields", map[string]any{}) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_op_contract_fields"); err != nil { + return err + } + fields, err := requireAgoalList(raw, "agoal/list_op_contract_fields", raw["content"], "id") + if err != nil { + return err + } + fields = filterAgoalItems(fields, rt.Str("keyword"), "id", "code", "title", "category", "type") + return rt.Output(map[string]any{"count": len(fields), "fields": fields}) + }, +} + +var UserRules = shortcut.Shortcut{ + Service: productAgoal, Command: "+user-rules", Product: productAgoal, + Description: "查询用户 Agoal 规则周期", + Intent: "当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_user_rules", + CanonicalPath: "agoal.shortcut_user_rules", CLIPath: "agoal +user-rules", PrimaryCLIPath: "agoal +user-rules", + }, + Description: "查询用户规则周期并可按稳定 ruleId 精确筛选", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, a content object, an explicit rules array, and stable rule IDs; the optional rule-id filter is local exact equality and provides a guaranteed-zero proof path.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询用户 Agoal 规则周期", + UseWhen: []string{"当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。"}, + AvoidWhen: []string{"需要目标正文时使用 +user-objectives;该目标列表当前缺少非空安全 fixture"}, + Examples: []string{"dws agoal +user-rules --rule-id "}, + }, + Result: agoalListResult("rules", "用户可见的 Agoal 规则与周期", false, + "rules.ruleName", "rules.periodFilter.currentPeriods.nameCn", "rules.periodFilter.historyPeriods.nameCn"), + }, + Flags: []shortcut.Flag{ + {Name: "user-id", Type: shortcut.FlagString, Desc: "可选用户稳定 ID;省略时查询本人"}, + {Name: "rule-id", Type: shortcut.FlagString, Desc: "可选稳定 ruleId;Shortcut 在严格验证完整数组后做精确等值筛选"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("user-id") { + params["dingUserId"] = rt.Str("user-id") + } + raw, err := rt.CallMCPData(productAgoal, "get_user_rules", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/get_user_rules"); err != nil { + return err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return agoalResponseError("agoal/get_user_rules", "malformed_business_result", "成功响应 content 不是规则对象") + } + rules, err := requireAgoalList(raw, "agoal/get_user_rules", content["rules"], "id") + if err != nil { + return err + } + wantRule := strings.TrimSpace(rt.Str("rule-id")) + if wantRule != "" { + filtered := make([]map[string]any, 0, 1) + for _, rule := range rules { + if agoalFirstString(rule, "id") == wantRule { + filtered = append(filtered, rule) + } + } + rules = filtered + } + return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + }, +} + +var ReportSubmitDetail = shortcut.Shortcut{ + Service: productAgoal, Command: "+report-submit-detail", Product: productAgoal, + Description: "分页查询周月报人员提交详情", + Intent: "当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_report_submit_detail", + CanonicalPath: "agoal.shortcut_report_submit_detail", CLIPath: "agoal +report-submit-detail", PrimaryCLIPath: "agoal +report-submit-detail", + }, + Description: "分页查询周月报人员提交详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, a paged content object, an explicit result array, stable nested user identity on every row, exact page/pageSize echo, and a valid totalCount. Because the lower keyword is ignored, keyword queries use one bounded full traversal with total consistency, duplicate, stall, and page-limit guards before local filtering; only reviewed personnel fields are projected and declared sensitive.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询周月报人员提交详情", + UseWhen: []string{"当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。"}, + AvoidWhen: []string{"只需规则级汇总时使用 +report-statistics-list;不要把人员级原始结果写入审计文档"}, + Examples: []string{"dws agoal +report-submit-detail --template-id --submit-state NOT_SUBMITTED --page 1 --page-size 20"}, + }, + Result: agoalListResult("submissions", "周月报人员提交详情", true, + "submissions.reportId", "submissions.user.dingUserId", "submissions.user.id", "submissions.user.name", "submissions.user.workNo"), + }, + Flags: []shortcut.Flag{ + {Name: "template-id", Type: shortcut.FlagString, Desc: "周月报规则模板稳定 ID", Required: true}, + {Name: "submit-state", Type: shortcut.FlagString, Desc: "提交状态", Required: true, Enum: []string{"ON_TIME", "LATE", "NOT_SUBMITTED"}}, + {Name: "query-date", Type: shortcut.FlagString, Desc: "可选 ISO-8601 日期或时间"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "可选人员名称关键词;可用于保证零命中"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于等于 1") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + if rt.Changed("query-date") { + if _, err := normalizeAgoalQueryDate(rt.Str("query-date")); err != nil { + return err + } + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + wantPage, wantSize := rt.Int("page"), rt.Int("page-size") + baseParams := map[string]any{ + "templateId": rt.Str("template-id"), "submitState": strings.ToUpper(rt.Str("submit-state")), + } + if rt.Changed("query-date") { + // Validate has already accepted this exact immutable Cobra value. + queryDate, _ := normalizeAgoalQueryDate(rt.Str("query-date")) + baseParams["queryDate"] = queryDate + } + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { + items, total, err := readAgoalSubmissionPage(rt, baseParams, wantPage, wantSize) + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": total}) + } + all, err := readAllAgoalSubmissions(rt, baseParams) + if err != nil { + return err + } + filtered := filterAgoalSubmissions(all, keyword) + start := len(filtered) + pageOffset := wantPage - 1 + if pageOffset <= len(filtered)/wantSize { + start = pageOffset * wantSize + } + end := start + wantSize + if end > len(filtered) { + end = len(filtered) + } + items := filtered[start:end] + return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": len(filtered)}) + }, +} + +const ( + agoalSubmissionReadPageSize = 100 + agoalSubmissionMaxPages = 20 +) + +func readAgoalSubmissionPage(rt *shortcut.RuntimeContext, baseParams map[string]any, page, pageSize int) ([]map[string]any, int, error) { + params := make(map[string]any, len(baseParams)+2) + for key, value := range baseParams { + params[key] = value + } + params["page"] = page + params["pageSize"] = pageSize + raw, err := rt.CallMCPData(productAgoal, "get_submit_detail", params) + if err != nil { + return nil, 0, err + } + if _, err := requireAgoalSuccess(raw, "agoal/get_submit_detail"); err != nil { + return nil, 0, err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_business_result", "成功响应 content 不是分页对象") + } + items, err := requireAgoalSubmissionList(content["result"]) + if err != nil { + return nil, 0, err + } + gotPage, pageOK := agoalNonNegativeInt(content["page"]) + gotSize, sizeOK := agoalNonNegativeInt(content["pageSize"]) + total, totalOK := agoalNonNegativeInt(content["totalCount"]) + if !pageOK || !sizeOK || !totalOK { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount") + } + if gotPage != page || gotSize != pageSize { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "pagination_mismatch", "服务端分页回显与请求不一致") + } + if total < len(items) { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "impossible_total", "totalCount 小于当前页记录数") + } + return items, total, nil +} + +func readAllAgoalSubmissions(rt *shortcut.RuntimeContext, baseParams map[string]any) ([]map[string]any, error) { + all := make([]map[string]any, 0) + seen := map[string]bool{} + wantTotal := -1 + for page := 1; page <= agoalSubmissionMaxPages; page++ { + items, total, err := readAgoalSubmissionPage(rt, baseParams, page, agoalSubmissionReadPageSize) + if err != nil { + return nil, err + } + if wantTotal < 0 { + wantTotal = total + } else if total != wantTotal { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_total_changed", "分页遍历期间 totalCount 发生变化") + } + for _, item := range items { + identity := agoalSubmissionIdentity(item) + if seen[identity] { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_duplicate", "分页遍历出现重复人员提交身份") + } + seen[identity] = true + all = append(all, item) + } + if len(all) == wantTotal { + return all, nil + } + if len(items) == 0 { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_stall", "尚有记录时服务返回空页,分页无法推进") + } + } + return nil, agoalResponseError("agoal/get_submit_detail", "readback_page_limit", "人员提交详情超过有界分页上限") +} + +func agoalSubmissionIdentity(item map[string]any) string { + if reportID := agoalFirstString(item, "reportId"); reportID != "" { + return "report:" + reportID + } + user, _ := item["user"].(map[string]any) + return "user:" + agoalFirstString(user, "dingUserId", "id") +} + +func filterAgoalSubmissions(items []map[string]any, keyword string) []map[string]any { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return items + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + user, _ := item["user"].(map[string]any) + if strings.Contains(strings.ToLower(agoalFirstString(user, "name")), keyword) { + out = append(out, item) + } + } + return out +} + +func filterAgoalItems(items []map[string]any, keyword string, keys ...string) []map[string]any { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return items + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + for _, key := range keys { + value, ok := item[key].(string) + if ok && strings.Contains(strings.ToLower(value), keyword) { + out = append(out, item) + break + } + } + } + return out +} + +func requireAgoalSubmissionList(value any) ([]map[string]any, error) { + rawItems, ok := value.([]any) + if !ok { + return nil, agoalResponseError("agoal/get_submit_detail", "malformed_collection", "成功响应 result 不是数组") + } + out := make([]map[string]any, 0, len(rawItems)) + for index, raw := range rawItems { + item, ok := raw.(map[string]any) + if !ok { + return nil, agoalResponseError("agoal/get_submit_detail", "malformed_item", fmt.Sprintf("人员提交详情第 %d 项不是对象", index)) + } + user, ok := item["user"].(map[string]any) + if !ok || agoalFirstString(user, "dingUserId", "id") == "" { + return nil, agoalResponseError("agoal/get_submit_detail", "missing_item_id", fmt.Sprintf("人员提交详情第 %d 项缺少稳定用户身份", index)) + } + projectedUser := map[string]any{} + for _, key := range []string{"dingUserId", "id", "name", "workNo"} { + if value, found := user[key]; found { + projectedUser[key] = value + } + } + projected := map[string]any{"user": projectedUser} + for _, key := range []string{"reportId", "publishTime"} { + if value, found := item[key]; found { + projected[key] = value + } + } + out = append(out, projected) + } + return out, nil +} + +func normalizeAgoalQueryDate(value string) (string, error) { + value = strings.TrimSpace(value) + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed.Format("2006-01-02"), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + location, loadErr := agoalLoadLocation("Asia/Shanghai") + if loadErr != nil { + location = time.FixedZone("CST", 8*60*60) + } + return parsed.In(location).Format("2006-01-02"), nil + } + return "", apperrors.NewValidation("--query-date 必须是 YYYY-MM-DD 或 RFC3339 时间") +} + +type unavailableAgoalSpec struct { + command string + description string + intent string + tool string + flags []shortcut.Flag + write bool + reason string +} + +func agoalStringFlag(name, description string, required bool) shortcut.Flag { + return shortcut.Flag{Name: name, Type: shortcut.FlagString, Desc: description, Required: required} +} + +func unavailableAgoalShortcut(spec unavailableAgoalSpec) shortcut.Shortcut { + flagNames := make([]string, 0, len(spec.flags)) + for _, flag := range spec.flags { + flagNames = append(flagNames, flag.Name) + } + unavailable := func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("该 Agoal Shortcut 当前 unavailable:" + spec.reason) + } + return shortcut.Shortcut{ + Service: productAgoal, Command: spec.command, Product: productAgoal, + Description: spec.description, Intent: spec.intent, + Risk: func() shortcut.Risk { + if spec.write { + return shortcut.RiskHighWrite + } + return shortcut.RiskRead + }(), + Safety: agoalUnavailableSafety(spec.write), + Flags: spec.flags, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: flagNames, + Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足", + }}, + SemanticDelta: "Atomic route " + spec.tool + " remains unavailable: " + spec.reason, + Validate: unavailable, + Execute: unavailable, + } +} + +var unavailableAgoalSpecs = []unavailableAgoalSpec{ + {command: "+strategy-list", description: "按范围查询战略解码", intent: "按部门或个人范围查询战略解码列表。", tool: "list_strategy_decodings", flags: []shortcut.Flag{ + {Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}}, + agoalStringFlag("scope-id", "部门或人员稳定 ID", true), + }, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"}, + {command: "+strategy-get", description: "查询战略解码详情", intent: "按 profileId 查询单个战略解码。", tool: "get_strategy_decoding_detail", flags: []shortcut.Flag{ + agoalStringFlag("profile-id", "战略解码稳定 ID", true), + }, reason: "raw 对不存在 profileId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"}, + {command: "+strategy-update", description: "覆盖更新战略解码", intent: "基于旧数据覆盖更新战略解码。", tool: "update_strategy_decoding", write: true, flags: []shortcut.Flag{ + agoalStringFlag("profile-id", "战略解码稳定 ID", true), agoalStringFlag("content", "完整实体 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"}, + {command: "+contract-list", description: "按范围查询经营合约", intent: "按部门或个人范围查询经营合约列表。", tool: "list_op_contracts", flags: []shortcut.Flag{ + {Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}}, + agoalStringFlag("scope-id", "部门或人员稳定 ID", true), + }, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"}, + {command: "+contract-get", description: "查询经营合约详情", intent: "按 contractId 查询单个经营合约。", tool: "get_op_contract_detail", flags: []shortcut.Flag{ + agoalStringFlag("contract-id", "经营合约稳定 ID", true), + }, reason: "raw 对不存在 contractId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"}, + {command: "+contract-update", description: "覆盖更新经营合约", intent: "基于旧数据覆盖经营合约全部维度。", tool: "update_op_contract", write: true, flags: []shortcut.Flag{ + agoalStringFlag("contract-id", "经营合约稳定 ID", true), agoalStringFlag("dimensions", "完整维度 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"}, + {command: "+scorecard-get", description: "查询计分卡详情", intent: "按部门与周期查询计分卡。", tool: "get_score_card_detail", flags: []shortcut.Flag{ + agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("dept-id", "部门稳定 ID", true), + }, reason: "exact+raw 对可访问部门和多个受控周期均返回 null 且无 success 终态,缺少非空计分卡 fixture"}, + {command: "+scorecard-entity-get", description: "查询计分卡实体详情", intent: "按计分卡与实体 ID 查询详情。", tool: "get_score_card_entity_detail", flags: []shortcut.Flag{ + agoalStringFlag("sc-id", "计分卡稳定 ID", true), agoalStringFlag("entity-id", "实体稳定 ID", true), + }, reason: "raw 对不存在计分卡/实体双 ID 返回 null 且无 success 终态,无法取得可安全读取的双 ID fixture"}, + {command: "+scorecard-update", description: "覆盖更新计分卡", intent: "基于旧数据覆盖更新计分卡内容。", tool: "update_score_card", write: true, flags: []shortcut.Flag{ + agoalStringFlag("dept-id", "部门稳定 ID", true), agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("id", "计分卡稳定 ID", true), + {Name: "tracking-period-type", Type: shortcut.FlagString, Desc: "跟踪周期", Required: true, Enum: []string{"MONTHLY", "QUARTERLY"}}, + agoalStringFlag("content", "完整计分卡 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读计分卡 fixture、全字段精确读回与恢复闭环"}, + {command: "+user-objectives", description: "查询用户目标", intent: "按用户、规则与周期查询目标。", tool: "list_user_objectives", flags: []shortcut.Flag{ + agoalStringFlag("user-id", "人员稳定 ID", true), agoalStringFlag("rule-id", "规则稳定 ID", true), agoalStringFlag("period-ids", "周期 ID CSV", true), + }, reason: "exact+raw 使用当前用户及全部可见规则/周期均只返回显式空数组,缺少已知非空目标 fixture"}, + {command: "+obj-template-upsert", description: "新增或覆盖更新目标模板", intent: "新增目标模板或基于旧数据覆盖更新模板。", tool: "create_or_update_obj_template", write: true, flags: []shortcut.Flag{ + agoalStringFlag("template-id", "更新时的模板稳定 ID", false), agoalStringFlag("title", "新增时的模板标题", false), agoalStringFlag("dimensions", "完整维度 JSON", true), + {Name: "objective-weight", Type: shortcut.FlagBool, Desc: "是否启用目标权重"}, + {Name: "dimension-weight", Type: shortcut.FlagBool, Desc: "是否启用维度权重"}, + {Name: "compute-by-weight", Type: shortcut.FlagBool, Desc: "维度是否参与计算"}, + }, reason: "raw 仅完成 dry-run payload;原子面无模板删除或版本恢复,无法验证 create/update 终态与零残留"}, +} + +func init() { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: productAgoal, + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询钉钉 Agoal 的周月报统计、人员提交详情、合约字段、用户规则与目标模板;其余能力按安全证据逐步开放", + UseWhen: []string{"用户明确处理钉钉 Agoal、战略解码、经营合约、计分卡、周月报统计或目标模板时使用"}, + AvoidWhen: []string{"用户处理 Lark OKR 的 Objective、Key Result、Progress 或 Alignment 时不要使用;两者数据模型和平台不同"}, + }, + }) + items := []shortcut.Shortcut{ReportStatisticsList, ObjectTemplateList, ContractFields, UserRules, ReportSubmitDetail} + for _, spec := range unavailableAgoalSpecs { + items = append(items, unavailableAgoalShortcut(spec)) + } + for index := range items { + if items[index].Availability != shortcut.AvailabilityUnavailable { + items[index].OutputRollout = output.RolloutUnifiedActive + } + } + shortcut.Register(items...) +} diff --git a/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go b/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..cc831ecc --- /dev/null +++ b/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,201 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package agoal + +import ( + "encoding/json" + "errors" + "math" + "strconv" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageAgoalScalarAndProjectionEdges(t *testing.T) { + for _, tc := range []struct { + name string + value any + want int + ok bool + }{ + {name: "int", value: 3, want: 3, ok: true}, + {name: "negative int", value: -1, want: -1}, + {name: "int64", value: int64(4), want: 4, ok: true}, + {name: "negative int64", value: int64(-1)}, + {name: "float", value: float64(5), want: 5, ok: true}, + {name: "negative float", value: float64(-1)}, + {name: "fraction", value: 1.5}, + {name: "huge float", value: math.MaxFloat64}, + {name: "json number", value: json.Number("6"), want: 6, ok: true}, + {name: "bad json number", value: json.Number("1.5")}, + {name: "negative json number", value: json.Number("-1")}, + {name: "wrong type", value: "7"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := agoalNonNegativeInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("agoalNonNegativeInt(%#v) = (%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + }) + } + + items := []map[string]any{{"id": "one", "name": "Known"}} + if got := filterAgoalItems(items, "", "name"); len(got) != 1 { + t.Fatalf("empty item filter = %#v", got) + } + if got := filterAgoalSubmissions(items, ""); len(got) != 1 { + t.Fatalf("empty submission filter = %#v", got) + } + if got := agoalSubmissionIdentity(map[string]any{"user": map[string]any{"id": "user"}}); got != "user:user" { + t.Fatalf("fallback identity = %q", got) + } + + for _, bad := range []any{ + nil, + []any{"bad"}, + []any{map[string]any{"user": "bad"}}, + []any{map[string]any{"user": map[string]any{"name": "missing identity"}}}, + } { + if _, err := requireAgoalSubmissionList(bad); err == nil { + t.Fatalf("malformed submission list accepted: %#v", bad) + } + } +} + +func TestCrossPlatformCoverageAgoalDateNormalizationEdges(t *testing.T) { + if got, err := normalizeAgoalQueryDate(" 2026-08-24 "); err != nil || got != "2026-08-24" { + t.Fatalf("date normalization = %q, %v", got, err) + } + if got, err := normalizeAgoalQueryDate("2026-08-24T01:00:00Z"); err != nil || got != "2026-08-24" { + t.Fatalf("RFC3339 normalization = %q, %v", got, err) + } + testseam.Swap(t, &agoalLoadLocation, func(string) (*time.Location, error) { + return nil, errors.New("fixture location failure") + }) + if got, err := normalizeAgoalQueryDate("2026-08-24T18:00:00Z"); err != nil || got != "2026-08-25" { + t.Fatalf("fallback timezone normalization = %q, %v", got, err) + } +} + +func TestCrossPlatformCoverageAgoalLeafFailureWiring(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response map[string]any + }{ + {name: "report transport", declaration: ReportStatisticsList, tool: "list_report_statistics"}, + {name: "report failure", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": false}}, + {name: "report conflicting error", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + {name: "report missing success", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"content": []any{}}}, + {name: "report malformed items", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "content": []any{"bad"}}}, + {name: "template transport", declaration: ObjectTemplateList, tool: "list_obj_template"}, + {name: "template failure", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": false}}, + {name: "template conflicting error", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "template missing success", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"content": map[string]any{}}}, + {name: "template content", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": []any{}}}, + {name: "template items", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}}, + {name: "template pagination", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}}, + {name: "template page mismatch", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 2, "pageSize": 20, "totalCount": 0}}}, + {name: "fields transport", declaration: ContractFields, tool: "list_op_contract_fields"}, + {name: "fields failure", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": false}}, + {name: "fields conflicting error", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + {name: "fields missing success", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"content": []any{}}}, + {name: "fields items", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "content": []any{"bad"}}}, + {name: "rules transport", declaration: UserRules, tool: "get_user_rules"}, + {name: "rules failure", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": false}}, + {name: "rules conflicting error", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "rules missing success", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"content": map[string]any{}}}, + {name: "rules content", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": []any{}}}, + {name: "rules items", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": map[string]any{"rules": []any{"bad"}}}}, + {name: "submission transport", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail"}, + {name: "submission failure", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": false}}, + {name: "submission conflicting error", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "submission missing success", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"content": map[string]any{}}}, + {name: "submission content", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": []any{}}}, + {name: "submission items", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}}, + {name: "submission pagination", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}}, + {name: "submission size mismatch", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 1, "pageSize": 21, "totalCount": 0}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &agoalCoverageCaller{} + if tc.response != nil { + caller.responses = map[string][]map[string]any{tc.tool: {tc.response}} + } + if err := runAgoalCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("failure fixture was accepted") + } + }) + } + + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{"rules": []any{map[string]any{"id": "rule"}}}}}, + }} + if err := runAgoalCoverage(t, UserRules, caller); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageAgoalBoundedPaginationAndDateExecution(t *testing.T) { + responses := make([]map[string]any, 0, agoalSubmissionMaxPages) + for page := 1; page <= agoalSubmissionMaxPages; page++ { + responses = append(responses, map[string]any{"success": true, "content": map[string]any{ + "page": page, "pageSize": agoalSubmissionReadPageSize, "totalCount": agoalSubmissionMaxPages + 1, + "result": []any{map[string]any{"reportId": "report-" + json.Number(string(rune('A'+page-1))).String(), "user": map[string]any{"id": "user"}}}, + }}) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{"get_submit_detail": responses}} + if err := runAgoalCoverage(t, ReportSubmitDetail, caller, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil { + t.Fatal("bounded pagination limit was accepted as complete") + } + + scanFailure := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"content": map[string]any{}}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, scanFailure, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil { + t.Fatal("scan page validation failure was accepted") + } + + dateCaller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 0, "result": []any{}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, dateCaller, + "--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "2026-08-24T01:00:00Z"); err != nil { + t.Fatal(err) + } + if got := dateCaller.calls[0].params["queryDate"]; got != "2026-08-24" { + t.Fatalf("queryDate = %#v", got) + } + + laterPage := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1, + "result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, laterPage, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", "--page", "2"); err != nil { + t.Fatal(err) + } + maximumPage := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1, + "result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, maximumPage, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", + "--page", strconv.Itoa(int(^uint(0)>>1))); err != nil { + t.Fatalf("maximum page must project an empty local page without overflowing: %v", err) + } +} diff --git a/internal/shortcut/agoal/agoal_hardening_test.go b/internal/shortcut/agoal/agoal_hardening_test.go new file mode 100644 index 00000000..9c3aba7c --- /dev/null +++ b/internal/shortcut/agoal/agoal_hardening_test.go @@ -0,0 +1,434 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package agoal + +import ( + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type agoalCoverageCall struct { + tool string + params map[string]any +} + +type agoalCoverageCaller struct { + responses map[string][]map[string]any + calls []agoalCoverageCall +} + +func (c *agoalCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, agoalCoverageCall{tool: tool, params: params}) + queue := c.responses[tool] + if len(queue) == 0 { + return nil, errors.New("unexpected agoal coverage call: " + tool) + } + payload := queue[0] + c.responses[tool] = queue[1:] + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (*agoalCoverageCaller) Format() string { return "json" } +func (*agoalCoverageCaller) DryRun() bool { return false } +func (*agoalCoverageCaller) Fields() string { return "" } +func (*agoalCoverageCaller) JQ() string { return "" } + +func runAgoalCoverage(t *testing.T, declaration shortcut.Shortcut, caller *agoalCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: productAgoal} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{productAgoal, declaration.Command}, args...)) + return root.Execute() +} + +func agoalRegistered(command string) (shortcut.Shortcut, bool) { + for _, item := range shortcut.All() { + if item.Service == productAgoal && item.Command == command { + return item, true + } + } + return shortcut.Shortcut{}, false +} + +func TestCrossPlatformCoverageAgoalStrictResponseValidation(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "empty", data: nil}, + {name: "missing success", data: map[string]any{"content": []any{}}}, + {name: "wrong success", data: map[string]any{"success": "true", "content": []any{}}}, + {name: "explicit failure", data: map[string]any{"success": false, "content": []any{}}}, + {name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := requireAgoalSuccess(tc.data, "agoal/test"); err == nil { + t.Fatal("malformed response was accepted") + } + }) + } + + for _, tc := range []struct { + name string + value any + }{ + {name: "missing collection", value: nil}, + {name: "wrong collection", value: map[string]any{}}, + {name: "bad element", value: []any{"bad"}}, + {name: "missing id", value: []any{map[string]any{"title": "template"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := requireAgoalList(map[string]any{"success": true}, "agoal/test", tc.value, "id"); err == nil { + t.Fatal("malformed collection was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageAgoalPublicReadsUseExactToolsAndShapes(t *testing.T) { + t.Run("report nonempty", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_report_statistics": {{"success": true, "content": []any{map[string]any{"templateId": "template"}}}}, + }} + if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "weekly"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_report_statistics", params: map[string]any{"keyword": "weekly"}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report legal zero", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_report_statistics": {{"success": true, "content": []any{}}}, + }} + if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "guaranteed-zero"); err != nil { + t.Fatal(err) + } + }) + + t.Run("template nonempty page", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_obj_template": {{"success": true, "content": map[string]any{ + "page": 2, "pageSize": 5, "totalCount": 6, + "result": []any{map[string]any{"id": "template"}}, + }}}, + }} + if err := runAgoalCoverage(t, ObjectTemplateList, caller, "--keyword", "target", "--page", "2", "--page-size", "5"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_obj_template", params: map[string]any{"keyword": "target", "page": 2, "pageSize": 5}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("template rejects false pagination", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_obj_template": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 0, + "result": []any{map[string]any{"id": "impossible"}}, + }}}, + }} + if err := runAgoalCoverage(t, ObjectTemplateList, caller); err == nil { + t.Fatal("impossible pagination total was accepted") + } + }) +} + +func TestCrossPlatformCoverageAgoalResidualReads(t *testing.T) { + t.Run("contract fields known and guaranteed zero", func(t *testing.T) { + items := []map[string]any{{"id": "field-1", "code": "revenue", "title": "Revenue"}} + if got := filterAgoalItems(items, "revenue", "id", "code", "title"); len(got) != 1 { + t.Fatalf("known field projection = %#v", got) + } + if got := filterAgoalItems(items, "guaranteed-zero", "id", "code", "title"); len(got) != 0 { + t.Fatalf("zero field projection = %#v", got) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_op_contract_fields": {{"success": true, "content": []any{items[0]}}}, + }} + if err := runAgoalCoverage(t, ContractFields, caller, "--keyword", "revenue"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_op_contract_fields", params: map[string]any{}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("user rules exact filter", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{ + "rules": []any{map[string]any{"id": "rule-1", "ruleName": "Rule"}}, + }}}, + }} + if err := runAgoalCoverage(t, UserRules, caller, "--user-id", "user", "--rule-id", "rule-1"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_user_rules", params: map[string]any{"dingUserId": "user"}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + zero := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{ + "rules": []any{map[string]any{"id": "rule-1"}}, + }}}, + }} + if err := runAgoalCoverage(t, UserRules, zero, "--rule-id", "guaranteed-zero"); err != nil { + t.Fatal(err) + } + }) + + t.Run("report submission page and privacy identity", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 1, + "result": []any{map[string]any{"reportId": nil, "user": map[string]any{"dingUserId": "user"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--page", "1", "--page-size", "20"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{ + "templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": 20, + }}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report keyword scans then filters locally", func(t *testing.T) { + items := []map[string]any{ + {"reportId": "report-1", "user": map[string]any{"dingUserId": "user-1", "name": "Known Person"}}, + {"reportId": "report-2", "user": map[string]any{"dingUserId": "user-2", "name": "Another Person"}}, + } + if got := filterAgoalSubmissions(items, "known"); len(got) != 1 { + t.Fatalf("known submission projection = %#v", got) + } + if got := filterAgoalSubmissions(items, "guaranteed-zero"); len(got) != 0 { + t.Fatalf("zero submission projection = %#v", got) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, + "result": []any{items[0], items[1]}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "guaranteed-zero", "--page", "1", "--page-size", "20"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{ + "templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": agoalSubmissionReadPageSize, + }}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report keyword scan rejects duplicate pages", func(t *testing.T) { + item := map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}} + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("duplicate submission pagination was accepted") + } + }) + + t.Run("report keyword scan rejects changing total", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 3, "result": []any{map[string]any{"reportId": "report-2", "user": map[string]any{"id": "user-2", "name": "Known"}}}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("changing pagination total was accepted") + } + }) + + t.Run("report keyword scan rejects stalled page", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("stalled pagination was accepted") + } + }) + + t.Run("report projection drops unreviewed personnel fields", func(t *testing.T) { + items, err := requireAgoalSubmissionList([]any{map[string]any{ + "reportId": "report", "unreviewed": "drop", + "user": map[string]any{"id": "user", "name": "Known", "email": "drop"}, + }}) + if err != nil { + t.Fatal(err) + } + if _, found := items[0]["unreviewed"]; found { + t.Fatal("unreviewed submission field was projected") + } + user := items[0]["user"].(map[string]any) + if _, found := user["email"]; found { + t.Fatal("unreviewed personnel field was projected") + } + }) + + for _, tc := range []struct { + name string + content map[string]any + }{ + {name: "missing stable user", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 1, "result": []any{map[string]any{"user": map[string]any{"name": "x"}}}}}, + {name: "page mismatch", content: map[string]any{"page": 2, "pageSize": 20, "totalCount": 0, "result": []any{}}}, + {name: "impossible total", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{map[string]any{"user": map[string]any{"id": "user"}}}}}, + } { + t.Run("report rejects "+tc.name, func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": tc.content}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "ON_TIME", "--page", "1", "--page-size", "20"); err == nil { + t.Fatal("malformed submission page was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageAgoalInvalidInputMakesZeroCalls(t *testing.T) { + for _, args := range [][]string{{"--page", "0"}, {"--page-size", "0"}, {"--page-size", "101"}} { + caller := &agoalCoverageCaller{} + err := runAgoalCoverage(t, ObjectTemplateList, caller, args...) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("invalid args %v error = %#v", args, err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid args %v called MCP: %#v", args, caller.calls) + } + } + for _, args := range [][]string{ + {"--template-id", "template", "--submit-state", "ON_TIME", "--page", "0"}, + {"--template-id", "template", "--submit-state", "ON_TIME", "--page-size", "101"}, + {"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "not-a-date"}, + } { + caller := &agoalCoverageCaller{} + if err := runAgoalCoverage(t, ReportSubmitDetail, caller, args...); err == nil { + t.Fatalf("invalid submission args %v were accepted", args) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid submission args %v called MCP: %#v", args, caller.calls) + } + } +} + +func TestCrossPlatformCoverageAgoalReviewedInventory(t *testing.T) { + total := 0 + public := 0 + unavailable := 0 + for _, item := range shortcut.All() { + if item.Service != productAgoal { + continue + } + total++ + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || shortcut.InPublicCatalog(item.Service, item.Command) { + t.Errorf("unavailable %s must remain hidden/non-public", item.Command) + } + continue + } + if shortcut.InPublicCatalog(item.Service, item.Command) { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability) + } + if item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout) + } + if item.Contract.Result == nil || item.Contract.Interface == nil || strings.TrimSpace(item.Contract.Description) == "" { + t.Errorf("public %s has incomplete Contract/Result", item.Command) + } + if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" { + t.Errorf("public %s has incomplete Safety", item.Command) + } + } + } + if total != 16 || public != 5 || unavailable != 11 { + t.Fatalf("agoal inventory total/public/unavailable = %d/%d/%d, want 16/5/11", total, public, unavailable) + } +} + +func TestCrossPlatformCoverageAgoalUnavailableCommandsMakeZeroCalls(t *testing.T) { + for _, spec := range unavailableAgoalSpecs { + declaration, found := agoalRegistered(spec.command) + if !found { + t.Fatalf("registered unavailable command missing: %s", spec.command) + } + args := make([]string, 0) + for _, flag := range declaration.Flags { + if !flag.Required { + continue + } + args = append(args, "--"+flag.Name) + switch flag.Name { + case "scope-type": + args = append(args, "PERSONAL") + case "tracking-period-type": + args = append(args, "MONTHLY") + case "submit-state": + args = append(args, "ON_TIME") + default: + if flag.Type != shortcut.FlagBool { + args = append(args, "fixture") + } + } + } + caller := &agoalCoverageCaller{} + err := runAgoalCoverage(t, declaration, caller, args...) + if err == nil || !strings.Contains(err.Error(), spec.reason) { + t.Errorf("%s unavailable error = %v", spec.command, err) + } + if len(caller.calls) != 0 { + t.Errorf("%s unavailable command called MCP: %#v", spec.command, caller.calls) + } + } +} diff --git a/internal/shortcut/aisearch/aisearch.go b/internal/shortcut/aisearch/aisearch.go new file mode 100644 index 00000000..eb68835a --- /dev/null +++ b/internal/shortcut/aisearch/aisearch.go @@ -0,0 +1,377 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package aisearch implements strict, task-oriented Shortcut projections over +// DingTalk's enterprise search APIs. +package aisearch + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const ( + aisearchCompositeReason = "Reviewed AiSearch Shortcut adapter: the executable CLI owns strict success, collection, item, stable-identity, and output validation; the task contract is stronger than a direct MCP passthrough." + aisearchZeroGapReason = "Live exact-leaf probes with a random zero-match marker still returned non-empty business objects, so the Shortcut cannot prove query relevance or a legitimate zero result." +) + +var aisearchReadSafety = contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", +} + +func aisearchResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中通过严格校验的命中数量"},"matches":{"type":"array","description":"通过 success、集合、元素和稳定身份校验的企业人员命中;该接口不发布分页或全局完整性承诺","items":{"type":"object","description":"带稳定身份且来源类型固定为 person 的企业人员命中","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"},"url":{"type":"string","minLength":1,"description":"稳定资源 URL"},"sourceType":{"type":"string","enum":["person"],"description":"已审核的企业人员来源类型"}},"required":["sourceType"],"anyOf":[{"required":["userId"]},{"required":["openDingTalkId"]},{"required":["url"]}],"additionalProperties":true}}},"required":["count","matches"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{ + "matches.userId", "matches.openDingTalkId", "matches.url", "matches.name", + "matches.mobile", "matches.phone", "matches.jobNumber", "matches.email", + "matches.department", "matches.position", "matches.duty", + }, + } +} + +func aisearchContract(command, description, intent string, available bool, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + availability := contract.InterfaceAvailable + reason := aisearchCompositeReason + if !available { + availability = contract.InterfaceUnavailable + reason = aisearchZeroGapReason + } + decl := corecmd.ContractDecl{ + Description: description, + Identity: contract.ToolIdentitySpec{ + ProductID: "aisearch", Name: name, CanonicalPath: "aisearch." + name, + CLIPath: "aisearch " + command, PrimaryCLIPath: "aisearch " + command, + }, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{ + "已有稳定资源 ID 时使用对应产品详情命令;不要把搜索命中当作资源写入回执", + "需要全局完结或可续页枚举时不要使用;下层未发布分页与终止证据", + }, + Examples: examples, + }, + Parameters: flags, + } + if available { + decl.Result = aisearchResult(description) + } + return decl +} + +// SearchPerson is the only public AiSearch Shortcut. Exact live evidence +// proves both a known non-empty user fixture and an explicit zero hit in the +// phone dimension using a value longer than any valid phone number. +var SearchPerson = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "aisearch", Command: "+search-person", Product: "aisearch", + Description: "按姓名、部门、职责或组织关系语义搜索企业人员", + Intent: "只知道人名、部门、职责、手机号、工号或上下级关系,需要获得可用于后续任务的人员稳定身份时使用。", + Risk: shortcut.RiskRead, + Safety: aisearchReadSafety, + Contract: aisearchContract( + "+search-person", + "按姓名、部门、职责或组织关系语义搜索企业人员", + "只知道人名、部门、职责、手机号、工号或上下级关系,需要获得可用于后续任务的人员稳定身份时使用。", + true, + []contract.ParamDecl{{Name: "query", Property: "keyword"}, {Name: "dimensions", Property: "dimension"}}, + `dws aisearch +search-person --query "张三" --dimensions name --format json`, + `dws aisearch +search-person --query "产品" --dimensions duty,department --format json`, + ), + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "人员搜索关键词;--query 不能为空白"}, + {Name: "dimensions", Type: shortcut.FlagStringSlice, Default: "all", Desc: "搜索维度;--dimensions 只能包含 all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,all 不能与其他维度并用"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空白"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"dimensions"}, Description: "--dimensions 只能包含 all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,all 不能与其他维度并用"}, + }, + Validate: validatePerson, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeSearchForSource(rt, "enterprise_person_search", map[string]any{ + "keyword": rt.Str("query"), "dimension": rt.StrSlice("dimensions"), + }, []string{"userId", "openDingTalkId", "url"}, "person") + }, +} + +// SearchEnterprise remains registered but unavailable. The IM slice can return +// stable identities for a known fixture, but a fresh random marker also +// returned a business object, so the service cannot prove a legitimate zero +// result or strict query relevance. +var SearchEnterprise = shortcut.Shortcut{ + OutputRollout: output.RolloutLegacyOnly, + Service: "aisearch", Command: "+search-enterprise", Product: "aisearch", + Description: "按主题搜索企业知识、消息、邮件与协作内容", + Intent: "需要按关键词搜索企业即时消息时使用;当前因随机查询仍返回业务对象,无法证明严格相关性与合法零结果,保持不可用。", + Risk: shortcut.RiskRead, + Safety: aisearchReadSafety, + Contract: aisearchContract( + "+search-enterprise", + "按主题搜索企业知识、消息、邮件与协作内容", + "需要按关键词搜索企业即时消息时使用;当前因随机查询仍返回业务对象,无法证明严格相关性与合法零结果,保持不可用。", + false, + []contract.ParamDecl{{Name: "queries"}, {Name: "types", Property: "searchTypes"}, {Name: "time-range", Property: "timeRange"}}, + `dws aisearch +search-enterprise --queries "发布方案" --types im --format json`, + ), + Flags: []shortcut.Flag{ + {Name: "queries", Type: shortcut.FlagStringSlice, Required: true, Desc: "即时消息关键词列表"}, + {Name: "types", Type: shortcut.FlagStringSlice, Default: "im", Desc: "已审核内容类型;--types 当前必须且只能为 im"}, + {Name: "time-range", Type: shortcut.FlagString, Desc: "服务端自然语言时间范围,如今天、本周或过去一月"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, + Description: "--types 当前必须且只能为 im", + }}, + Validate: validateEnterprise, + Execute: func(rt *shortcut.RuntimeContext) error { + return unavailableSearch("aisearch/search_enterprise") + }, +} + +// SearchBehavior remains registered but unavailable for the same zero-match +// relevance gap as SearchEnterprise. +var SearchBehavior = shortcut.Shortcut{ + OutputRollout: output.RolloutLegacyOnly, + Service: "aisearch", Command: "+search-behavior", Product: "aisearch", + Description: "搜索发送、创建、分享、编辑或接收等企业行为记录", + Intent: "用户明确询问谁发送、创建、分享、编辑或接收过什么时使用;当前因零命中相关性合同不足保持不可用。", + Risk: shortcut.RiskRead, + Safety: aisearchReadSafety, + Contract: aisearchContract( + "+search-behavior", + "搜索发送、创建、分享、编辑或接收等企业行为记录", + "用户明确询问谁发送、创建、分享、编辑或接收过什么时使用;当前因零命中相关性合同不足保持不可用。", + false, + []contract.ParamDecl{ + {Name: "queries"}, {Name: "types", Property: "searchTypes"}, + {Name: "behavior", Property: "behaviorType"}, {Name: "chat-scope", Property: "chatScope"}, + {Name: "time-range", Property: "timeRange"}, {Name: "direction"}, + }, + `dws aisearch +search-behavior --types mail --behavior send --direction "我->同事" --format json`, + ), + Flags: []shortcut.Flag{ + {Name: "queries", Type: shortcut.FlagStringSlice, Desc: "内容关键词列表;汇总场景可留空"}, + {Name: "types", Type: shortcut.FlagStringSlice, Default: "all", Desc: "内容类型:all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail"}, + {Name: "behavior", Type: shortcut.FlagString, Default: "all", Enum: []string{"all", "send", "create", "share", "edit", "receive"}, Desc: "行为类型"}, + {Name: "chat-scope", Type: shortcut.FlagString, Desc: "仅 IM 搜索时的会话或群范围"}, + {Name: "time-range", Type: shortcut.FlagString, Desc: "服务端自然语言时间范围"}, + {Name: "direction", Type: shortcut.FlagString, Desc: "交互方向,如我->同事"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, + Description: "--types 只能包含已审核内容类型,all 不能与其他类型并用;--chat-scope 仅能与 im 类型组合", + }}, + Validate: validateBehavior, + Execute: func(rt *shortcut.RuntimeContext) error { + return unavailableSearch("aisearch/search_enterprise_behavior") + }, +} + +func unavailableSearch(operation string) error { + return responsecheck.Error(operation, "capability_unavailable", aisearchZeroGapReason) +} + +func validatePerson(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return responsecheck.Error("aisearch/enterprise_person_search", "empty_query", "--query 不能为空白") + } + return validateValues("--dimensions", rt.StrSlice("dimensions"), map[string]bool{ + "all": true, "name": true, "department": true, "position": true, "duty": true, + "supervisor": true, "subordinate": true, "phone": true, "jobNumber": true, + }) +} + +func validateEnterprise(rt *shortcut.RuntimeContext) error { + queries := rt.StrSlice("queries") + if len(queries) == 0 { + return responsecheck.Error("aisearch/search_enterprise", "empty_query", "--queries 至少需要一个非空即时消息关键词") + } + for _, query := range queries { + if strings.TrimSpace(query) == "" { + return responsecheck.Error("aisearch/search_enterprise", "empty_query", "--queries 不能包含空关键词") + } + } + types := rt.StrSlice("types") + if len(types) != 1 || strings.TrimSpace(types[0]) != "im" { + return responsecheck.Error("aisearch/search_enterprise", "unsupported_search_type", "--types 当前必须且只能为 im;其他类型请使用 aisearch enterprise 原子命令") + } + return nil +} + +func validateBehavior(rt *shortcut.RuntimeContext) error { + types := rt.StrSlice("types") + if err := validateSearchTypes(types); err != nil { + return err + } + if rt.Str("chat-scope") != "" && !contains(types, "im") { + return responsecheck.Error("aisearch/search_enterprise_behavior", "invalid_chat_scope", "--chat-scope 仅能与 --types im 组合") + } + return nil +} + +func validateSearchTypes(values []string) error { + return validateValues("--types", values, map[string]bool{ + "all": true, "document": true, "im": true, "calendar": true, "todo": true, + "minute": true, "report": true, "image": true, "link": true, "notable": true, + "baike": true, "mail": true, + }) +} + +func validateValues(flag string, values []string, allowed map[string]bool) error { + if len(values) == 0 { + return responsecheck.Error("aisearch/validate", "empty_selector", flag+" 不能为空") + } + seen := make(map[string]bool, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || !allowed[value] { + return responsecheck.Error("aisearch/validate", "invalid_selector", fmt.Sprintf("%s 包含未支持值 %q", flag, value)) + } + if seen[value] { + return responsecheck.Error("aisearch/validate", "duplicate_selector", fmt.Sprintf("%s 包含重复值 %q", flag, value)) + } + seen[value] = true + } + if len(values) > 1 && seen["all"] { + return responsecheck.Error("aisearch/validate", "conflicting_selector", flag+" 中 all 不能与其他值并用") + } + return nil +} + +func contains(values []string, expected string) bool { + for _, value := range values { + if strings.TrimSpace(value) == expected { + return true + } + } + return false +} + +func executeSearchForSource(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys []string, expectedSource string) error { + operation := "aisearch/" + tool + data, err := rt.CallMCPReadData("aisearch", tool, params) + if err != nil { + return err + } + matches, err := projectSearch(data, operation, identityKeys) + if err != nil { + return err + } + for index, item := range matches { + if source := firstNonEmptyString(item, "sourceType"); source != expectedSource { + return responsecheck.Error(operation, "unexpected_item_source", fmt.Sprintf("响应 result[%d] 来源 %q 与已审核类型 %q 不一致", index, source, expectedSource)) + } + } + return rt.Output(map[string]any{"count": len(matches), "matches": matches}) +} + +func projectSearch(data map[string]any, operation string, identityKeys []string) ([]map[string]any, error) { + if hasConflictingError(data) { + return nil, responsecheck.Error(operation, "conflicting_error_fields", "响应同时声明 success=true 和非空错误字段") + } + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + seen := make(map[string]bool, len(items)) + for index, item := range items { + projected := make(map[string]any, len(item)) + for key, value := range item { + projected[key] = value + } + source, ok := projected["sourceType"].(string) + source = strings.TrimSpace(source) + if !ok || source == "" { + return nil, responsecheck.Error(operation, "missing_item_source", fmt.Sprintf("响应 result[%d] 缺少非空 sourceType", index)) + } + projected["sourceType"] = source + candidateKeys := append([]string(nil), identityKeys...) + for _, key := range []string{"userId", "openDingTalkId", "url", "openConversationId"} { + if !contains(candidateKeys, key) { + candidateKeys = append(candidateKeys, key) + } + } + for _, key := range candidateKeys { + raw, present := projected[key] + if !present || raw == nil { + delete(projected, key) + continue + } + value, valid := raw.(string) + if !valid { + return nil, responsecheck.Error(operation, "malformed_item_identity", fmt.Sprintf("响应 result[%d].%s 必须是字符串", index, key)) + } + value = strings.TrimSpace(value) + if value == "" { + delete(projected, key) + continue + } + projected[key] = value + } + identity := firstNonEmptyString(projected, identityKeys...) + if identity == "" { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("响应 result[%d] 缺少稳定身份", index)) + } + if seen[identity] { + return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("响应 result[%d] 重复稳定身份", index)) + } + seen[identity] = true + out = append(out, projected) + } + return out, nil +} + +func hasConflictingError(data map[string]any) bool { + envelope := data + if content, ok := data["content"].(map[string]any); ok { + envelope = content + } + success, ok := envelope["success"].(bool) + if !ok || !success { + return false + } + if message := firstNonEmptyString(envelope, "errorMsg", "errorMessage"); message != "" { + return true + } + if code, present := envelope["errorCode"]; present && code != nil { + switch typed := code.(type) { + case string: + return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0" + case float64: + return typed != 0 + default: + return true + } + } + return false +} + +func firstNonEmptyString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func init() { + shortcut.Register(SearchPerson, SearchEnterprise, SearchBehavior) +} diff --git a/internal/shortcut/aisearch/aisearch_cross_platform_coverage_test.go b/internal/shortcut/aisearch/aisearch_cross_platform_coverage_test.go new file mode 100644 index 00000000..755b5ae4 --- /dev/null +++ b/internal/shortcut/aisearch/aisearch_cross_platform_coverage_test.go @@ -0,0 +1,369 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package aisearch + +import ( + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type aisearchCaller struct { + product string + tool string + args map[string]any + payload string + err error + calls int +} + +func (caller *aisearchCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.product, caller.tool, caller.args = product, tool, args + caller.calls++ + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.payload}}}, nil +} + +func TestCrossPlatformCoverageAiSearchValidationAndExecutionBranches(t *testing.T) { + for _, test := range []struct { + values []string + wantErr bool + }{ + {nil, true}, + {[]string{"unknown"}, true}, + {[]string{"name", "name"}, true}, + {[]string{"all", "name"}, true}, + {[]string{"name", "duty"}, false}, + } { + if err := validateValues("--dimensions", test.values, map[string]bool{"all": true, "name": true, "duty": true}); (err != nil) != test.wantErr { + t.Errorf("validateValues(%v) err=%v wantErr=%v", test.values, err, test.wantErr) + } + } + + enterprise := func(queries, types []string) error { + cmd := &cobra.Command{Use: "+search-enterprise"} + cmd.Flags().StringSlice("queries", queries, "") + cmd.Flags().StringSlice("types", types, "") + return validateEnterprise(shortcut.RuntimeContextForTest(cmd, SearchEnterprise)) + } + if enterprise(nil, []string{"im"}) == nil || enterprise([]string{" "}, []string{"im"}) == nil || enterprise([]string{"fixture"}, []string{"document"}) == nil || enterprise([]string{"fixture"}, []string{"im"}) != nil { + t.Fatal("enterprise validation branch matrix drift") + } + behavior := &cobra.Command{Use: "+search-behavior"} + behavior.Flags().StringSlice("types", []string{"im"}, "") + behavior.Flags().String("chat-scope", "fixture", "") + if err := validateBehavior(shortcut.RuntimeContextForTest(behavior, SearchBehavior)); err != nil { + t.Fatalf("valid IM behavior selector: %v", err) + } + + caller := &aisearchCaller{payload: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + declaration := SearchPerson + declaration.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: "+search-person"} + command.Flags().String("query", "fixture", "") + command.Flags().StringSlice("dimensions", []string{"name"}, "") + runtime := shortcut.RuntimeContextForTest(command, declaration) + caller.err = errors.New("transport") + if err := executeSearchForSource(runtime, "enterprise_person_search", map[string]any{"keyword": "fixture"}, []string{"userId"}, "person"); err == nil { + t.Fatal("transport failure returned success") + } + caller.err = nil + caller.payload = `{"success":true}` + if err := executeSearchForSource(runtime, "enterprise_person_search", map[string]any{"keyword": "fixture"}, []string{"userId"}, "person"); err == nil { + t.Fatal("projection failure returned success") + } + caller.err = errors.New("transport") + if err := executeSearchForSource(runtime, "search_enterprise", nil, []string{"openConversationId"}, "im"); err == nil { + t.Fatal("source transport failure returned success") + } + caller.err = nil + caller.payload = `{"success":true}` + if err := executeSearchForSource(runtime, "search_enterprise", nil, []string{"openConversationId"}, "im"); err == nil { + t.Fatal("source projection failure returned success") + } +} + +func TestCrossPlatformCoverageAiSearchConflictingEvidenceAndIdentityNormalization(t *testing.T) { + for index, data := range []map[string]any{ + {"content": map[string]any{"success": true, "errorCode": float64(1), "result": []any{}}}, + {"success": true, "errorCode": map[string]any{}, "result": []any{}}, + } { + if !hasConflictingError(data) { + t.Errorf("conflicting evidence %d not detected", index) + } + } + for index, data := range []map[string]any{ + {"success": false, "errorCode": "FAILED"}, + {"success": true, "errorCode": float64(0)}, + {"success": true, "errorCode": "0"}, + } { + if hasConflictingError(data) { + t.Errorf("non-conflicting evidence %d rejected", index) + } + } + items, err := projectSearch(map[string]any{"success": true, "result": []any{map[string]any{ + "sourceType": " person ", "userId": " stable ", "openDingTalkId": " ", "url": nil, + }}}, "aisearch/test", []string{"userId"}) + if err != nil || len(items) != 1 || items[0]["sourceType"] != "person" || items[0]["userId"] != "stable" { + t.Fatalf("identity normalization failed: items=%v err=%v", items, err) + } + if _, present := items[0]["openDingTalkId"]; present { + t.Fatal("blank optional identity was not omitted") + } + if _, err := projectSearch(map[string]any{"success": true, "result": []any{map[string]any{"sourceType": " ", "userId": "stable"}}}, "aisearch/test", []string{"userId"}); err == nil { + t.Fatal("blank sourceType returned success") + } +} + +func (*aisearchCaller) Format() string { return "json" } +func (*aisearchCaller) DryRun() bool { return false } +func (*aisearchCaller) Fields() string { return "" } +func (*aisearchCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageAiSearchRejectsFalseSuccessAndBadCollections(t *testing.T) { + for identity, value := range map[string]string{ + "userId": "user-fixture", "openDingTalkId": "open-fixture", "url": "https://example.invalid/user-fixture", + } { + valid := map[string]any{"success": true, "result": []any{map[string]any{"sourceType": "person", identity: value}}} + items, err := projectSearch(valid, "aisearch/test", []string{"userId", "openDingTalkId", "url"}) + if err != nil || len(items) != 1 || items[0][identity] != value { + t.Fatalf("valid %s identity rejected: items=%v err=%v", identity, items, err) + } + } + explicitEmpty := map[string]any{"success": true, "result": []any{}} + items, err := projectSearch(explicitEmpty, "aisearch/test", []string{"userId"}) + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty rejected: items=%v err=%v", items, err) + } + + broken := []map[string]any{ + {}, + {"result": []any{}}, + {"success": "true", "result": []any{}}, + {"success": false, "result": []any{}}, + {"success": true}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{}}}, + {"success": true, "result": []any{map[string]any{"sourceType": "person"}}}, + {"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": 1}}}, + {"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": "same"}, map[string]any{"sourceType": "person", "userId": "same"}}}, + {"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": "good", "openDingTalkId": map[string]any{}}}}, + {"success": true, "errorCode": "REMOTE_ERROR", "result": []any{}}, + {"success": true, "errorMsg": "conflict", "result": []any{}}, + } + for index, data := range broken { + if got, projectErr := projectSearch(data, "aisearch/test", []string{"userId"}); projectErr == nil { + t.Errorf("broken response %d returned success: %v", index, got) + } + } +} + +func TestCrossPlatformCoverageAiSearchSelectorsFailBeforeRemoteCall(t *testing.T) { + caller := &aisearchCaller{payload: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + cmd := &cobra.Command{Use: "+search-person"} + cmd.Flags().StringSlice("dimensions", []string{"all", "name"}, "") + if err := validatePerson(shortcut.RuntimeContextForTest(cmd, SearchPerson)); err == nil { + t.Fatal("all plus another dimension must fail") + } + if caller.calls != 0 { + t.Fatalf("invalid selector made %d remote calls", caller.calls) + } + blank := &cobra.Command{Use: "+search-person"} + blank.Flags().String("query", " ", "") + blank.Flags().StringSlice("dimensions", []string{"name"}, "") + if err := validatePerson(shortcut.RuntimeContextForTest(blank, SearchPerson)); err == nil { + t.Fatal("blank person query must fail") + } + if caller.calls != 0 { + t.Fatalf("blank person query made %d remote calls", caller.calls) + } + + behavior := &cobra.Command{Use: "+search-behavior"} + behavior.Flags().StringSlice("types", []string{"document"}, "") + behavior.Flags().String("chat-scope", "fixture-chat", "") + if err := validateBehavior(shortcut.RuntimeContextForTest(behavior, SearchBehavior)); err == nil { + t.Fatal("chat scope without im must fail") + } + if caller.calls != 0 { + t.Fatalf("invalid behavior selector made %d remote calls", caller.calls) + } + + enterprise := &cobra.Command{Use: "+search-enterprise"} + enterprise.Flags().StringSlice("queries", []string{""}, "") + enterprise.Flags().StringSlice("types", []string{"im"}, "") + if err := validateEnterprise(shortcut.RuntimeContextForTest(enterprise, SearchEnterprise)); err == nil { + t.Fatal("empty enterprise query must fail") + } + if caller.calls != 0 { + t.Fatalf("empty enterprise query made %d remote calls", caller.calls) + } +} + +func TestCrossPlatformCoverageUnavailableAiSearchMakesNoRemoteCall(t *testing.T) { + caller := &aisearchCaller{payload: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + for _, declaration := range []shortcut.Shortcut{SearchEnterprise, SearchBehavior} { + if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil || !strings.Contains(err.Error(), "cannot prove query relevance") { + t.Errorf("%s unavailable error = %v", declaration.Command, err) + } + } + if caller.calls != 0 { + t.Fatalf("unavailable searches made %d remote calls", caller.calls) + } +} + +func TestCrossPlatformCoverageAiSearchExactShortcutMapping(t *testing.T) { + caller := &aisearchCaller{payload: `{"success":true,"errorCode":null,"errorMsg":"","result":[{"sourceType":"person","userId":"stable-user"}]}`} + helpers.InitDepsForTest(t, caller) + + declaration := SearchPerson + declaration.OutputRollout = output.RolloutLegacyOnly + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "aisearch"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{"aisearch", "+search-person", "--query", "fixture", "--dimensions", "name,duty"}) + if err := root.Execute(); err != nil { + t.Fatalf("exact shortcut execution: %v", err) + } + if caller.calls != 1 || caller.product != "aisearch" || caller.tool != "enterprise_person_search" { + t.Fatalf("call = count:%d product:%q tool:%q", caller.calls, caller.product, caller.tool) + } + if caller.args["keyword"] != "fixture" { + t.Fatalf("keyword = %#v", caller.args["keyword"]) + } + dimensions, ok := caller.args["dimension"].([]string) + if !ok || strings.Join(dimensions, ",") != "name,duty" { + t.Fatalf("dimensions = %#v", caller.args["dimension"]) + } + + caller.calls = 0 + caller.payload = `{"success":true,"result":[{"sourceType":"im","url":"https://example.invalid/non-person-resource"}]}` + driftCommand := &cobra.Command{Use: "+search-person"} + driftCommand.Flags().String("query", "fixture", "") + driftCommand.Flags().StringSlice("dimensions", []string{"name"}, "") + if err := declaration.Execute(shortcut.RuntimeContextForTest(driftCommand, declaration)); err == nil || !strings.Contains(err.Error(), "来源") { + t.Fatalf("non-person source was not rejected: %v", err) + } + if caller.calls != 1 || caller.tool != "enterprise_person_search" { + t.Fatalf("source drift call = count:%d tool:%q", caller.calls, caller.tool) + } +} + +func TestCrossPlatformCoverageAiSearchEnterpriseHardenedMapperSourceGuard(t *testing.T) { + caller := &aisearchCaller{payload: `{"success":true,"result":[{"sourceType":"im","openConversationId":"cid-fixture"}]}`} + helpers.InitDepsForTest(t, caller) + + declaration := SearchEnterprise + declaration.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: "+search-enterprise"} + command.Flags().StringSlice("queries", []string{"fixture"}, "") + command.Flags().StringSlice("types", []string{"im"}, "") + command.Flags().String("time-range", "过去一周", "") + runtime := shortcut.RuntimeContextForTest(command, declaration) + if err := executeSearchForSource(runtime, "search_enterprise", map[string]any{ + "queries": []string{"fixture"}, "searchTypes": []string{"im"}, "timeRange": "过去一周", + }, []string{"openConversationId", "url"}, "im"); err != nil { + t.Fatalf("hardened enterprise mapper: %v", err) + } + if caller.calls != 1 || caller.product != "aisearch" || caller.tool != "search_enterprise" { + t.Fatalf("call = count:%d product:%q tool:%q", caller.calls, caller.product, caller.tool) + } + if got := caller.args["searchTypes"]; len(got.([]string)) != 1 || got.([]string)[0] != "im" { + t.Fatalf("searchTypes = %#v", got) + } + if caller.args["timeRange"] != "过去一周" { + t.Fatalf("timeRange = %#v", caller.args["timeRange"]) + } + + caller.payload = `{"success":true,"result":[{"sourceType":"minute","openConversationId":"cid-fixture"}]}` + badCommand := &cobra.Command{Use: "+search-enterprise"} + badCommand.Flags().StringSlice("queries", []string{"fixture"}, "") + badCommand.Flags().StringSlice("types", []string{"im"}, "") + badCommand.Flags().String("time-range", "", "") + if err := executeSearchForSource( + shortcut.RuntimeContextForTest(badCommand, declaration), + "search_enterprise", + map[string]any{"queries": []string{"fixture"}, "searchTypes": []string{"im"}}, + []string{"openConversationId", "url"}, + "im", + ); err == nil || !strings.Contains(err.Error(), "来源") { + t.Fatalf("source drift error = %v", err) + } +} + +func TestCrossPlatformCoverageAiSearchCatalogAndContracts(t *testing.T) { + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "aisearch" { + registered[item.Command] = item + } + } + if len(registered) != 3 { + t.Fatalf("registered AiSearch shortcuts = %d, want 3", len(registered)) + } + for _, command := range []string{"+search-person", "+search-enterprise", "+search-behavior"} { + item := registered[command] + if item.Command == "" { + t.Fatalf("missing %s", command) + } + if strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Safety", command) + } + } + public := registered["+search-person"] + if public.OutputRollout != output.RolloutUnifiedActive || public.Contract.Result == nil || len(public.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("search-person lacks unified typed sensitive Result: %#v", public) + } + var schema map[string]any + if err := json.Unmarshal(public.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" { + t.Fatalf("search-person result schema invalid: schema=%v err=%v", schema, err) + } + matches := schema["properties"].(map[string]any)["matches"].(map[string]any) + items := matches["items"].(map[string]any) + sourceType := items["properties"].(map[string]any)["sourceType"].(map[string]any) + if got, ok := sourceType["enum"].([]any); !ok || len(got) != 1 || got[0] != "person" { + t.Fatalf("search-person sourceType enum = %#v", sourceType["enum"]) + } + identityAlternatives, ok := items["anyOf"].([]any) + if !ok || len(identityAlternatives) != 3 { + t.Fatalf("search-person stable identity anyOf = %#v", items["anyOf"]) + } + for index, identity := range []string{"userId", "openDingTalkId", "url"} { + branch, branchOK := identityAlternatives[index].(map[string]any) + required, requiredOK := branch["required"].([]any) + if !branchOK || !requiredOK || len(required) != 1 || required[0] != identity { + t.Fatalf("search-person stable identity anyOf[%d] = %#v, want required %q", index, identityAlternatives[index], identity) + } + } + if registered["+search-person"].Hidden || registered["+search-person"].Availability != shortcut.AvailabilityAvailable { + t.Fatalf("search-person visibility/availability = hidden:%v availability:%q", registered["+search-person"].Hidden, registered["+search-person"].Availability) + } + for _, command := range []string{"+search-enterprise", "+search-behavior"} { + item := registered[command] + if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil { + t.Errorf("%s must remain hidden/unavailable without Result: hidden=%v availability=%q rollout=%q result=%v", command, item.Hidden, item.Availability, item.OutputRollout, item.Contract.Result) + } + } +} diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index c5c20534..14066fdd 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -24,6 +24,7 @@ package aitable import ( "encoding/json" "fmt" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -59,6 +60,19 @@ func parseJSONObject(flag, s string) (map[string]any, error) { return m, nil } +// trimNonEmpty returns a copy of ss with each element trimmed of surrounding +// whitespace and all empty entries dropped. Used to sanitize string-slice +// flags (e.g. --field-ids) before they reach the downstream MCP tool. +func trimNonEmpty(ss []string) []string { + out := ss[:0] + for _, s := range ss { + if t := strings.TrimSpace(s); t != "" { + out = append(out, t) + } + } + return out +} + // 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 diff --git a/internal/shortcut/aitable/datasource.go b/internal/shortcut/aitable/datasource.go new file mode 100644 index 00000000..ea9c3835 --- /dev/null +++ b/internal/shortcut/aitable/datasource.go @@ -0,0 +1,534 @@ +// 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. + +// This file declares the 7 datasource shortcuts for the aitable service: +// create / update / sync / sync-status / get-config / list-sources / +// get-fields. Each maps 1:1 onto a datasource MCP tool on the "aitable" +// server. datasource-type is passed through without CLI-side enum +// validation; source-config is validated as a JSON object but passed +// through as a raw string (MCP types it as string). + +package aitable + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// ───────────────────────────────────────────────────────────── +// datasource: 数据源同步管理(server: aitable) +// ───────────────────────────────────────────────────────────── + +// DatasourceCreate 为指定 AI 表格创建数据源同步配置(create_datasource)。 +var DatasourceCreate = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-create", + Product: serverMain, + Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。", + Intent: "当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_create", + CanonicalPath: "aitable.shortcut_datasource_create", + CLIPath: "aitable +datasource-create", + PrimaryCLIPath: "aitable +datasource-create", + }, + Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。", + UseWhen: []string{"当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。"}, + AvoidWhen: []string{ + "目标 Base 已有数据源表且仅需更新配置时(改用 +datasource-update)", + "仅需触发已有数据源表的同步时(改用 +datasource-sync)", + }, + Examples: []string{ + `dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + `dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID(通过 +base-list / +base-search 获取)", Required: true}, + {Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true}, + {Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(是否保留已删除字段,默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入", Required: true}, + {Name: "auto", Type: shortcut.FlagBool, Desc: "是否开启自动同步,默认 false;创建新数据源表时该字段始终下发给下游"}, + {Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"}, + {Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)。不传时使用下游默认自动同步策略"}, + }, + Tips: []string{ + `dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + `dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil { + return err + } + params := map[string]any{ + "baseId": rt.Str("base-id"), + "datasourceType": rt.Str("datasource-type"), + "sourceConfig": rt.Str("source-config"), + } + params["auto"] = rt.Bool("auto") + if rt.Changed("field-ids") { + raw := rt.StrSlice("field-ids") + cleaned := trimNonEmpty(raw) + if len(cleaned) == 0 { + return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入") + } + params["fieldIds"] = cleaned + } + if rt.Changed("auto-sync-setting") { + v := rt.Str("auto-sync-setting") + if v == "" { + return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入") + } + if _, err := parseJSONObject("auto-sync-setting", v); err != nil { + return err + } + params["autoSyncSetting"] = v + } + data, err := rt.CallMCPData(serverMain, "create_datasource", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// DatasourceUpdate 更新已有数据源表的同步配置(update_datasource_config)。 +var DatasourceUpdate = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-update", + Product: serverMain, + Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。", + Intent: "当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_update", + CanonicalPath: "aitable.shortcut_datasource_update", + CLIPath: "aitable +datasource-update", + PrimaryCLIPath: "aitable +datasource-update", + }, + Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。", + UseWhen: []string{"当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。"}, + AvoidWhen: []string{ + "需要创建新数据源表时(改用 +datasource-create)", + "仅需触发同步不改配置时(改用 +datasource-sync)", + }, + Examples: []string{ + `dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`, + `dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "已存在的数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的数据源表)", Required: true}, + {Name: "source-config", Type: shortcut.FlagString, Desc: "可选。新的源配置 JSON 字符串。不传时保持原有配置不变;传入时整体覆盖。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入"}, + {Name: "auto", Type: shortcut.FlagBool, Desc: "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变"}, + {Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"}, + {Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)"}, + }, + Tips: []string{ + `dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`, + `dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + if rt.Changed("source-config") { + if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil { + return err + } + params["sourceConfig"] = rt.Str("source-config") + } + if rt.Changed("auto") { + params["auto"] = rt.Bool("auto") + } + if rt.Changed("field-ids") { + raw := rt.StrSlice("field-ids") + cleaned := trimNonEmpty(raw) + if len(cleaned) == 0 { + return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入") + } + params["fieldIds"] = cleaned + } + if rt.Changed("auto-sync-setting") { + v := rt.Str("auto-sync-setting") + if v == "" { + return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入") + } + if _, err := parseJSONObject("auto-sync-setting", v); err != nil { + return err + } + params["autoSyncSetting"] = v + } + if !rt.Changed("source-config") && !rt.Changed("auto") && !rt.Changed("field-ids") && !rt.Changed("auto-sync-setting") { + return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 +datasource-sync") + } + data, err := rt.CallMCPData(serverMain, "update_datasource_config", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// DatasourceSync 对数据源表触发一次手动同步(run_datasource_sync)。 +var DatasourceSync = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-sync", + Product: serverMain, + Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。", + Intent: "当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_sync", + CanonicalPath: "aitable.shortcut_datasource_sync", + CLIPath: "aitable +datasource-sync", + PrimaryCLIPath: "aitable +datasource-sync", + }, + Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。", + UseWhen: []string{"当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。"}, + AvoidWhen: []string{ + "需要创建新数据源表时(改用 +datasource-create)", + "需要更新配置时(改用 +datasource-update)", + }, + Examples: []string{ + `dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`, + `dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "table-ids", Type: shortcut.FlagStringSlice, Desc: "待触发同步的数据源表 ID 列表(通过 +base-get / +table-list 获取,仅允许 sync=true 的表,1-5 个)", Required: true}, + }, + Tips: []string{ + `dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`, + `dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + rawTableIDs := rt.StrSlice("table-ids") + tableIDs := trimNonEmpty(rawTableIDs) + if len(tableIDs) < 1 || len(tableIDs) > 5 { + return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs)) + } + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableIds": tableIDs, + } + data, err := rt.CallMCPData(serverMain, "run_datasource_sync", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// DatasourceSyncStatus 查询数据源表同步任务状态(get_datasource_sync_status)。 +var DatasourceSyncStatus = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-sync-status", + Product: serverMain, + Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。", + Intent: "当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_sync_status", + CanonicalPath: "aitable.shortcut_datasource_sync_status", + CLIPath: "aitable +datasource-sync-status", + PrimaryCLIPath: "aitable +datasource-sync-status", + }, + Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。", + UseWhen: []string{"当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。"}, + AvoidWhen: []string{ + "需要触发同步时(改用 +datasource-sync)", + }, + Examples: []string{ + `dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true}, + {Name: "task-ids", Type: shortcut.FlagStringSlice, Desc: "待查询的同步任务 ID 列表(由 +datasource-sync / +datasource-create / +datasource-update 返回)。单次最多 5 个,超出请拆分多次调用。", Required: true}, + }, + Tips: []string{ + `dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + rawTaskIDs := rt.StrSlice("task-ids") + taskIDs := trimNonEmpty(rawTaskIDs) + if len(taskIDs) < 1 || len(taskIDs) > 5 { + return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(taskIDs)) + } + params["taskIds"] = taskIDs + data, err := rt.CallMCPData(serverMain, "get_datasource_sync_status", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// DatasourceGetConfig 获取数据源表的同步配置信息(get_datasource_config)。 +var DatasourceGetConfig = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-get-config", + Product: serverMain, + Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。", + Intent: "当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_get_config", + CanonicalPath: "aitable.shortcut_datasource_get_config", + CLIPath: "aitable +datasource-get-config", + PrimaryCLIPath: "aitable +datasource-get-config", + }, + Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。", + UseWhen: []string{"当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。"}, + AvoidWhen: []string{ + "需要更新配置时(改用 +datasource-update)", + "需要查询同步任务状态时(改用 +datasource-sync-status)", + }, + Examples: []string{ + `dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true}, + }, + Tips: []string{ + `dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "tableId": rt.Str("table-id"), + } + data, err := rt.CallMCPData(serverMain, "get_datasource_config", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// DatasourceListSources 列出指定数据源类型可用的来源(list_datasource_sources)。 +var DatasourceListSources = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-list-sources", + Product: serverMain, + Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。", + Intent: "当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_list_sources", + CanonicalPath: "aitable.shortcut_datasource_list_sources", + CLIPath: "aitable +datasource-list-sources", + PrimaryCLIPath: "aitable +datasource-list-sources", + }, + Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。", + UseWhen: []string{"当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。"}, + AvoidWhen: []string{ + "需要创建数据源表时(改用 +datasource-create)", + "需要获取数据源字段结构时(改用 +datasource-get-fields)", + }, + Examples: []string{ + `dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true}, + }, + Tips: []string{ + `dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{ + "baseId": rt.Str("base-id"), + "datasourceType": rt.Str("datasource-type"), + } + data, err := rt.CallMCPData(serverMain, "list_datasource_sources", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +// DatasourceGetFields 获取指定数据源来源的可同步字段列表(get_datasource_fields)。 +var DatasourceGetFields = shortcut.Shortcut{ + Service: "aitable", + Command: "+datasource-get-fields", + Product: serverMain, + Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。", + Intent: "当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_datasource_get_fields", + CanonicalPath: "aitable.shortcut_datasource_get_fields", + CLIPath: "aitable +datasource-get-fields", + PrimaryCLIPath: "aitable +datasource-get-fields", + }, + Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。", + UseWhen: []string{"当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。"}, + AvoidWhen: []string{ + "需要列出可用来源时(改用 +datasource-list-sources)", + "需要创建数据源表时(改用 +datasource-create)", + }, + Examples: []string{ + `dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + }, + }, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true}, + {Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。结构同 +datasource-create 的 --source-config,需含 processCode、name、iconUrl、url、dataType 及对应时间字段", Required: true}, + }, + Tips: []string{ + `dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil { + return err + } + params := map[string]any{ + "baseId": rt.Str("base-id"), + "datasourceType": rt.Str("datasource-type"), + "sourceConfig": rt.Str("source-config"), + } + data, err := rt.CallMCPData(serverMain, "get_datasource_fields", params) + if err != nil { + return err + } + return rt.Output(data) + }, +} + +func init() { + shortcut.Register(withReviewedAITableShortcutContracts( + DatasourceCreate, + DatasourceUpdate, + DatasourceSync, + DatasourceSyncStatus, + DatasourceGetConfig, + DatasourceListSources, + DatasourceGetFields, + )...) +} diff --git a/internal/shortcut/aitable/datasource_coverage_test.go b/internal/shortcut/aitable/datasource_coverage_test.go new file mode 100644 index 00000000..2b68c59f --- /dev/null +++ b/internal/shortcut/aitable/datasource_coverage_test.go @@ -0,0 +1,367 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type datasourceCoverageCaller struct { + err error + resp string + argLog []map[string]any +} + +func (c *datasourceCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) { + c.argLog = append(c.argLog, args) + if c.err != nil { + return nil, c.err + } + text := c.resp + if text == "" { + text = `{"status":"success","data":{}}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (c *datasourceCoverageCaller) Format() string { return "json" } +func (c *datasourceCoverageCaller) DryRun() bool { return false } +func (c *datasourceCoverageCaller) Fields() string { return "" } +func (c *datasourceCoverageCaller) JQ() string { return "" } + +func runDatasourceShortcutCLI(t *testing.T, caller *datasourceCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"aitable"}, args...)) + return root.Execute() +} + +// ── DatasourceCreate error paths ───────────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", "not-json") + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config parse error", err) + } +} + +func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`, + "--auto-sync-setting", "not-json") + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting parse error", err) + } +} + +func TestCrossPlatformCoverageDatasourceCreatePropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`) + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} + +func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyFieldIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`, + "--field-ids", "") + if err == nil || !strings.Contains(err.Error(), "field-ids") { + t.Fatalf("error = %v, want field-ids empty error", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceCreateRejectsWhitespaceOnlyFieldIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`, + "--field-ids", " , , ") + if err == nil || !strings.Contains(err.Error(), "field-ids") { + t.Fatalf("error = %v, want field-ids empty error", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyAutoSyncSetting(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`, + "--auto-sync-setting", "") + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting empty error", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceCreatePassesFieldIDsThrough(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`, + "--field-ids", "fld1,fld2") + if err != nil { + t.Fatalf("create with valid --field-ids should succeed: %v", err) + } + if len(caller.argLog) != 1 { + t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog)) + } + got, _ := caller.argLog[0]["fieldIds"].([]string) + if len(got) != 2 || got[0] != "fld1" || got[1] != "fld2" { + t.Fatalf("fieldIds = %#v, want [fld1 fld2]", got) + } +} + +// ── DatasourceUpdate error paths ───────────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", + "--source-config", "not-json") + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config parse error", err) + } +} + +func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", + "--auto-sync-setting", "not-json") + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting parse error", err) + } +} + +func TestCrossPlatformCoverageDatasourceUpdateRejectsNoChanges(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1") + if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") { + t.Fatalf("error = %v, want no-changes error", err) + } +} + +func TestCrossPlatformCoverageDatasourceUpdatePropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", "--auto") + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} + +func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyFieldIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", + "--field-ids", "") + if err == nil || !strings.Contains(err.Error(), "field-ids") { + t.Fatalf("error = %v, want field-ids empty error", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceUpdateRejectsWhitespaceOnlyFieldIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", + "--field-ids", " , ") + if err == nil || !strings.Contains(err.Error(), "field-ids") { + t.Fatalf("error = %v, want field-ids empty error", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyAutoSyncSetting(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", + "--auto-sync-setting", "") + if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") { + t.Fatalf("error = %v, want auto-sync-setting empty error", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceUpdatePassesFieldIDsThrough(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", + "--field-ids", "fldA,fldB,fldC") + if err != nil { + t.Fatalf("update with valid --field-ids should succeed: %v", err) + } + if len(caller.argLog) != 1 { + t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog)) + } + got, _ := caller.argLog[0]["fieldIds"].([]string) + if len(got) != 3 || got[0] != "fldA" || got[1] != "fldB" || got[2] != "fldC" { + t.Fatalf("fieldIds = %#v, want [fldA fldB fldC]", got) + } +} + +// ── DatasourceSync error paths ──────────────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceSyncShortcutRejectsTooManyTableIDs(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-sync", "--base-id", "BASE1", "--table-ids", "T1,T2,T3,T4,T5,T6") + if err == nil || !strings.Contains(err.Error(), "1-5") { + t.Fatalf("error = %v, want 1-5 limit", err) + } +} + +func TestCrossPlatformCoverageDatasourceSyncShortcutPropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-sync", "--base-id", "BASE1", "--table-ids", "TBL1") + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} + +// ── DatasourceSyncStatus error paths ───────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceSyncStatusShortcutRejectsTooManyTaskIDs(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1", + "--task-ids", "T1,T2,T3,T4,T5,T6") + if err == nil || !strings.Contains(err.Error(), "1-5") { + t.Fatalf("error = %v, want 1-5 limit", err) + } +} + +func TestCrossPlatformCoverageDatasourceSyncStatusShortcutPropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1", + "--task-ids", "TASK1") + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} + +// ── DatasourceSync whitespace trimming ────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceSyncRejectsWhitespaceOnlyTableIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-sync", "--base-id", "BASE1", "--table-ids", " , ") + if err == nil || (!strings.Contains(err.Error(), "table-ids") && !strings.Contains(err.Error(), "1-5")) { + t.Fatalf("error = %v, want table-ids rejection", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when whitespace-only table-ids is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceSyncTrimsWhitespaceFromTableIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-sync", "--base-id", "BASE1", "--table-ids", " TBL1 , TBL2 ") + if err != nil { + t.Fatalf("sync with whitespace-padded --table-ids should succeed: %v", err) + } + if len(caller.argLog) != 1 { + t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog)) + } + got, _ := caller.argLog[0]["tableIds"].([]string) + if len(got) != 2 || got[0] != "TBL1" || got[1] != "TBL2" { + t.Fatalf("tableIds = %#v, want [TBL1 TBL2]", got) + } +} + +// ── DatasourceSyncStatus whitespace trimming ─────────────────────────────── + +func TestCrossPlatformCoverageDatasourceSyncStatusRejectsWhitespaceOnlyTaskIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1", + "--task-ids", " , ") + if err == nil || (!strings.Contains(err.Error(), "task-ids") && !strings.Contains(err.Error(), "1-5")) { + t.Fatalf("error = %v, want task-ids rejection", err) + } + if len(caller.argLog) != 0 { + t.Fatalf("MCP should not be called when whitespace-only task-ids is rejected, got %d calls", len(caller.argLog)) + } +} + +func TestCrossPlatformCoverageDatasourceSyncStatusTrimsWhitespaceFromTaskIDs(t *testing.T) { + caller := &datasourceCoverageCaller{} + err := runDatasourceShortcutCLI(t, caller, + "+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1", + "--task-ids", " TASK1 , TASK2 ") + if err != nil { + t.Fatalf("sync-status with whitespace-padded --task-ids should succeed: %v", err) + } + if len(caller.argLog) != 1 { + t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog)) + } + got, _ := caller.argLog[0]["taskIds"].([]string) + if len(got) != 2 || got[0] != "TASK1" || got[1] != "TASK2" { + t.Fatalf("taskIds = %#v, want [TASK1 TASK2]", got) + } +} + +// ── DatasourceGetConfig error path ─────────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceGetConfigShortcutPropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-get-config", "--base-id", "BASE1", "--table-id", "TBL1") + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} + +// ── DatasourceListSources error path ───────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceListSourcesShortcutPropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-list-sources", "--base-id", "BASE1", "--datasource-type", "OA") + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} + +// ── DatasourceGetFields error paths ────────────────────────────────────────── + +func TestCrossPlatformCoverageDatasourceGetFieldsShortcutRejectsInvalidSourceConfig(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{}, + "+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", "not-json") + if err == nil || !strings.Contains(err.Error(), "source-config") { + t.Fatalf("error = %v, want source-config parse error", err) + } +} + +func TestCrossPlatformCoverageDatasourceGetFieldsShortcutPropagatesCallMCPError(t *testing.T) { + err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")}, + "+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA", + "--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`) + if err == nil || !strings.Contains(err.Error(), "mcp-error") { + t.Fatalf("error = %v, want mcp-error", err) + } +} diff --git a/internal/shortcut/builtin/aisearch_semantic_catalog_test.go b/internal/shortcut/builtin/aisearch_semantic_catalog_test.go new file mode 100644 index 00000000..f579d59e --- /dev/null +++ b/internal/shortcut/builtin/aisearch_semantic_catalog_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAiSearchSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_aisearch.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "aisearch" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "aisearch" { + registered[item.Command] = item + } + } + if len(registered) != 3 || len(source.Shortcuts) != 3 { + t.Fatalf("registered/catalog = %d/%d, want 3/3", len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + for command, record := range source.Shortcuts { + item, ok := registered[command] + if !ok { + t.Errorf("catalog contains stale %s", command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk { + t.Errorf("%s semantic facts drifted", command) + } + if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Contract/Safety", command) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("aisearch", command) || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil { + t.Errorf("%s public availability drift", command) + } + } else { + unavailable++ + if availability != shortcut.AvailabilityUnavailable || !item.Hidden || shortcut.InPublicCatalog("aisearch", command) || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil { + t.Errorf("%s unavailable visibility drift", command) + } + } + } + if public != 1 || unavailable != 2 { + t.Fatalf("public/unavailable = %d/%d, want 1/2", public, unavailable) + } +} diff --git a/internal/shortcut/builtin/aitable_semantic_catalog_test.go b/internal/shortcut/builtin/aitable_semantic_catalog_test.go index 9f29f8aa..31a3e812 100644 --- a/internal/shortcut/builtin/aitable_semantic_catalog_test.go +++ b/internal/shortcut/builtin/aitable_semantic_catalog_test.go @@ -36,8 +36,8 @@ func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurfa } registered[item.Command] = item } - if len(registered) != 93 || len(source.Shortcuts) != 93 { - t.Fatalf("registered/catalog = %d/%d, want 93/93", len(registered), len(source.Shortcuts)) + if len(registered) != 100 || len(source.Shortcuts) != 100 { + t.Fatalf("registered/catalog = %d/%d, want 100/100", len(registered), len(source.Shortcuts)) } var missing, stale []string diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index aca6aa6e..880e0b78 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -25,22 +25,29 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" // Service packages — each registers its shortcuts from init(). + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/agoal" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendar" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/contact" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devapp" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devdoc" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/ding" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/doc" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/drive" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/hrbrain" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/live" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/mail" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutes" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/oa" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/pat" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/report" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/whiteboard" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki" "github.com/spf13/cobra" diff --git a/internal/shortcut/builtin/contact_semantic_catalog_test.go b/internal/shortcut/builtin/contact_semantic_catalog_test.go new file mode 100644 index 00000000..076dc9fa --- /dev/null +++ b/internal/shortcut/builtin/contact_semantic_catalog_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageContactSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_contact.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "contact" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "contact" { + registered[item.Command] = item + } + } + if len(registered) != 16 || len(source.Shortcuts) != 16 { + t.Fatalf("registered/catalog = %d/%d, want 16/16", len(registered), len(source.Shortcuts)) + } + wantCompatibilityVisible := map[string]bool{"+list-roles": true} + public, unavailable := 0, 0 + for command, record := range source.Shortcuts { + item, ok := registered[command] + if !ok { + t.Errorf("catalog contains stale %s", command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk { + t.Errorf("%s semantic facts drifted", command) + } + if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Contract/Safety", command) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("contact", command) || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil { + t.Errorf("%s public availability drift", command) + } + } else { + unavailable++ + if availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("contact", command) || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil { + t.Errorf("%s unavailable visibility drift", command) + } + if record.CompatibilityVisible { + if item.Hidden || !item.CompatibilityVisible { + t.Errorf("%s compatibility visibility drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible { + t.Errorf("%s hidden unavailable visibility drift", command) + } + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + if command == "+search-mobile" || command == "+by-mobile" { + if !strings.Contains(record.SemanticDelta, "专用手机号精确查询接口") { + t.Errorf("%s must describe the dedicated exact mobile interface", command) + } + if strings.Contains(record.SemanticDelta, "关键词能力") || strings.Contains(record.SemanticDelta, "关键词数组") { + t.Errorf("%s must not describe the retired keyword-search route", command) + } + } + if command == "+list-roster-fields" || command == "+get-roster" { + if !strings.Contains(record.SemanticDelta, "历史 CLI 继续透传原 MCP 调用与真实错误") { + t.Errorf("%s must preserve the historical direct CLI execution path", command) + } + if strings.Contains(record.SemanticDelta, "0 次远程调用") { + t.Errorf("%s must not claim a zero-call unavailable runtime", command) + } + } + if command == "+list-roles" { + if !strings.Contains(record.SemanticDelta, "历史 CLI 继续执行严格 get_org_labels 适配") || !strings.Contains(record.SemanticDelta, "整批拒绝") { + t.Errorf("%s must preserve strict historical CLI execution while remaining Agent unavailable", command) + } + if strings.Contains(record.SemanticDelta, "0 次远程调用") { + t.Errorf("%s must not claim a zero-call unavailable runtime", command) + } + } + } + if public != 13 || unavailable != 3 { + t.Fatalf("public/unavailable = %d/%d, want 13/3", public, unavailable) + } +} diff --git a/internal/shortcut/builtin/devdoc_semantic_catalog_test.go b/internal/shortcut/builtin/devdoc_semantic_catalog_test.go new file mode 100644 index 00000000..23cb16d3 --- /dev/null +++ b/internal/shortcut/builtin/devdoc_semantic_catalog_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDevdocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_devdoc.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "devdoc" || len(source.Shortcuts) != 1 { + t.Fatalf("service/count = %q/%d", source.Service, len(source.Shortcuts)) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "devdoc" { + registered[item.Command] = item + } + } + if len(registered) != 1 { + t.Fatalf("registered Devdoc shortcuts = %d, want 1", len(registered)) + } + for command, record := range source.Shortcuts { + item, ok := registered[command] + if !ok { + t.Fatalf("catalog entry %s is not registered", command) + } + if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable { + t.Fatalf("catalog entry is not reviewed private unavailable: %#v", record) + } + if !item.Hidden || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || item.SemanticDelta != record.SemanticDelta { + t.Fatalf("runtime semantic facts drifted: %#v", item) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.OutputRollout != output.RolloutUnifiedActive || strings.TrimSpace(item.Safety.Effect) == "" { + t.Fatal("unavailable Devdoc Shortcut lacks Contract/Result/Safety/unified output") + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || !strings.Contains(string(item.Contract.Result.DataSchema), "zeroMatchProven") { + t.Fatal("unavailable Devdoc Shortcut does not publish the semantic zero-match boundary") + } + } +} diff --git a/internal/shortcut/builtin/hrbrain_semantic_catalog_test.go b/internal/shortcut/builtin/hrbrain_semantic_catalog_test.go new file mode 100644 index 00000000..3e822970 --- /dev/null +++ b/internal/shortcut/builtin/hrbrain_semantic_catalog_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageHRbrainSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_hrbrain.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "hrbrain" { + registered[item.Command] = item + } + } + if source.Service != "hrbrain" || len(source.Shortcuts) != 11 || len(registered) != 11 { + t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered)) + } + var missing, stale []string + blockerCounts := map[string]int{} + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if record.Public || record.Availability != "" || !record.Reviewed || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || !item.Hidden { + t.Errorf("%s availability/review drift", command) + } + if strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic delta drift", command) + } + if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Contract/Result/Safety/unified output", command) + } + if item.Contract.Interface == nil || strings.Contains(item.Contract.Interface.Reason, "shortcut_defect") { + t.Errorf("%s lacks a reviewed non-Shortcut blocker", command) + } else if strings.Contains(item.Contract.Interface.Reason, "classified=adapter_business_service") { + blockerCounts["adapter_business_service"]++ + } else if strings.Contains(item.Contract.Interface.Reason, "classified=tenant_fixture") { + blockerCounts["tenant_fixture"]++ + } else { + t.Errorf("%s has unknown blocker classification", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if blockerCounts["adapter_business_service"] != 9 || blockerCounts["tenant_fixture"] != 2 { + t.Fatalf("blocker counts = %#v", blockerCounts) + } +} diff --git a/internal/shortcut/builtin/live_semantic_catalog_test.go b/internal/shortcut/builtin/live_semantic_catalog_test.go new file mode 100644 index 00000000..d10854a9 --- /dev/null +++ b/internal/shortcut/builtin/live_semantic_catalog_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageLiveSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_live.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "live" { + registered[item.Command] = item + } + } + if source.Service != "live" || len(registered) != 1 || len(source.Shortcuts) != 1 { + t.Fatalf("service/registered/catalog = %q/%d/%d, want live/1/1", source.Service, len(registered), len(source.Shortcuts)) + } + record := source.Shortcuts["+list-my-lives"] + item, ok := registered["+list-my-lives"] + if !ok { + t.Fatal("registered Live shortcut missing") + } + if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable { + t.Fatalf("semantic record = %#v", record) + } + if !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk { + t.Fatal("Live semantic facts drifted") + } + if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("live", item.Command) { + t.Fatalf("Live visibility drift: hidden=%v availability=%q", item.Hidden, item.Availability) + } + if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" || item.Contract.Result != nil || item.OutputRollout != output.RolloutLegacyOnly { + t.Fatal("Live unavailable shortcut contract drift") + } +} diff --git a/internal/shortcut/builtin/pat_semantic_catalog_test.go b/internal/shortcut/builtin/pat_semantic_catalog_test.go new file mode 100644 index 00000000..be27ac2e --- /dev/null +++ b/internal/shortcut/builtin/pat_semantic_catalog_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoveragePATSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_pat.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "pat" { + registered[item.Command] = item + } + } + if source.Service != "pat" || len(source.Shortcuts) != 2 || len(registered) != 2 { + t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered)) + } + public := 0 + for command, record := range source.Shortcuts { + item, ok := registered[command] + if !ok || !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Fatalf("%s semantic review drift", command) + } + if record.Public { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" { + t.Fatalf("%s public contract drift", command) + } + } else if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable { + t.Fatalf("%s unavailable visibility drift", command) + } + if command == "+authorize" { + if record.Disposition != shortcut.DispositionAliasInternal || record.Primary != "pat chmod" || item.PrimaryCommand != "pat chmod" || !strings.Contains(item.SemanticDelta, "classified=routed") { + t.Fatalf("%s must route to the reviewed pat chmod execution chain", command) + } + if item.Contract.Interface == nil || !strings.Contains(item.Contract.Interface.Reason, "blocked_fixture") || !strings.Contains(item.Contract.Interface.Reason, "classified=routed") { + t.Fatalf("%s must separate routed implementation from unproved live write", command) + } + } + } + if public != 1 { + t.Fatalf("public PAT shortcuts = %d, want 1", public) + } +} diff --git a/internal/shortcut/builtin/sheet_semantic_catalog_test.go b/internal/shortcut/builtin/sheet_semantic_catalog_test.go new file mode 100644 index 00000000..d6650fd0 --- /dev/null +++ b/internal/shortcut/builtin/sheet_semantic_catalog_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageSheetSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_sheet.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "sheet" { + t.Fatalf("service=%q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "sheet" { + continue + } + if _, duplicate := registered[item.Command]; duplicate { + t.Fatalf("duplicate Sheet Shortcut %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 2 || len(source.Shortcuts) != 2 { + t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if !record.Public || item.Hidden || !shortcut.InPublicCatalog("sheet", command) { + t.Errorf("%s is not delivered as reviewed public", command) + } + if item.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk { + t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) != 0 || len(stale) != 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/builtin/whiteboard_semantic_catalog_test.go b/internal/shortcut/builtin/whiteboard_semantic_catalog_test.go new file mode 100644 index 00000000..bbdda746 --- /dev/null +++ b/internal/shortcut/builtin/whiteboard_semantic_catalog_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageWhiteboardSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_whiteboard.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "whiteboard" { + t.Fatalf("service=%q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "whiteboard" { + continue + } + if _, duplicate := registered[item.Command]; duplicate { + t.Fatalf("duplicate Whiteboard Shortcut %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 2 || len(source.Shortcuts) != 2 { + t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts)) + } + public, unavailable, hidden := 0, 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if !record.Public || item.Hidden || !shortcut.InPublicCatalog("whiteboard", command) { + t.Errorf("%s did not enter the public surface", command) + } + if !record.Public { + hidden++ + } else { + public++ + } + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + if item.Availability != shortcut.AvailabilityAvailable || record.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk { + t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Interface == nil || item.Contract.Interface.Availability != "available" || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command) + } + } + if public != 2 || unavailable != 0 || hidden != 0 { + t.Fatalf("Whiteboard source/public/unavailable/hidden=2/%d/%d/%d, want 2/2/0/0", public, unavailable, hidden) + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) != 0 || len(stale) != 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/contact/common.go b/internal/shortcut/contact/common.go new file mode 100644 index 00000000..0c1e98ee --- /dev/null +++ b/internal/shortcut/contact/common.go @@ -0,0 +1,533 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package contact + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const ( + contactCompositeReason = "Reviewed Contact Shortcut adapter: the executable CLI owns strict success, collection, item, stable-identity, and unified-output validation." + contactCompatibilityInterfaceReason = "Historical CLI and full-Schema compatibility: this executable shortcut remains callable with its published interface shape, but semantic catalog policy keeps it unavailable to Agents until strict live evidence is complete." + contactUnavailableReason = "Exact live-leaf probes across multiple authorized profiles could not safely produce a guaranteed zero-result fixture, so empty-result truth cannot be proved without guessing." + contactRosterGapReason = "Exact raw roster probes are blocked by the current authorization or platform capability before a typed business response is available." +) + +var contactReadSafety = contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", +} + +func markContactCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: contactCompatibilityInterfaceReason, + } +} + +func contactCollectionResult(collection, description string) *contract.ResultSpec { + itemSchema := `{"type":"object","description":"带稳定身份的已校验项目","additionalProperties":true}` + sensitive := []string{} + switch collection { + case "followings": + itemSchema = `{"type":"object","description":"带稳定开放用户身份的特别关注联系人","properties":{"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"}},"required":["openDingTalkId"],"additionalProperties":false}` + sensitive = []string{"followings.openDingTalkId"} + case "users": + itemSchema = `{"type":"object","description":"至少带 userId 或 openDingTalkId 之一的通讯录用户","properties":{"name":{"type":"string","description":"用户姓名"},"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"flowerName":{"type":"string","description":"用户花名"},"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"},"title":{"type":"string","description":"职位名称"}},"additionalProperties":false}` + sensitive = []string{"users.name", "users.userId", "users.flowerName", "users.openDingTalkId", "users.title"} + case "members": + itemSchema = `{"type":"object","description":"带稳定用户身份的成员","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"name":{"type":"string","description":"成员姓名"}},"required":["userId"],"additionalProperties":false}` + sensitive = []string{"members.userId", "members.name"} + case "depts": + itemSchema = `{"type":"object","description":"带稳定部门身份的直属子部门","properties":{"deptId":{"type":"integer","minimum":1,"description":"稳定部门 ID"},"deptName":{"type":"string","description":"部门名称"}},"required":["deptId"],"additionalProperties":false}` + sensitive = []string{"depts.deptName"} + } + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中通过严格校验的项目数量"},%q:{"type":"array","description":%q,"items":%s}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, itemSchema, collection, + )), + SensitivePaths: sensitive, + } +} + +func contactObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description)), + } +} + +func finalizeContactShortcut(item *shortcut.Shortcut, result *contract.ResultSpec, available bool) { + item.OutputRollout = output.RolloutUnifiedActive + item.Safety = contactReadSafety + if item.Contract.Identity.Name == "" { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(item.Command, "+"), "-", "_") + examples := append([]string(nil), item.Tips...) + parameters := make([]contract.ParamDecl, 0, len(item.Flags)) + for _, flag := range item.Flags { + parameters = append(parameters, contract.ParamDecl{Name: flag.Name}) + } + item.Contract = corecmd.ContractDecl{ + Description: item.Description, + Identity: contract.ToolIdentitySpec{ + ProductID: "contact", Name: name, CanonicalPath: "contact." + name, + CLIPath: "contact " + item.Command, PrimaryCLIPath: "contact " + item.Command, + }, + Selection: contract.SelectionSpec{ + AgentSummary: item.Description, UseWhen: []string{item.Intent}, + AvoidWhen: []string{"该能力未公开;使用已审核的 Contact Shortcut 或对应原子命令"}, + Examples: examples, + }, + Parameters: parameters, + } + } + item.Contract.Result = result + availability := contract.InterfaceAvailable + reason := contactCompositeReason + if !available { + availability = contract.InterfaceUnavailable + reason = contactUnavailableReason + item.OutputRollout = output.RolloutLegacyOnly + item.Contract.Result = nil + } + item.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason, + } +} + +func validateContactNonBlank(rt *shortcut.RuntimeContext, operation string, flags ...string) error { + for _, flag := range flags { + if strings.TrimSpace(rt.Str(flag)) == "" { + return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 不能为空白") + } + } + return nil +} + +func validateContactMobile(rt *shortcut.RuntimeContext, operation, flag string) error { + if _, err := normalizeContactMobile(rt.Str(flag)); err != nil { + return responsecheck.Error(operation, "invalid_mobile", "--"+flag+" 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号") + } + return nil +} + +func validateContactOptionalNonBlank(rt *shortcut.RuntimeContext, operation, flag string) error { + if rt.Changed(flag) && strings.TrimSpace(rt.Str(flag)) == "" { + return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 显式传入时不能为空白") + } + return nil +} + +func validateContactPositiveStringID(rt *shortcut.RuntimeContext, operation, flag string) error { + value := strings.TrimSpace(rt.Str(flag)) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return responsecheck.Error(operation, "invalid_parameter", "--"+flag+" 必须为正整数") + } + return nil +} + +func validateContactPositiveInt(rt *shortcut.RuntimeContext, operation, flag string) error { + if rt.Int(flag) <= 0 { + return responsecheck.Error(operation, "invalid_parameter", "--"+flag+" 必须大于 0") + } + return nil +} + +func validateContactPositiveIDList(rt *shortcut.RuntimeContext, operation, flag string) error { + values := rt.StrSlice(flag) + if len(values) == 0 { + return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 至少需要一个正整数 ID") + } + seen := make(map[int64]bool, len(values)) + for _, value := range values { + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || parsed <= 0 { + return responsecheck.Error(operation, "invalid_parameter", "--"+flag+" 每项都必须为正整数") + } + if seen[parsed] { + return responsecheck.Error(operation, "duplicate_parameter", "--"+flag+" 不能包含重复 ID") + } + seen[parsed] = true + } + return nil +} + +func normalizedContactIDList(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, strings.TrimSpace(value)) + } + return out +} + +func contactEnvelope(data map[string]any, operation string) (map[string]any, error) { + envelope, err := responsecheck.RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, key := range []string{"errorCode", "errorMsg", "errorMessage", "error"} { + if contactFailureValue(envelope[key]) { + return nil, responsecheck.Error(operation, "conflicting_failure_evidence", "success=true 响应同时携带失败字段 "+key) + } + } + return envelope, nil +} + +func contactFailureValue(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + trimmed := strings.TrimSpace(typed) + return trimmed != "" && trimmed != "0" && !strings.EqualFold(trimmed, "success") + case float64: + return typed != 0 + case int: + return typed != 0 + case bool: + return typed + case map[string]any: + return len(typed) != 0 + case []any: + return len(typed) != 0 + default: + return true + } +} + +func strictUserSearch(data map[string]any, operation string, allowMissingResult bool) ([]map[string]any, error) { + envelope, err := contactEnvelope(data, operation) + if err != nil { + return nil, err + } + if _, present := envelope["result"]; !present && allowMissingResult { + return nil, responsecheck.Error(operation, "missing_result", "手机号搜索未返回显式 result;不能把缺失详情当作成功的空结果") + } + items, err := responsecheck.RequireObjectCollection(envelope, operation, "result") + if err != nil { + return nil, err + } + return projectUsers(items, operation) +} + +// strictMobileLookup decodes the dedicated exact-mobile interface. The live +// service returns one result object for a match and omits result for a +// successful no-match. That omission is reviewed only for this exact lookup; +// null, empty objects, arrays, and malformed identities remain failures. +func strictMobileLookup(data map[string]any, operation string) (map[string]any, bool, error) { + envelope, err := contactEnvelope(data, operation) + if err != nil { + return nil, false, err + } + raw, present := envelope["result"] + if !present { + return nil, false, nil + } + result, ok := raw.(map[string]any) + if !ok || len(result) == 0 { + return nil, false, responsecheck.Error(operation, "malformed_result", "手机号精确查询 result 必须是非空对象") + } + userID := contactString(result, "userId") + if userID == "" { + return nil, false, responsecheck.Error(operation, "missing_stable_identity", "手机号精确查询结果缺少非空 userId") + } + row := map[string]any{"userId": userID} + if name, present, valid := contactOptionalString(result, "orgUserName"); !valid { + return nil, false, responsecheck.Error(operation, "malformed_optional_field", "手机号精确查询 orgUserName 必须是字符串") + } else if present && name != "" { + row["name"] = name + } + return row, true, nil +} + +func normalizeContactMobile(value string) (string, error) { + return normalizeContactMobilePart(value, 6) +} + +func normalizeContactMobilePart(value string, minimumDigits int) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("empty mobile") + } + var digits strings.Builder + for index, r := range trimmed { + switch { + case r >= '0' && r <= '9': + digits.WriteRune(r) + case r == '+' && index == 0: + case r == ' ' || r == '-' || r == '(' || r == ')': + default: + return "", fmt.Errorf("invalid mobile character") + } + } + normalized := digits.String() + if strings.HasPrefix(normalized, "00") { + normalized = strings.TrimPrefix(normalized, "00") + } + if len(normalized) < minimumDigits { + return "", fmt.Errorf("mobile too short") + } + return normalized, nil +} + +func strictFollowings(data map[string]any, operation, expectedOpenID string) ([]map[string]any, error) { + envelope, err := contactEnvelope(data, operation) + if err != nil { + return nil, err + } + result, ok := envelope["result"].(map[string]any) + if !ok || result == nil { + return nil, responsecheck.Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", envelope["result"])) + } + raw, present := result["models"] + if !present { + return nil, responsecheck.Error(operation, "missing_collection", "响应缺少显式 result.models 集合") + } + items, ok := raw.([]any) + if !ok { + return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result.models 应为数组,实际为 %T", raw)) + } + out := make([]map[string]any, 0, len(items)) + seen := make(map[string]bool, len(items)) + for index, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok || item == nil { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("响应 result.models[%d] 应为对象,实际为 %T", index, rawItem)) + } + openID := contactString(item, "openDingTalkId") + if openID == "" { + openID = contactString(item, "openDingtalkId") + } + if openID == "" { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("响应 result.models[%d] 缺少 openDingTalkId", index)) + } + if seen[openID] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("响应包含重复 openDingTalkId(索引 %d)", index)) + } + seen[openID] = true + if expectedOpenID == "" || openID == expectedOpenID { + out = append(out, map[string]any{"openDingTalkId": openID}) + } + } + return out, nil +} + +func strictRoles(data map[string]any, operation string) ([]map[string]any, error) { + envelope, err := contactEnvelope(data, operation) + if err != nil { + return nil, err + } + rawGroups, present := envelope["result"] + if !present { + return nil, responsecheck.Error(operation, "missing_collection", "响应缺少显式 result 角色分组集合") + } + groups, ok := rawGroups.([]any) + if !ok { + return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result 应为数组,实际为 %T", rawGroups)) + } + out := make([]map[string]any, 0) + seen := map[int64]bool{} + for groupIndex, rawGroup := range groups { + group, ok := rawGroup.(map[string]any) + if !ok || group == nil { + return nil, responsecheck.Error(operation, "malformed_group", fmt.Sprintf("响应 result[%d] 应为对象,实际为 %T", groupIndex, rawGroup)) + } + groupName := contactString(group, "groupName") + if groupName == "" { + return nil, responsecheck.Error(operation, "malformed_group", fmt.Sprintf("响应 result[%d] 缺少 groupName", groupIndex)) + } + rawLabels, present := group["labels"] + if !present { + return nil, responsecheck.Error(operation, "missing_collection", fmt.Sprintf("响应 result[%d] 缺少 labels 集合", groupIndex)) + } + labels, ok := rawLabels.([]any) + if !ok { + return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result[%d].labels 应为数组,实际为 %T", groupIndex, rawLabels)) + } + for labelIndex, rawLabel := range labels { + label, ok := rawLabel.(map[string]any) + if !ok || label == nil { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("响应 result[%d].labels[%d] 应为对象,实际为 %T", groupIndex, labelIndex, rawLabel)) + } + rawID, idPresent := label["labelId"] + rawName, namePresent := label["name"] + name, nameIsString := rawName.(string) + name = strings.TrimSpace(name) + // The legacy get_org_labels response can contain one reviewed null + // placeholder inside an otherwise valid group. Preserve that row exactly + // for CLI compatibility instead of rejecting every valid role in the + // response. This exception is intentionally confined to the hidden, + // legacy-only +list-roles path; public role-based shortcuts remain + // fail-closed on malformed identities. + if idPresent && rawID == nil && namePresent && rawName == nil { + out = append(out, map[string]any{"labelId": nil, "labelName": rawName}) + continue + } + id, idOK := contactInt64(rawID) + if !idOK || id <= 0 || !namePresent || !nameIsString || name == "" { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("响应 result[%d].labels[%d] 缺少有效 labelId/name", groupIndex, labelIndex)) + } + if seen[id] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("响应包含重复 labelId(分组 %d,索引 %d)", groupIndex, labelIndex)) + } + seen[id] = true + out = append(out, map[string]any{"labelId": id, "labelName": name}) + } + } + return out, nil +} + +func projectUsers(items []map[string]any, operation string) ([]map[string]any, error) { + out := make([]map[string]any, 0, len(items)) + seen := make(map[string]bool, len(items)) + for index, item := range items { + userID := contactString(item, "userId") + openID := contactString(item, "openDingTalkId") + if userID == "" && openID == "" { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("用户结果第 %d 项缺少 userId/openDingTalkId", index)) + } + identity := userID + if identity == "" { + identity = openID + } + if seen[identity] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("用户结果第 %d 项重复稳定身份", index)) + } + seen[identity] = true + row := map[string]any{} + for _, key := range []string{"name", "userId", "flowerName", "openDingTalkId", "title"} { + value, present, valid := contactOptionalString(item, key) + if !valid { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("用户结果第 %d 项的 %s 必须是字符串", index, key)) + } + if present && value != "" { + row[key] = value + } + } + out = append(out, row) + } + return out, nil +} + +func strictMembers(data map[string]any, operation, path string) ([]map[string]any, error) { + if _, err := contactEnvelope(data, operation); err != nil { + return nil, err + } + items, err := responsecheck.RequireObjectCollection(data, operation, path) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + seen := make(map[string]bool, len(items)) + for index, item := range items { + user, ok := item["userInfo"].(map[string]any) + if !ok || len(user) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("%s[%d].userInfo 应为非空对象", path, index)) + } + id := contactString(user, "userId") + if id == "" { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("%s[%d] 缺少 userId", path, index)) + } + if seen[id] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("%s[%d] 重复 userId", path, index)) + } + seen[id] = true + row := map[string]any{"userId": id} + name, _, valid := contactOptionalString(user, "name") + if !valid { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("%s[%d].userInfo.name 必须是字符串", path, index)) + } + if name != "" { + row["name"] = name + } + out = append(out, row) + } + return out, nil +} + +func strictSubDepts(data map[string]any, operation string) ([]map[string]any, error) { + if _, err := contactEnvelope(data, operation); err != nil { + return nil, err + } + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + seen := make(map[int64]bool, len(items)) + for index, item := range items { + id, ok := contactInt64(item["deptId"]) + if !ok || id <= 0 { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("部门结果第 %d 项缺少有效 deptId", index)) + } + if seen[id] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("部门结果第 %d 项重复 deptId", index)) + } + seen[id] = true + row := map[string]any{"deptId": id} + name, _, valid := contactOptionalString(item, "deptName") + if !valid { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("部门结果第 %d 项的 deptName 必须是字符串", index)) + } + if name != "" { + row["deptName"] = name + } + out = append(out, row) + } + return out, nil +} + +func contactString(object map[string]any, key string) string { + value, _ := object[key].(string) + return strings.TrimSpace(value) +} + +func contactOptionalString(object map[string]any, key string) (string, bool, bool) { + raw, present := object[key] + if !present || raw == nil { + return "", present, true + } + value, ok := raw.(string) + if !ok { + return "", true, false + } + return strings.TrimSpace(value), true, true +} + +func contactInt64(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if typed != float64(int64(typed)) { + return 0, false + } + return int64(typed), true + case int64: + return typed, true + case int: + return int64(typed), true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed, err == nil + default: + return 0, false + } +} diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go index d84b2819..d692e297 100644 --- a/internal/shortcut/contact/contact.go +++ b/internal/shortcut/contact/contact.go @@ -21,6 +21,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) // GetSelf 获取当前登录用户信息(我是谁 / 本人)。 @@ -56,18 +57,27 @@ var ListFollowings = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +list-followings"}, }, + Parameters: []contract.ParamDecl{{Name: "open-id"}}, + }, + Flags: []shortcut.Flag{ + {Name: "open-id", Type: shortcut.FlagString, Desc: "可选;仅保留 openDingTalkId 精确匹配的特别关注,用于确定性存在性检查;显式传入时不能为空白"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"open-id"}, Description: "--open-id 显式传入时不能为空白"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactOptionalNonBlank(rt, "contact/list_my_followings", "open-id") }, Tips: []string{ `dws contact +list-followings`, }, Execute: func(rt *shortcut.RuntimeContext) error { - // Project the raw {arguments,result:{models:[…]}} envelope down to a - // clean {count, followings:[{openDingTalkId}]} list. - data, err := rt.CallMCPData("contact", "list_my_followings", nil) + data, err := rt.CallMCPData("contact", "list_my_followings", map[string]any{}) + if err != nil { + return err + } + followings, err := strictFollowings(data, "contact/list_my_followings", rt.Str("open-id")) if err != nil { return err } - followings := listFollowingsProject(data) return rt.Output(map[string]any{"count": len(followings), "followings": followings}) }, } @@ -138,9 +148,16 @@ var SearchUser = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +search-user --query \"张三\""}, }, + // Keep the published merge-base property name. Execute remains a reviewed + // composite adapter from --query to the downstream keyword field. + Parameters: []contract.ParamDecl{{Name: "query", Property: "query"}}, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词;--query 不能为空白", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空白"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactNonBlank(rt, "contact/search_contact_by_key_word", "query") }, Tips: []string{ `dws contact +search-user --query "张三"`, @@ -152,7 +169,10 @@ var SearchUser = shortcut.Shortcut{ if err != nil { return err } - users := searchUserProject(data) + users, err := strictUserSearch(data, "contact/search_contact_by_key_word", false) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(users), "users": users}) }, } @@ -222,22 +242,42 @@ var SearchMobile = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +search-mobile --mobile 13800138000"}, }, + // Keep the merge-base Schema property. Execute uses the dedicated exact + // mobile interface and returns its reviewed stable-identity projection. + Parameters: []contract.ParamDecl{{Name: "mobile", Property: "mobile"}}, }, Flags: []shortcut.Flag{ - {Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true}, + {Name: "mobile", Type: shortcut.FlagString, Desc: "手机号;--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"mobile"}, Description: "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactMobile(rt, "contact/search_user_by_mobile", "mobile") }, Tips: []string{ `dws contact +search-mobile --mobile 13800138000`, }, Execute: func(rt *shortcut.RuntimeContext) error { + if err := rt.RequireAll("mobile"); err != nil { + return err + } + mobile, err := normalizeContactMobile(rt.Str("mobile")) + if err != nil { + return responsecheck.Error("contact/search_user_by_mobile", "invalid_mobile", "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号") + } data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{ - "mobile": rt.Str("mobile"), + "mobile": mobile, }) if err != nil { return err } - users := searchUserProject(data) - return rt.Output(map[string]any{"count": len(users), "users": users}) + user, found, err := strictMobileLookup(data, "contact/search_user_by_mobile") + if err != nil { + return err + } + if !found { + return rt.Output(map[string]any{"count": 0, "users": []map[string]any{}}) + } + return rt.Output(map[string]any{"count": 1, "users": []map[string]any{user}}) }, } @@ -283,7 +323,10 @@ var ListRoles = shortcut.Shortcut{ if err != nil { return err } - roles := listRolesProject(data) + roles, err := strictRoles(data, "contact/get_org_labels") + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(roles), "roles": roles}) }, } @@ -390,7 +433,7 @@ var ListRoleMembers = shortcut.Shortcut{ Command: "+list-role-members", Product: "contact", Description: "查询角色下的成员列表", - Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。", + Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表;按名称找角色时先用 contact label get 原子命令取得角色 ID。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -412,13 +455,18 @@ var ListRoleMembers = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询角色下的成员列表", - UseWhen: []string{"当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。"}, + UseWhen: []string{"当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表;按名称找角色时先用 contact label get 原子命令取得角色 ID。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +list-role-members --id 12345"}, }, + Parameters: []contract.ParamDecl{{Name: "id", Property: "id"}}, }, Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Desc: "角色 ID", Required: true}, + {Name: "id", Type: shortcut.FlagString, Desc: "角色 ID;--id 必须为正整数", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "--id 必须为正整数"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactPositiveStringID(rt, "contact/get_label_members_by_labelId", "id") }, Tips: []string{ `dws contact +list-role-members --id 12345`, @@ -430,7 +478,10 @@ var ListRoleMembers = shortcut.Shortcut{ if err != nil { return err } - members := memberListProject(data) + members, err := strictMembers(data, "contact/get_label_members_by_labelId", "labelUserList") + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(members), "members": members}) }, } @@ -540,9 +591,14 @@ var ListSubDepts = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +list-sub-depts --dept 1"}, }, + Parameters: []contract.ParamDecl{{Name: "dept", Property: "dept"}}, }, Flags: []shortcut.Flag{ - {Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1)", Required: true}, + {Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1);--dept 必须大于 0", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"dept"}, Description: "--dept 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactPositiveInt(rt, "contact/get_sub_depts_by_dept_id", "dept") }, Tips: []string{ `dws contact +list-sub-depts --dept 1`, @@ -554,7 +610,10 @@ var ListSubDepts = shortcut.Shortcut{ if err != nil { return err } - depts := listSubDeptsProject(data) + depts, err := strictSubDepts(data, "contact/get_sub_depts_by_dept_id") + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(depts), "depts": depts}) }, } @@ -655,21 +714,29 @@ var ListDeptMembers = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +list-dept-members --depts 12345,67890"}, }, + Parameters: []contract.ParamDecl{{Name: "depts", Property: "depts"}}, }, Flags: []shortcut.Flag{ - {Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔", Required: true}, + {Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔;--depts 每项都必须为正整数且不能重复", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"depts"}, Description: "--depts 每项都必须为正整数且不能重复"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactPositiveIDList(rt, "contact/get_dept_members_by_deptId", "depts") }, Tips: []string{ `dws contact +list-dept-members --depts 12345,67890`, }, Execute: func(rt *shortcut.RuntimeContext) error { data, err := rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{ - "deptIds": rt.StrSlice("depts"), + "deptIds": normalizedContactIDList(rt.StrSlice("depts")), }) if err != nil { return err } - members := memberListProject(data) + members, err := strictMembers(data, "contact/get_dept_members_by_deptId", "deptUserList") + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(members), "members": members}) }, } @@ -719,6 +786,18 @@ var GetRoster = shortcut.Shortcut{ } func init() { + finalizeContactShortcut(&ListFollowings, contactCollectionResult("followings", ListFollowings.Description), true) + finalizeContactShortcut(&SearchUser, contactCollectionResult("users", SearchUser.Description), true) + finalizeContactShortcut(&SearchMobile, contactCollectionResult("users", SearchMobile.Description), true) + finalizeContactShortcut(&ListRoles, contactCollectionResult("roles", ListRoles.Description), false) + markContactCompatibilityOnly(&ListRoles) + finalizeContactShortcut(&ListRoleMembers, contactCollectionResult("members", ListRoleMembers.Description), true) + finalizeContactShortcut(&ListSubDepts, contactCollectionResult("depts", ListSubDepts.Description), true) + finalizeContactShortcut(&ListDeptMembers, contactCollectionResult("members", ListDeptMembers.Description), true) + finalizeContactShortcut(&ListRosterFields, contactCollectionResult("fields", ListRosterFields.Description), false) + finalizeContactShortcut(&GetRoster, contactObjectResult(GetRoster.Description), false) + ListRosterFields.Contract.Interface.Reason = contactRosterGapReason + GetRoster.Contract.Interface.Reason = contactRosterGapReason shortcut.Register( ListFollowings, SearchUser, diff --git a/internal/shortcut/contact/contact_strict_cross_platform_coverage_test.go b/internal/shortcut/contact/contact_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..c0377519 --- /dev/null +++ b/internal/shortcut/contact/contact_strict_cross_platform_coverage_test.go @@ -0,0 +1,624 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package contact + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type contactCaller struct { + payload string + payloads map[string]string + err error + errors map[string]error + calls int + product string + tool string + args map[string]any + history []contactCall +} + +type contactCall struct { + tool string + args map[string]any +} + +func (caller *contactCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.product, caller.tool, caller.args = product, tool, args + caller.history = append(caller.history, contactCall{tool: tool, args: args}) + if caller.err != nil { + return nil, caller.err + } + if err := caller.errors[tool]; err != nil { + return nil, err + } + payload := caller.payload + if toolPayload, ok := caller.payloads[tool]; ok { + payload = toolPayload + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: payload}}}, nil +} +func (*contactCaller) Format() string { return "json" } +func (*contactCaller) DryRun() bool { return false } +func (*contactCaller) Fields() string { return "" } +func (*contactCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageContactStrictSearchAndCollections(t *testing.T) { + users, err := strictUserSearch(map[string]any{ + "success": true, "result": []any{map[string]any{"userId": "stable-user", "name": "Fixture"}}, + }, "contact/test", false) + if err != nil || len(users) != 1 { + t.Fatalf("valid users rejected: users=%v err=%v", users, err) + } + users, err = strictUserSearch(map[string]any{"success": true, "result": []any{}}, "contact/test", false) + if err != nil || len(users) != 0 { + t.Fatalf("explicit zero rejected: users=%v err=%v", users, err) + } + + broken := []map[string]any{ + {}, + {"result": []any{}}, + {"success": "true", "result": []any{}}, + {"success": false, "result": []any{}}, + {"success": true}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{}}}, + {"success": true, "result": []any{map[string]any{"name": "no-id"}}}, + {"success": true, "result": []any{map[string]any{"userId": "same"}, map[string]any{"userId": "same"}}}, + {"success": true, "result": []any{map[string]any{"userId": "good", "name": float64(1)}}}, + {"success": true, "errorCode": "FAILED", "result": []any{}}, + } + for index, data := range broken { + if got, projectErr := strictUserSearch(data, "contact/test", false); projectErr == nil { + t.Errorf("broken response %d returned success: %v", index, got) + } + } + + if got, projectErr := strictMembers(map[string]any{ + "success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "stable-user"}}}, + }, "contact/members", "deptUserList"); projectErr != nil || len(got) != 1 { + t.Fatalf("valid members rejected: got=%v err=%v", got, projectErr) + } + for _, data := range []map[string]any{ + {"success": false}, + {"success": true}, + {"success": true, "deptUserList": map[string]any{}}, + {"success": true, "deptUserList": []any{map[string]any{"unexpected": true}}}, + {"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"name": "no-id"}}}}, + {"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "same"}}, map[string]any{"userInfo": map[string]any{"userId": "same"}}}}, + {"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "good", "name": true}}}}, + } { + if got, projectErr := strictMembers(data, "contact/members", "deptUserList"); projectErr == nil { + t.Errorf("broken members returned success: %v", got) + } + } +} + +func TestCrossPlatformCoverageContactSubDepartmentsRejectBadIdentityAndShape(t *testing.T) { + depts, err := strictSubDepts(map[string]any{ + "success": true, + "result": []any{map[string]any{"deptId": float64(1), "deptName": "Fixture"}}, + }, "contact/sub-depts") + if err != nil || len(depts) != 1 || depts[0]["deptId"] != int64(1) { + t.Fatalf("valid sub departments rejected: depts=%v err=%v", depts, err) + } + for _, broken := range []map[string]any{ + {"success": true}, + {"success": true, "result": []any{map[string]any{"deptId": float64(0)}}}, + {"success": true, "result": []any{map[string]any{"deptId": float64(1)}, map[string]any{"deptId": float64(1)}}}, + {"success": true, "result": []any{map[string]any{"deptId": float64(1), "deptName": map[string]any{}}}}, + } { + if got, projectErr := strictSubDepts(broken, "contact/sub-depts"); projectErr == nil { + t.Errorf("broken sub departments returned success: %v", got) + } + } +} + +func TestCrossPlatformCoverageContactSearchMobileUsesReviewedObjectShape(t *testing.T) { + caller := &contactCaller{payloads: map[string]string{ + "search_user_by_mobile": `{"success":true,"result":{"userId":"stable-user","orgUserName":"Fixture"}}`, + }, errors: map[string]error{"get_user_info_by_user_ids": errors.New("detail permission unavailable")}} + helpers.InitDepsForTest(t, caller) + cmd := &cobra.Command{Use: "+search-mobile"} + cmd.Flags().String("mobile", "", "") + if err := cmd.Flags().Set("mobile", "+86 138-0013-8000"); err != nil { + t.Fatal(err) + } + declaration := SearchMobile + declaration.OutputRollout = output.RolloutLegacyOnly + if err := declaration.Validate(shortcut.RuntimeContextForTest(cmd, declaration)); err != nil { + t.Fatalf("formatted mobile validation: %v", err) + } + if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err != nil { + t.Fatalf("search-mobile: %v", err) + } + if caller.calls != 1 || caller.product != "contact" || caller.history[0].tool != "search_user_by_mobile" || caller.history[0].args["mobile"] != "8613800138000" { + t.Fatalf("mapping = calls:%d product:%q history:%#v", caller.calls, caller.product, caller.history) + } + caller.errors = nil + + caller.calls, caller.history = 0, nil + caller.errors = map[string]error{"search_user_by_mobile": errors.New("lookup unavailable")} + if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err == nil { + t.Fatal("search-mobile swallowed the exact lookup transport error") + } + if caller.calls != 1 || caller.history[0].args["mobile"] != "8613800138000" { + t.Fatalf("transport failure mapping = calls:%d history:%#v", caller.calls, caller.history) + } + caller.errors = nil + + caller.calls, caller.history = 0, nil + caller.payloads["search_user_by_mobile"] = `{"success":true}` + if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err != nil { + t.Fatalf("reviewed exact-mobile zero rejected: %v", err) + } + if caller.calls != 1 || caller.history[0].tool != "search_user_by_mobile" { + t.Fatalf("zero match must stop after exact lookup: %#v", caller.history) + } + caller.payloads["search_user_by_mobile"] = `{"success":true,"result":null}` + if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err == nil { + t.Fatal("null result must not become a successful empty search") + } + + caller.payloads["search_user_by_mobile"] = `{"success":true,"result":{"openDingTalkId":"open-only"}}` + caller.calls, caller.history = 0, nil + if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err == nil { + t.Fatal("mobile candidate without userId returned success") + } + if caller.calls != 1 { + t.Fatalf("missing userId calls=%d history=%#v", caller.calls, caller.history) + } + invalidCommand := &cobra.Command{Use: "+search-mobile"} + invalidCommand.Flags().String("mobile", "", "") + if err := invalidCommand.Flags().Set("mobile", "not-a-phone"); err != nil { + t.Fatal(err) + } + caller.calls, caller.history = 0, nil + if err := declaration.Execute(shortcut.RuntimeContextForTest(invalidCommand, declaration)); err == nil { + t.Fatal("invalid mobile reached execution") + } + if caller.calls != 0 { + t.Fatalf("invalid mobile made calls: %#v", caller.history) + } +} + +func TestCrossPlatformCoverageContactMobileProofHelperMatrix(t *testing.T) { + user, found, err := strictMobileLookup(map[string]any{ + "success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": "Fixture"}, + }, "contact/mobile") + if err != nil || !found || user["userId"] != "stable-user" || user["name"] != "Fixture" { + t.Fatalf("exact mobile result rejected: user=%#v found=%v err=%v", user, found, err) + } + if user, found, err := strictMobileLookup(map[string]any{"success": true}, "contact/mobile"); err != nil || found || user != nil { + t.Fatalf("reviewed no-match rejected: user=%#v found=%v err=%v", user, found, err) + } + for _, broken := range []map[string]any{ + {"success": false}, + {"success": true, "result": nil}, + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"orgUserName": "no-id"}}, + {"success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": 7}}, + } { + if user, found, err := strictMobileLookup(broken, "contact/mobile"); err == nil { + t.Errorf("broken mobile lookup returned success: user=%#v found=%v data=%#v", user, found, broken) + } + } + for input, want := range map[string]string{ + "+86 138-0013-8000": "8613800138000", + "008613800138000": "8613800138000", + "(138) 0013-8000": "13800138000", + } { + if got, err := normalizeContactMobile(input); err != nil || got != want { + t.Errorf("normalize %q = %q, %v; want %q", input, got, err, want) + } + } + for _, input := range []string{"", "123", "1+3800138000", "13800x138000"} { + if got, err := normalizeContactMobile(input); err == nil { + t.Errorf("invalid mobile %q normalized to %q", input, got) + } + } +} + +func TestCrossPlatformCoverageContactRequiredSearchInputsFailBeforeRemoteCall(t *testing.T) { + caller := &contactCaller{payload: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + mobileCommand := &cobra.Command{Use: "+search-mobile"} + mobileCommand.Flags().String("mobile", "", "") + if err := SearchMobile.Execute(shortcut.RuntimeContextForTest(mobileCommand, SearchMobile)); err == nil { + t.Fatal("missing mobile unexpectedly reached execution") + } + if caller.calls != 0 { + t.Fatalf("missing mobile made %d remote calls", caller.calls) + } + + userCommand := &cobra.Command{Use: "+search-user"} + userCommand.Flags().String("query", "fixture", "") + declaration := SearchUser + declaration.OutputRollout = output.RolloutLegacyOnly + if err := declaration.Execute(shortcut.RuntimeContextForTest(userCommand, declaration)); err != nil { + t.Fatalf("search-user regressed: %v", err) + } + if caller.calls != 1 || caller.args["keyword"] != "fixture" { + t.Fatalf("search-user mapping = calls:%d args:%#v", caller.calls, caller.args) + } +} + +func TestCrossPlatformCoverageContactInputConstraintsFailBeforeRemoteCall(t *testing.T) { + caller := &contactCaller{payload: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + tests := []struct { + declaration shortcut.Shortcut + flag string + value string + }{ + {SearchUser, "query", " "}, + {SearchMobile, "mobile", " "}, + {SearchMobile, "mobile", "not-a-phone"}, + {ListRoleMembers, "id", "0"}, + {ListRoleMembers, "id", "not-a-number"}, + {ListSubDepts, "dept", "0"}, + {ListDeptMembers, "depts", "1,1"}, + {ListDeptMembers, "depts", "1,invalid"}, + {ListFollowings, "open-id", " "}, + } + for _, test := range tests { + command := &cobra.Command{Use: test.declaration.Command} + switch test.declaration.Command { + case "+list-sub-depts": + command.Flags().Int(test.flag, 0, "") + case "+list-dept-members": + command.Flags().StringSlice(test.flag, nil, "") + default: + command.Flags().String(test.flag, "", "") + } + if err := command.Flags().Set(test.flag, test.value); err != nil { + t.Fatalf("set %s %s: %v", test.declaration.Command, test.flag, err) + } + if err := test.declaration.Validate(shortcut.RuntimeContextForTest(command, test.declaration)); err == nil { + t.Errorf("%s accepted invalid %s=%q", test.declaration.Command, test.flag, test.value) + } + } + if caller.calls != 0 { + t.Fatalf("invalid inputs made %d remote calls", caller.calls) + } +} + +func TestCrossPlatformCoverageListRolesCompatibilityUsesStrictLegacyMCP(t *testing.T) { + caller := &contactCaller{payload: `{"success":true,"result":[{"groupName":"Fixture group","labels":[{"labelId":1,"name":"Fixture role"},{"labelId":null,"name":null}]}]}`} + helpers.InitDepsForTest(t, caller) + declaration := ListRoles + declaration.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: declaration.Command} + var stdout bytes.Buffer + command.SetOut(&stdout) + if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err != nil { + t.Fatalf("list roles compatibility call: %v", err) + } + if caller.calls != 1 || caller.product != "contact" || caller.tool != "get_org_labels" || len(caller.args) != 0 { + t.Fatalf("list roles mapping = calls:%d product:%q tool:%q args:%#v", caller.calls, caller.product, caller.tool, caller.args) + } + var payload struct { + Count int `json:"count"` + Roles []map[string]any `json:"roles"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode list roles output: %v", err) + } + if payload.Count != 2 || len(payload.Roles) != 2 || payload.Roles[0]["labelName"] != "Fixture role" { + t.Fatalf("legacy role output drift: %#v", payload) + } + if value, present := payload.Roles[1]["labelId"]; !present || value != nil || payload.Roles[1]["labelName"] != nil { + t.Fatalf("reviewed placeholder was not preserved: %#v", payload.Roles[1]) + } + + caller.calls = 0 + caller.payload = `{"success":true,"result":[{"groupName":"Fixture group","labels":[{"labelId":1,"name":"Fixture role"},{"name":"Missing identity"}]}]}` + if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil { + t.Fatal("list roles accepted a malformed role element") + } + if caller.calls != 1 { + t.Fatalf("malformed response calls = %d, want 1", caller.calls) + } + + caller.calls = 0 + caller.err = errors.New("permission denied") + if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil { + t.Fatal("list roles swallowed downstream error") + } + if caller.calls != 1 { + t.Fatalf("downstream error calls = %d, want 1", caller.calls) + } +} + +func TestCrossPlatformCoverageRosterCompatibilityDelegatesToLegacyMCP(t *testing.T) { + caller := &contactCaller{payload: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + + listDeclaration := ListRosterFields + listDeclaration.OutputRollout = output.RolloutLegacyOnly + if err := listDeclaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: listDeclaration.Command}, listDeclaration)); err != nil { + t.Fatalf("list roster fields compatibility call: %v", err) + } + if caller.calls != 1 || caller.product != "hrmregister" || caller.tool != "list_authorized_roster_fields" || len(caller.args) != 0 { + t.Fatalf("list roster fields mapping = calls:%d product:%q tool:%q args:%#v", caller.calls, caller.product, caller.tool, caller.args) + } + + caller.calls, caller.history = 0, nil + getCommand := &cobra.Command{Use: GetRoster.Command} + getCommand.Flags().String("staff-id", "", "") + getCommand.Flags().StringSlice("fields", nil, "") + if err := getCommand.Flags().Set("staff-id", "fixture-staff"); err != nil { + t.Fatal(err) + } + if err := getCommand.Flags().Set("fields", "field-a,field-b"); err != nil { + t.Fatal(err) + } + getDeclaration := GetRoster + getDeclaration.OutputRollout = output.RolloutLegacyOnly + if err := getDeclaration.Execute(shortcut.RuntimeContextForTest(getCommand, getDeclaration)); err != nil { + t.Fatalf("get roster compatibility call: %v", err) + } + fields, ok := caller.args["fieldCodeList"].([]string) + if caller.calls != 1 || caller.product != "hrmregister" || caller.tool != "get_authorized_emp_rosterInfo" || caller.args["staffId"] != "fixture-staff" || !ok || len(fields) != 2 || fields[0] != "field-a" || fields[1] != "field-b" { + t.Fatalf("get roster mapping = calls:%d product:%q tool:%q args:%#v", caller.calls, caller.product, caller.tool, caller.args) + } + + caller.err = errors.New("permission denied") + caller.calls = 0 + if err := listDeclaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: listDeclaration.Command}, listDeclaration)); err == nil { + t.Fatal("list roster fields swallowed downstream error") + } + if caller.calls != 1 { + t.Fatalf("downstream error calls = %d, want 1", caller.calls) + } +} + +func TestCrossPlatformCoverageContactFollowingsAndRolesRejectBadElements(t *testing.T) { + followings, err := strictFollowings(map[string]any{ + "success": true, + "result": map[string]any{"models": []any{ + map[string]any{"openDingTalkId": "open-1"}, + map[string]any{"openDingTalkId": "open-2"}, + }}, + }, "contact/followings", "open-2") + if err != nil || len(followings) != 1 || followings[0]["openDingTalkId"] != "open-2" { + t.Fatalf("strict followings = %#v, err=%v", followings, err) + } + followings, err = strictFollowings(map[string]any{ + "success": true, + "result": map[string]any{"models": []any{map[string]any{"openDingTalkId": "open-1"}}}, + }, "contact/followings", "guaranteed-missing") + if err != nil || len(followings) != 0 { + t.Fatalf("filtered explicit zero = %#v, err=%v", followings, err) + } + caller := &contactCaller{payload: `{"success":true,"result":{"models":[{"openDingTalkId":"open-1"}]}}`} + helpers.InitDepsForTest(t, caller) + command := &cobra.Command{Use: "+list-followings"} + command.Flags().String("open-id", "guaranteed-missing", "") + declaration := ListFollowings + declaration.OutputRollout = output.RolloutLegacyOnly + if executeErr := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); executeErr != nil { + t.Fatalf("exact followings mapping: %v", executeErr) + } + if caller.calls != 1 || caller.product != "contact" || caller.tool != "list_my_followings" { + t.Fatalf("followings mapping = calls:%d product:%q tool:%q", caller.calls, caller.product, caller.tool) + } + for _, broken := range []map[string]any{ + {"success": false}, + {"success": true}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"models": map[string]any{}}}, + {"success": true, "result": map[string]any{"models": []any{"bad"}}}, + {"success": true, "result": map[string]any{"models": []any{map[string]any{}}}}, + {"success": true, "result": map[string]any{"models": []any{map[string]any{"openDingTalkId": "same"}, map[string]any{"openDingTalkId": "same"}}}}, + } { + if got, parseErr := strictFollowings(broken, "contact/followings", ""); parseErr == nil { + t.Errorf("broken followings returned success: %#v", got) + } + } + + roles, err := strictRoles(map[string]any{ + "success": true, + "result": []any{map[string]any{ + "groupName": "Fixture group", + "labels": []any{ + map[string]any{"labelId": float64(1), "name": "Fixture role"}, + map[string]any{"labelId": nil, "name": nil}, + }, + }}, + }, "contact/roles") + if err != nil || len(roles) != 2 || roles[0]["labelId"] != int64(1) || roles[0]["labelName"] != "Fixture role" || roles[1]["labelId"] != nil || roles[1]["labelName"] != nil { + t.Fatalf("strict roles = %#v, err=%v", roles, err) + } + for _, broken := range []map[string]any{ + {"success": false}, + {"success": true}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"labels": []any{}}}}, + {"success": true, "result": []any{map[string]any{"groupName": "Fixture"}}}, + {"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": map[string]any{}}}}, + {"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{"bad"}}}}, + {"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{map[string]any{"labelId": nil}}}}}, + {"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{map[string]any{"labelId": nil, "name": ""}}}}}, + {"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{map[string]any{"labelId": float64(1), "name": "One"}, map[string]any{"labelId": float64(1), "name": "Duplicate"}}}}}, + } { + if got, parseErr := strictRoles(broken, "contact/roles"); parseErr == nil { + t.Errorf("broken roles returned success: %#v", got) + } + } + if users, err := projectUsers([]map[string]any{{"openDingTalkId": "open-only"}}, "contact/users"); err != nil || users[0]["openDingTalkId"] != "open-only" { + t.Fatalf("open-ID-only user rejected: %#v err=%v", users, err) + } + if members, err := strictMembers(map[string]any{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "u1", "name": "Fixture"}}}}, "contact/members", "deptUserList"); err != nil || members[0]["name"] != "Fixture" { + t.Fatalf("named member rejected: %#v err=%v", members, err) + } +} + +func TestCrossPlatformCoverageContactDirectContracts(t *testing.T) { + available := []*shortcut.Shortcut{ + &ListFollowings, &SearchUser, &SearchMobile, &ListRoleMembers, &ListSubDepts, &ListDeptMembers, + } + for _, item := range available { + if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || len(item.Contract.Result.SensitivePaths) == 0 || strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Contract/Result/Safety/unified output", item.Command) + continue + } + var schema map[string]any + if err := json.Unmarshal(item.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" { + t.Errorf("%s invalid Result schema: schema=%v err=%v", item.Command, schema, err) + } + } + for item, want := range map[*shortcut.Shortcut]map[string]string{ + &SearchMobile: {"mobile": "mobile"}, + &ListRoleMembers: {"id": "id"}, + &ListSubDepts: {"dept": "dept"}, + &ListDeptMembers: {"depts": "depts"}, + } { + for _, parameter := range item.Contract.Parameters { + if property, ok := want[parameter.Name]; ok { + if parameter.Property != property { + t.Errorf("%s parameter %s property=%q want=%q", item.Command, parameter.Name, parameter.Property, property) + } + delete(want, parameter.Name) + } + } + if len(want) != 0 { + t.Errorf("%s missing compatibility parameters: %#v", item.Command, want) + } + } + if ListRoles.OutputRollout != output.RolloutLegacyOnly || ListRoles.Contract.Result != nil || ListRoles.Contract.Interface == nil || ListRoles.Contract.Interface.Availability != "available" || ListRoles.Contract.Interface.Reason != contactCompatibilityInterfaceReason { + t.Errorf("+list-roles compatibility delivery drift: rollout=%q result=%v interface=%#v", ListRoles.OutputRollout, ListRoles.Contract.Result, ListRoles.Contract.Interface) + } + for _, item := range []*shortcut.Shortcut{&ListRosterFields, &GetRoster} { + if item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil || item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s unavailable delivery drift: rollout=%q result=%v interface=%#v", item.Command, item.OutputRollout, item.Contract.Result, item.Contract.Interface) + } + } +} + +func TestCrossPlatformCoverageContactPrimitiveMatrices(t *testing.T) { + for index, test := range []struct { + value any + want bool + }{ + {nil, false}, {"", false}, {"0", false}, {"SUCCESS", false}, {"bad", true}, + {float64(0), false}, {float64(1), true}, {0, false}, {1, true}, {false, false}, {true, true}, + {map[string]any{}, false}, {map[string]any{"x": 1}, true}, {[]any{}, false}, {[]any{1}, true}, {struct{}{}, true}, + } { + if got := contactFailureValue(test.value); got != test.want { + t.Errorf("failure matrix %d = %v, want %v", index, got, test.want) + } + } + for index, test := range []struct { + value any + want int64 + ok bool + }{ + {float64(7), 7, true}, {float64(7.5), 0, false}, {int64(8), 8, true}, {9, 9, true}, + {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {" 11 ", 11, true}, {"bad", 0, false}, {true, 0, false}, + } { + got, ok := contactInt64(test.value) + if got != test.want || ok != test.ok { + t.Errorf("integer matrix %d = (%d,%v), want (%d,%v)", index, got, ok, test.want, test.ok) + } + } + if got := normalizedContactIDList([]string{" 1 ", "2"}); len(got) != 2 || got[0] != "1" || got[1] != "2" { + t.Fatalf("normalized IDs = %#v", got) + } + if _, err := strictUserSearch(map[string]any{"success": true}, "contact/test", true); err == nil { + t.Fatal("missing optional-result evidence returned success") + } +} + +func TestCrossPlatformCoverageContactValidatorsAcceptCanonicalInputs(t *testing.T) { + command := &cobra.Command{Use: "contact"} + command.Flags().String("required", " value ", "") + command.Flags().String("optional", " value ", "") + command.Flags().String("id", " 7 ", "") + command.Flags().Int("dept", 7, "") + command.Flags().StringSlice("depts", []string{" 7 ", "8"}, "") + rt := shortcut.RuntimeContextForTest(command, SearchUser) + for _, err := range []error{ + validateContactNonBlank(rt, "contact/test", "required"), + validateContactOptionalNonBlank(rt, "contact/test", "optional"), + validateContactPositiveStringID(rt, "contact/test", "id"), + validateContactPositiveInt(rt, "contact/test", "dept"), + validateContactPositiveIDList(rt, "contact/test", "depts"), + } { + if err != nil { + t.Fatalf("canonical validation failed: %v", err) + } + } + command.Flags().StringSlice("empty-list", nil, "") + if err := validateContactPositiveIDList(rt, "contact/test", "empty-list"); err == nil { + t.Fatal("empty ID list returned success") + } +} + +func TestCrossPlatformCoverageContactDirectExecutors(t *testing.T) { + tests := []struct { + declaration shortcut.Shortcut + flag string + value string + payload string + tool string + }{ + {ListFollowings, "open-id", "open-1", `{"success":true,"result":{"models":[{"openDingTalkId":"open-1"}]}}`, "list_my_followings"}, + {SearchUser, "query", "fixture", `{"success":true,"result":[{"userId":"user-1"}]}`, "search_contact_by_key_word"}, + {ListRoleMembers, "id", "7", `{"success":true,"labelUserList":[{"userInfo":{"userId":"user-1"}}]}`, "get_label_members_by_labelId"}, + {ListSubDepts, "dept", "7", `{"success":true,"result":[{"deptId":8}]}`, "get_sub_depts_by_dept_id"}, + {ListDeptMembers, "depts", " 7 ,8", `{"success":true,"deptUserList":[{"userInfo":{"userId":"user-1"}}]}`, "get_dept_members_by_deptId"}, + } + for _, test := range tests { + t.Run(test.declaration.Command, func(t *testing.T) { + caller := &contactCaller{payload: test.payload} + helpers.InitDepsForTest(t, caller) + declaration := test.declaration + declaration.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: declaration.Command} + switch declaration.Command { + case "+list-sub-depts": + command.Flags().Int(test.flag, 0, "") + case "+list-dept-members": + command.Flags().StringSlice(test.flag, nil, "") + default: + command.Flags().String(test.flag, "", "") + } + if err := command.Flags().Set(test.flag, test.value); err != nil { + t.Fatal(err) + } + rt := shortcut.RuntimeContextForTest(command, declaration) + if err := declaration.Execute(rt); err != nil { + t.Fatalf("success execution: %v", err) + } + if caller.tool != test.tool { + t.Fatalf("tool=%q want=%q", caller.tool, test.tool) + } + caller.err = errors.New("transport") + if err := declaration.Execute(rt); err == nil { + t.Fatal("transport error returned success") + } + caller.err = nil + caller.payload = `{"success":true}` + if err := declaration.Execute(rt); err == nil { + t.Fatal("projection error returned success") + } + }) + } +} diff --git a/internal/shortcut/devapp/devapp.go b/internal/shortcut/devapp/devapp.go index 77519d70..547e634e 100644 --- a/internal/shortcut/devapp/devapp.go +++ b/internal/shortcut/devapp/devapp.go @@ -26,12 +26,13 @@ package devapp import ( "encoding/json" + "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" - "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -64,6 +65,29 @@ func devAppObjectResult(outcomes ...contract.ResultOutcome) *contract.ResultSpec } } +func devAppVerifiedMutationResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"经稳定应用 ID 精确读回验证的开放平台应用变更","properties":{"action":{"type":"string","description":"已执行并验证的变更动作"},"unifiedAppId":{"type":"string","description":"发生变更的稳定统一应用 ID"},"verified":{"type":"boolean","description":"是否已通过精确对象读回或删除后全量排除验证"},"resource":{"type":"object","description":"写后按同一稳定 ID 读回的业务对象;删除动作省略","additionalProperties":true}},"required":["action","unifiedAppId","verified"],"additionalProperties":false}`), + } +} + +func devAppCredentialResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"按稳定应用 ID 严格验证的开放平台应用凭证","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"appKey":{"type":"string","description":"应用公开客户端标识"},"clientId":{"type":"string","description":"应用公开客户端标识兼容字段"},"appSecret":{"type":"string","description":"应用敏感客户端密钥"},"clientSecret":{"type":"string","description":"应用敏感客户端密钥兼容字段"},"secret":{"type":"string","description":"应用敏感密钥兼容字段"}},"additionalProperties":true}`), + SensitivePaths: []string{"appSecret", "clientSecret", "secret"}, + } +} + +func devAppMemberListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证并可按稳定 userId 精确筛选的应用成员列表","properties":{"count":{"type":"integer","description":"匹配的成员数量"},"members":{"type":"array","description":"匹配的应用成员","items":{"type":"object","description":"带稳定 userId 的应用成员","additionalProperties":true}}},"required":["count","members"],"additionalProperties":false}`), + SensitivePaths: []string{"members.name"}, + } +} + func devAppPaginatedProjectionResult(collection, description string) *contract.ResultSpec { schema, _ := json.Marshal(map[string]any{ "type": "object", @@ -98,6 +122,298 @@ func devAppCursorPagination() *contract.PaginationSpec { } } +const devAppMaxReadbackPages = 20 + +func devAppResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func devAppContainers(data map[string]any) []map[string]any { + if data == nil { + return nil + } + containers := []map[string]any{data} + for index := 0; index < len(containers) && index < 12; index++ { + for _, key := range []string{"content", "result", "data"} { + if nested, ok := containers[index][key].(map[string]any); ok { + containers = append(containers, nested) + } + } + } + return containers +} + +func devAppErrorValue(value any) bool { + switch typed := value.(type) { + case nil: + return false + case bool: + return typed + case float64: + return typed != 0 && typed != 200 + case string: + normalized := strings.ToUpper(strings.TrimSpace(typed)) + return normalized != "" && normalized != "0" && normalized != "200" && normalized != "OK" && normalized != "SUCCESS" + case map[string]any: + return len(typed) > 0 + case []any: + return len(typed) > 0 + default: + return true + } +} + +func devAppFirstString(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func requireDevAppSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, devAppResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功或结果确实为空") + } + foundSuccess := false + for _, candidate := range devAppContainers(data) { + if value, present := candidate["success"]; present { + foundSuccess = true + success, ok := value.(bool) + if !ok { + return nil, devAppResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := devAppFirstString(candidate, "errorMsg", "errorMessage", "message") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, devAppResponseError(operation, "remote_failure", message) + } + } + for _, key := range []string{"error", "errorCode", "error_code"} { + if value, present := candidate[key]; present && devAppErrorValue(value) { + return nil, devAppResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key)) + } + } + } + if !foundSuccess { + return nil, devAppResponseError(operation, "missing_success", "响应缺少 success 布尔终态,无法证明业务成功") + } + return data, nil +} + +func devAppOnlyEnvelopeFields(candidate map[string]any) bool { + for key := range candidate { + switch key { + case "success", "result", "data", "content", "message", "error", "errorCode", "error_code", "code": + default: + return false + } + } + return true +} + +func requireDevAppObject(data map[string]any, operation string) (map[string]any, error) { + data, err := requireDevAppSuccess(data, operation) + if err != nil { + return nil, err + } + containers := devAppContainers(data) + for index := len(containers) - 1; index >= 0; index-- { + candidate := containers[index] + if len(candidate) == 0 || devAppOnlyEnvelopeFields(candidate) { + continue + } + return candidate, nil + } + return nil, devAppResponseError(operation, "missing_business_result", "响应没有可验证的非空业务对象") +} + +func requireDevAppIdentity(object map[string]any, operation string, expected map[string]string) error { + for key, want := range expected { + if want == "" { + continue + } + if got := devAppFirstString(object, key); got == "" { + return devAppResponseError(operation, "readback_id_missing", fmt.Sprintf("读回对象缺少稳定字段 %s", key)) + } else if got != want { + return devAppResponseError(operation, "readback_id_mismatch", fmt.Sprintf("读回对象字段 %s 与请求目标不一致", key)) + } + } + return nil +} + +func requireDevAppFields(object map[string]any, operation string, expected map[string]any) error { + for key, want := range expected { + got, present := object[key] + if !present { + return devAppResponseError(operation, "readback_field_missing", fmt.Sprintf("读回对象缺少请求字段 %s", key)) + } + if fmt.Sprint(got) != fmt.Sprint(want) { + return devAppResponseError(operation, "readback_field_mismatch", fmt.Sprintf("读回对象字段 %s 与请求值不一致", key)) + } + } + return nil +} + +func requireDevAppStringField(object map[string]any, operation, want string, keys ...string) error { + if got := devAppFirstString(object, keys...); got == "" { + return devAppResponseError(operation, "readback_field_missing", fmt.Sprintf("读回对象缺少请求字段 %s", strings.Join(keys, "/"))) + } else if got != want { + return devAppResponseError(operation, "readback_field_mismatch", fmt.Sprintf("读回对象字段 %s 与请求值不一致", strings.Join(keys, "/"))) + } + return nil +} + +func requireDevAppCollection(data map[string]any, operation string, idKeys []string, keys ...string) ([]any, map[string]any, error) { + data, err := requireDevAppSuccess(data, operation) + if err != nil { + return nil, nil, err + } + for _, candidate := range devAppContainers(data) { + for _, key := range keys { + value, present := candidate[key] + if !present { + continue + } + items, ok := value.([]any) + if !ok { + return nil, nil, devAppResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) + } + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, nil, devAppResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index)) + } + if devAppFirstString(item, idKeys...) == "" { + return nil, nil, devAppResponseError(operation, "missing_item_id", fmt.Sprintf("响应 %s[%d] 缺少稳定身份字段", key, index)) + } + } + return items, candidate, nil + } + } + return nil, nil, devAppResponseError(operation, "missing_collection", "成功响应缺少预期集合字段") +} + +func readDevAppObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected map[string]string) (map[string]any, map[string]any, error) { + raw, err := rt.CallMCPData(productDevApp, tool, params) + if err != nil { + return nil, nil, err + } + object, err := requireDevAppObject(raw, productDevApp+"/"+tool) + if err != nil { + return nil, nil, err + } + if err := requireDevAppIdentity(object, productDevApp+"/"+tool, expected); err != nil { + return nil, nil, err + } + return raw, object, nil +} + +func outputDevAppObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected map[string]string) error { + raw, _, err := readDevAppObject(rt, tool, params, expected) + if err != nil { + return err + } + return rt.OutputForTool(tool, raw) +} + +func devAppWritePreview(rt *shortcut.RuntimeContext, tool string, params map[string]any) (bool, error) { + if !rt.DryRun() { + return false, nil + } + return true, rt.Output(map[string]any{ + "dry_run": true, "executed": false, "tool": tool, "arguments": params, + }) +} + +func callDevAppWrite(rt *shortcut.RuntimeContext, tool string, params map[string]any) (map[string]any, error) { + raw, err := rt.CallMCPWriteDataStrict(productDevApp, tool, params) + if err != nil { + return nil, err + } + return requireDevAppSuccess(raw, productDevApp+"/"+tool) +} + +func verifiedDevAppMutation(action, appID string, resource map[string]any) map[string]any { + result := map[string]any{ + "action": action, "unifiedAppId": appID, "verified": true, + } + if resource != nil { + result["resource"] = resource + } + return result +} + +func verifyDeletedDevApp(rt *shortcut.RuntimeContext, appID, appKey string) error { + if strings.TrimSpace(appKey) == "" { + return devAppResponseError("devapp/delete_dev_app", "missing_readback_selector", "删除前读回缺少 appKey,无法在删除后绑定稳定排除查询") + } + cursor := "" + seen := map[string]bool{} + for page := 0; page < devAppMaxReadbackPages; page++ { + params := map[string]any{"appKey": appKey, "pageSize": 20} + if cursor != "" { + params["cursor"] = cursor + } + raw, err := rt.CallMCPData(productDevApp, "list_dev_app", params) + if err != nil { + return err + } + apps, err := listAppProject(raw) + if err != nil { + return err + } + for _, app := range apps { + if devAppFirstString(app, "unifiedAppId") == appID { + return devAppResponseError("devapp/list_dev_app", "delete_readback_present", "删除后精确列表读回仍包含目标应用") + } + } + projection, err := devAppListProjection(raw, "apps", apps, "devapp/list_dev_app") + if err != nil { + return err + } + more, _ := projection["hasMore"].(bool) + if !more { + return nil + } + next := devAppFirstString(projection, "nextCursor") + if next == "" || next == cursor || seen[next] { + return devAppResponseError("devapp/list_dev_app", "cursor_stall", "删除后排除查询的分页游标未推进") + } + seen[next] = true + cursor = next + } + return devAppResponseError("devapp/list_dev_app", "page_limit", "删除后排除查询达到页数上限,无法证明目标已不存在") +} + +func changeDevAppStatus(rt *shortcut.RuntimeContext, tool, action, expectedStatus string) error { + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, tool, params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, tool, params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_dev_app", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if err := requireDevAppStringField(resource, "devapp/get_dev_app", expectedStatus, "appStatus", "status"); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation(action, appID, resource)) +} + // --------------------------------------------------------------------------- // 应用主体 // --------------------------------------------------------------------------- @@ -182,24 +498,31 @@ var ListApp = shortcut.Shortcut{ if err != nil { return err } - apps := listAppProject(data) - return rt.Output(devAppListProjection(data, "apps", apps)) + apps, err := listAppProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "apps", apps, "devapp/list_dev_app") + if err != nil { + return err + } + return rt.Output(projection) }, } // listAppProject reshapes list_dev_app into a clean app list // ({unifiedAppId, name, appKey, agentId, status, gmtModified}) — output-projection -// clean output projection. The list container and per-item field names are probed -// defensively across candidate keys, so an unknown/empty shape yields an empty -// list rather than a crash or fabricated data. -func listAppProject(data map[string]any) []map[string]any { - raw := listAppFindList(data) +// clean output projection. The list container and every item are validated +// before projection so unknown response shapes fail closed. +func listAppProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app", + []string{"unifiedAppId", "unified_app_id"}, "list", "items", "apps", "appList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := listAppFirst(m, "unifiedAppId", "unified_app_id"); ok { row["unifiedAppId"] = v @@ -219,36 +542,9 @@ func listAppProject(data map[string]any) []map[string]any { if v, ok := listAppFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { row["gmtModified"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// listAppFindList locates the app list payload, tolerating a bare top-level -// array or nesting one level under a common envelope key. -func listAppFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "apps", "appList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "apps", "appList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // listAppFirst returns the first present candidate key's value. @@ -302,7 +598,8 @@ var GetApp = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -338,6 +635,7 @@ var CreateApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +create --name "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "应用名称", Required: true}, @@ -352,7 +650,39 @@ var CreateApp = shortcut.Shortcut{ if rt.Changed("icon-media-id") { params["iconMediaId"] = rt.Str("icon-media-id") } - return rt.CallMCP("create_dev_app", params) + if previewed, err := devAppWritePreview(rt, "create_dev_app", params); previewed || err != nil { + return err + } + receipt, err := callDevAppWrite(rt, "create_dev_app", params) + if err != nil { + return err + } + created, err := requireDevAppObject(receipt, "devapp/create_dev_app") + if err != nil { + return err + } + appID := devAppFirstString(created, "unifiedAppId", "unified_app_id") + if appID == "" { + return devAppResponseError("devapp/create_dev_app", "missing_resource_id", "创建回执缺少 unifiedAppId,无法绑定精确读回") + } + _, resource, err := readDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("name"), "name", "appName"); err != nil { + return err + } + if rt.Changed("desc") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("desc"), "desc", "description"); err != nil { + return err + } + } + if rt.Changed("icon-media-id") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("icon-media-id"), "iconMediaId", "icon"); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("create", appID, resource)) }, } @@ -388,15 +718,30 @@ var UpdateApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +update --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称"}, - {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述"}, - {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称;至少提供一项非空的应用基础信息更新"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述;至少提供一项非空的应用基础信息更新"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId;至少提供一项非空的应用基础信息更新"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"name", "desc", "icon-media-id"}, + Description: "至少提供一项非空的应用基础信息更新", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"name", "desc", "icon-media-id"} { + if rt.Changed(name) && rt.Str(name) != "" { + return nil + } + } + return apperrors.NewValidation("至少提供一项待更新字段:--name、--desc 或 --icon-media-id") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("name") { params["name"] = rt.Str("name") } @@ -406,7 +751,26 @@ var UpdateApp = shortcut.Shortcut{ if rt.Changed("icon-media-id") { params["iconMediaId"] = rt.Str("icon-media-id") } - return rt.CallMCP("update_dev_app", params) + if previewed, err := devAppWritePreview(rt, "update_dev_app", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "update_dev_app", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "name": {"name", "appName"}, "desc": {"desc", "description"}, "icon-media-id": {"iconMediaId", "icon"}, + } { + if rt.Changed(flag) { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str(flag), keys...); err != nil { + return err + } + } + } + return rt.Output(verifiedDevAppMutation("update", appID, resource)) }, } @@ -442,12 +806,32 @@ var DeleteApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +delete --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("delete_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, "delete_dev_app", params); previewed || err != nil { + return err + } + _, existing, err := readDevAppObject(rt, "get_dev_app", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + appKey := devAppFirstString(existing, "appKey", "clientId") + if appKey == "" { + return devAppResponseError("devapp/get_dev_app", "missing_readback_selector", "删除前读回缺少 appKey,拒绝进入不可逆写入") + } + if _, err := callDevAppWrite(rt, "delete_dev_app", params); err != nil { + return err + } + if err := verifyDeletedDevApp(rt, appID, appKey); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("delete", appID, nil)) }, } @@ -483,12 +867,13 @@ var EnableApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +enable --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("enable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppStatus(rt, "enable_dev_app", "enable", "normal") }, } @@ -524,12 +909,13 @@ var DisableApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +disable --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("disable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppStatus(rt, "disable_dev_app", "disable", "disabled") }, } @@ -541,11 +927,48 @@ var GetCredentials = shortcut.Shortcut{ Description: "读取开放平台应用凭证", Intent: "当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "medium", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", + Name: "shortcut_credentials_get", + CanonicalPath: "devapp.shortcut_credentials_get", + CLIPath: "devapp +credentials-get", + PrimaryCLIPath: "devapp +credentials-get", + }, + Description: "读取开放平台应用凭证", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed adapter requires an explicit success marker, a non-empty credential object, exact unifiedAppId, a public client identifier, and a non-empty secret before unified output; secret fields are declared sensitive.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "读取开放平台应用凭证", + UseWhen: []string{"当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。"}, + AvoidWhen: []string{"只需应用元数据时使用 +get;不要把返回密钥写入文档、日志、示例或聊天正文"}, + Examples: []string{"dws devapp +credentials-get --unified-app-id "}, + }, + Result: devAppCredentialResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_dev_app_credentials", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + _, credentials, err := readDevAppObject(rt, "get_dev_app_credentials", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if devAppFirstString(credentials, "appKey", "clientId") == "" { + return devAppResponseError("devapp/get_dev_app_credentials", "missing_client_id", "凭证响应缺少非空 appKey/clientId") + } + if devAppFirstString(credentials, "appSecret", "clientSecret", "secret") == "" { + return devAppResponseError("devapp/get_dev_app_credentials", "missing_client_secret", "凭证响应缺少非空 appSecret/clientSecret") + } + return rt.Output(credentials) }, } @@ -594,7 +1017,8 @@ var WebappGet = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_extension_webapp_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_extension_webapp_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -630,16 +1054,31 @@ var WebappConfig = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +webapp-config --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型"}, - {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址"}, - {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址"}, - {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址"}, + {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型;至少提供一项非空的网页应用配置"}, + {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址;至少提供一项非空的网页应用配置"}, + {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址;至少提供一项非空的网页应用配置"}, + {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址;至少提供一项非空的网页应用配置"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"h5-page-type", "homepage-url", "pc-homepage-url", "omp-url"}, + Description: "至少提供一项非空的网页应用配置", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"h5-page-type", "homepage-url", "pc-homepage-url", "omp-url"} { + if rt.Changed(name) && rt.Str(name) != "" { + return nil + } + } + return apperrors.NewValidation("至少提供一项网页应用配置:--h5-page-type、--homepage-url、--pc-homepage-url 或 --omp-url") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("h5-page-type") { params["h5PageType"] = rt.Str("h5-page-type") } @@ -652,7 +1091,27 @@ var WebappConfig = shortcut.Shortcut{ if rt.Changed("omp-url") { params["ompUrl"] = rt.Str("omp-url") } - return rt.CallMCP("set_extension_webapp_config", params) + if previewed, err := devAppWritePreview(rt, "set_extension_webapp_config", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "set_extension_webapp_config", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_webapp_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "h5-page-type": {"h5PageType"}, "homepage-url": {"homepageUrl"}, + "pc-homepage-url": {"pcHomepageUrl"}, "omp-url": {"ompUrl"}, + } { + if rt.Changed(flag) { + if err := requireDevAppStringField(resource, "devapp/get_extension_webapp_config", rt.Str(flag), keys...); err != nil { + return err + } + } + } + return rt.Output(verifiedDevAppMutation("webapp_config", appID, resource)) }, } @@ -725,24 +1184,33 @@ var PermissionList = shortcut.Shortcut{ if err != nil { return err } - permissions := permissionListProject(data) - return rt.Output(devAppListProjection(data, "permissions", permissions)) + permissions, err := permissionListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "permissions", permissions, "devapp/list_dev_app_permissions") + if err != nil { + return err + } + return rt.Output(projection) }, } // permissionListProject reshapes list_dev_app_permissions into a clean // permission-point list ({scopeValue, scopeName, apiName, authStatus, scopeType}) // — clean output projection. The list container and per-item field -// names are probed defensively across candidate keys, so an unknown/empty shape -// yields an empty list rather than a crash or fabricated data. -func permissionListProject(data map[string]any) []map[string]any { - raw := permissionListFindList(data) +// names are probed across reviewed candidate keys, while missing, mistyped, or +// malformed collections fail closed. +func permissionListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_permissions", + []string{"scopeValue", "scope_value", "permissionCode", "code"}, + "list", "items", "permissions", "permissionList", "scopes") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := permissionListFirst(m, "scopeValue", "scope_value", "permissionCode", "code"); ok { row["scopeValue"] = v @@ -759,36 +1227,9 @@ func permissionListProject(data map[string]any) []map[string]any { if v, ok := permissionListFirst(m, "scopeType", "scope_type"); ok { row["scopeType"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// permissionListFindList locates the permission list payload, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func permissionListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // permissionListFirst returns the first present candidate key's value. @@ -877,24 +1318,112 @@ var MemberList = shortcut.Shortcut{ AgentSummary: "查询开放平台应用成员", UseWhen: []string{"当你要查看某应用有哪些成员及其角色(如谁是开发者/管理员),用于核对协作人员或权限归属时使用;输入 unifiedAppId,返回成员列表。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws devapp +member-list --unified-app-id "}, + Examples: []string{"dws devapp +member-list --unified-app-id --user-id "}, }, + Result: devAppMemberListResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "user-id", Type: shortcut.FlagString, Desc: "可选稳定 userId;由 Shortcut 在严格验证完整成员数组后做精确等值筛选"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + if err != nil { + return err + } + members, _, err := requireDevAppCollection(raw, "devapp/list_dev_app_members", []string{"userId", "user_id"}, "members", "items", "list") + if err != nil { + return err + } + wantUser := strings.TrimSpace(rt.Str("user-id")) + out := projectDevAppMembers(members, wantUser) + return rt.Output(map[string]any{"count": len(out), "members": out}) }, } +func projectDevAppMembers(members []any, wantUser string) []map[string]any { + out := make([]map[string]any, 0, len(members)) + for _, value := range members { + member := value.(map[string]any) + if wantUser != "" && devAppFirstString(member, "userId", "user_id") != wantUser { + continue + } + out = append(out, member) + } + return out +} + +func readDevAppMembers(rt *shortcut.RuntimeContext, appID string) ([]any, error) { + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_members", map[string]any{"unifiedAppId": appID}) + if err != nil { + return nil, err + } + members, _, err := requireDevAppCollection(raw, "devapp/list_dev_app_members", + []string{"userId", "user_id"}, "members", "items", "list") + return members, err +} + +func verifyDevAppMembers(rt *shortcut.RuntimeContext, appID string, userIDs []string, memberType string, present bool) error { + members, err := readDevAppMembers(rt, appID) + if err != nil { + return err + } + want := make(map[string]bool, len(userIDs)) + for _, userID := range userIDs { + want[userID] = true + } + found := make(map[string]int, len(userIDs)) + for _, value := range members { + member := value.(map[string]any) + userID := devAppFirstString(member, "userId", "user_id") + if !want[userID] { + continue + } + found[userID]++ + if found[userID] > 1 { + return devAppResponseError("devapp/list_dev_app_members", "duplicate_readback_identity", "成员读回包含重复稳定 userId,无法证明唯一终态") + } + if present { + if err := requireDevAppStringField(member, "devapp/list_dev_app_members", memberType, "memberType", "member_type"); err != nil { + return err + } + } + } + for _, userID := range userIDs { + if present && found[userID] != 1 { + return devAppResponseError("devapp/list_dev_app_members", "member_readback_missing", "添加后成员列表未精确包含全部目标 userId") + } + if !present && found[userID] != 0 { + return devAppResponseError("devapp/list_dev_app_members", "member_remove_readback_present", "移除后成员列表仍包含目标 userId") + } + } + return nil +} + +func validatedDevAppMemberInputs(rt *shortcut.RuntimeContext) ([]string, string, error) { + userIDs, err := validatedDevAppValues(rt.StrSlice("user-ids"), "--user-ids") + if err != nil { + return nil, "", err + } + memberType, err := validatedDevAppMemberType(rt.Str("member-type")) + return userIDs, memberType, err +} + +func validatedDevAppMemberType(value string) (string, error) { + memberType := strings.TrimSpace(value) + if memberType == "" { + return "", apperrors.NewValidation("--member-type 不能为空") + } + return memberType, nil +} + // MemberAdd maps helper `add_dev_app_members`. var MemberAdd = shortcut.Shortcut{ Service: "devapp", Command: "+member-add", Product: productDevApp, Description: "添加开放平台应用成员", - Intent: "当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。", + Intent: "当你要给某应用增加协作人员并要求按稳定 userId 逐项确认成员角色已写回时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER)。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -916,23 +1445,45 @@ var MemberAdd = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "添加开放平台应用成员", - UseWhen: []string{"当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。"}, + UseWhen: []string{"当你要给某应用增加协作人员并要求按稳定 userId 逐项确认成员角色已写回时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER)。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +member-add --unified-app-id --user-ids --member-type "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, - {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表,不能为空且不能重复", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER,不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user-ids"}, Description: "userId 列表不能为空且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-type"}, Description: "成员类型不能为空"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + _, _, err := validatedDevAppMemberInputs(rt) + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, memberType, _ := validatedDevAppMemberInputs(rt) + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "userIds": rt.StrSlice("user-ids"), - "memberType": rt.Str("member-type"), + "unifiedAppId": appID, + "userIds": userIDs, + "memberType": memberType, } - return rt.CallMCP("add_dev_app_members", params) + if previewed, err := devAppWritePreview(rt, "add_dev_app_members", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "add_dev_app_members", params); err != nil { + return err + } + if err := verifyDevAppMembers(rt, appID, userIDs, memberType, true); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("member_add", appID, map[string]any{ + "count": len(userIDs), "memberType": memberType, + })) }, } @@ -942,7 +1493,7 @@ var MemberRemove = shortcut.Shortcut{ Command: "+member-remove", Product: productDevApp, Description: "移除开放平台应用成员", - Intent: "当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。", + Intent: "当某人不再参与、你要取消其应用成员身份,并要求按稳定 userId 逐项确认目标已不在成员列表中时使用;传入 unifiedAppId、userId 列表和成员类型。", Risk: shortcut.RiskHighWrite, Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", @@ -964,23 +1515,45 @@ var MemberRemove = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "移除开放平台应用成员", - UseWhen: []string{"当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。"}, + UseWhen: []string{"当某人不再参与、你要取消其应用成员身份,并要求按稳定 userId 逐项确认目标已不在成员列表中时使用;传入 unifiedAppId、userId 列表和成员类型。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +member-remove --unified-app-id --user-ids --member-type "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, - {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表,不能为空且不能重复", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER,不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user-ids"}, Description: "userId 列表不能为空且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-type"}, Description: "成员类型不能为空"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + _, _, err := validatedDevAppMemberInputs(rt) + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, memberType, _ := validatedDevAppMemberInputs(rt) + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "userIds": rt.StrSlice("user-ids"), - "memberType": rt.Str("member-type"), + "unifiedAppId": appID, + "userIds": userIDs, + "memberType": memberType, } - return rt.CallMCP("remove_dev_app_members", params) + if previewed, err := devAppWritePreview(rt, "remove_dev_app_members", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "remove_dev_app_members", params); err != nil { + return err + } + if err := verifyDevAppMembers(rt, appID, userIDs, memberType, false); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("member_remove", appID, map[string]any{ + "count": len(userIDs), "memberType": memberType, + })) }, } @@ -1062,7 +1635,8 @@ var RobotGet = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_extension_robot_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_extension_robot_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -1075,21 +1649,63 @@ var RobotConfig = shortcut.Shortcut{ Description: "创建或更新现有应用的机器人配置(upsert)", Intent: "当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_config", + CanonicalPath: "devapp.shortcut_robot_config", CLIPath: "devapp +robot-config", PrimaryCLIPath: "devapp +robot-config", + }, + Description: "创建或更新现有应用的机器人配置并精确读回", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter rejects an empty update, requires terminal success, then reads the same unifiedAppId and compares every changed scalar field before returning verified output.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建或更新现有应用的机器人配置(upsert)", + UseWhen: []string{"当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。"}, + AvoidWhen: []string{"只需查看现状使用 +robot-get;仅切换已配置机器人的在线状态使用 +robot-enable 或 +robot-disable"}, + Examples: []string{"dws devapp +robot-config --unified-app-id --name --mode STREAM"}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称"}, - {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介"}, - {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述"}, - {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId"}, - {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址"}, - {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址"}, - {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL"}, - {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表"}, - {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限"}, - {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验"}, + {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"name", "brief", "desc", "icon-media-id", "outgoing-url", "event-callback-url", "mode", "skills", "add-scope", "disable-ssl-verify"}, + Description: "至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"name", "brief", "desc", "icon-media-id", "outgoing-url", "event-callback-url", "mode"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) != "" { + return nil + } + } + if rt.Changed("skills") && len(rt.StrSlice("skills")) > 0 { + return nil + } + if rt.Changed("add-scope") || rt.Changed("disable-ssl-verify") { + return nil + } + return apperrors.NewValidation("至少提供一项非空机器人配置") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("name") { params["name"] = rt.Str("name") } @@ -1120,7 +1736,45 @@ var RobotConfig = shortcut.Shortcut{ if rt.Changed("disable-ssl-verify") { params["disableSSLVerify"] = rt.Bool("disable-ssl-verify") } - return rt.CallMCP("set_extension_robot_config", params) + if previewed, err := devAppWritePreview(rt, "set_extension_robot_config", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "set_extension_robot_config", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_robot_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "name": {"name"}, "brief": {"brief"}, "desc": {"desc", "description"}, + "icon-media-id": {"iconMediaId"}, "outgoing-url": {"outgoingUrl"}, + "event-callback-url": {"eventCallbackUrl"}, "mode": {"mode"}, + } { + if !rt.Changed(flag) { + continue + } + want := rt.Str(flag) + if flag == "mode" { + want = strings.ToUpper(want) + } + if err := requireDevAppStringField(resource, "devapp/get_extension_robot_config", want, keys...); err != nil { + return err + } + } + for flag, key := range map[string]string{"add-scope": "addScope", "disable-ssl-verify": "disableSSLVerify"} { + if rt.Changed(flag) { + if err := requireDevAppFields(resource, "devapp/get_extension_robot_config", map[string]any{key: rt.Bool(flag)}); err != nil { + return err + } + } + } + if rt.Changed("skills") { + if err := requireDevAppFields(resource, "devapp/get_extension_robot_config", map[string]any{"skills": rt.StrSlice("skills")}); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("robot_config", appID, resource)) }, } @@ -1130,13 +1784,32 @@ var RobotEnable = shortcut.Shortcut{ Command: "+robot-enable", Product: productDevApp, Description: "启用现有应用机器人能力(纯启用,无需配置字段)", - Intent: "当应用的机器人能力已配置但处于关闭状态、你只想把它打开生效时使用;仅传 unifiedAppId 即可,会实际启用该应用的机器人能力,无需再传配置字段。", + Intent: "当应用已经具有可启用的机器人配置、你要把它切换到 ONLINE 时使用;写后会读取同一 unifiedAppId 并只在 robotStatus=ONLINE 时成功。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_enable", + CanonicalPath: "devapp.shortcut_robot_enable", CLIPath: "devapp +robot-enable", PrimaryCLIPath: "devapp +robot-enable", + }, + Description: "启用已配置的应用机器人并读回 ONLINE 终态", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and exact get_extension_robot_config readback for the same unifiedAppId with robotStatus=ONLINE."}, + Selection: contract.SelectionSpec{ + AgentSummary: "启用现有应用机器人能力(纯启用,无需配置字段)", + UseWhen: []string{"当应用已经具有可启用的机器人配置、你要把它切换到 ONLINE 时使用;写后会读取同一 unifiedAppId 并只在 robotStatus=ONLINE 时成功。"}, + AvoidWhen: []string{"需要修改机器人字段时使用 +robot-config;只查看状态使用 +robot-get"}, + Examples: []string{"dws devapp +robot-enable --unified-app-id "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("enable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppRobotStatus(rt, "enable_dev_app_robot", "robot_enable", "ONLINE") }, } @@ -1146,16 +1819,58 @@ var RobotDisable = shortcut.Shortcut{ Command: "+robot-disable", Product: productDevApp, Description: "停用现有应用的机器人能力", - Intent: "当你要临时关闭某应用的机器人(不再收发机器人消息)但保留其配置时使用;传入 unifiedAppId 会实际停用机器人能力,可日后再启用恢复。", + Intent: "当你要下线某应用的机器人、不再收发机器人消息时使用;当前平台读回终态为 UNCONFIGURED,因此重新上线前可能需要先用 +robot-config 恢复配置。", Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_disable", + CanonicalPath: "devapp.shortcut_robot_disable", CLIPath: "devapp +robot-disable", PrimaryCLIPath: "devapp +robot-disable", + }, + Description: "下线应用机器人并读回 UNCONFIGURED 终态", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and exact get_extension_robot_config readback for the same unifiedAppId with robotStatus=UNCONFIGURED; it does not promise that configuration remains reusable."}, + Selection: contract.SelectionSpec{ + AgentSummary: "停用现有应用的机器人能力", + UseWhen: []string{"当你要下线某应用的机器人、不再收发机器人消息时使用;当前平台读回终态为 UNCONFIGURED,因此重新上线前可能需要先用 +robot-config 恢复配置。"}, + AvoidWhen: []string{"只需临时停用但必须保证配置可原样恢复时不要使用;当前下游没有该保留语义"}, + Examples: []string{"dws devapp +robot-disable --unified-app-id "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("disable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppRobotStatus(rt, "disable_dev_app_robot", "robot_disable", "UNCONFIGURED") }, } +func changeDevAppRobotStatus(rt *shortcut.RuntimeContext, tool, action, wantStatus string) error { + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, tool, params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, tool, params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_robot_config", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + gotStatus := strings.ToUpper(devAppFirstString(resource, "robotStatus", "robot_status", "status")) + if gotStatus == "" { + return devAppResponseError("devapp/get_extension_robot_config", "readback_status_missing", "机器人状态读回缺少 robotStatus") + } + if gotStatus != wantStatus { + return devAppResponseError("devapp/get_extension_robot_config", "readback_status_mismatch", fmt.Sprintf("机器人状态读回为 %s,预期 %s", gotStatus, wantStatus)) + } + return rt.Output(verifiedDevAppMutation(action, appID, resource)) +} + // --------------------------------------------------------------------------- // 事件订阅 // --------------------------------------------------------------------------- @@ -1165,8 +1880,8 @@ var EventList = shortcut.Shortcut{ Service: "devapp", Command: "+event-list", Product: productDevApp, - Description: "查询应用已订阅的事件列表", - Intent: "当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。", + Description: "查询应用可用事件目录与订阅状态", + Intent: "当你要查某应用可用的事件码、事件名称及当前订阅状态(用于选择订阅项、排查漏收事件或退订前核对)时使用;输入 unifiedAppId,可按关键词过滤并游标分页。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -1180,15 +1895,15 @@ var EventList = shortcut.Shortcut{ CLIPath: "devapp +event-list", PrimaryCLIPath: "devapp +event-list", }, - Description: "查询应用已订阅的事件列表", + Description: "查询应用可用事件目录与订阅状态", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "查询应用已订阅的事件列表", - UseWhen: []string{"当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。"}, + AgentSummary: "查询应用可用事件目录与订阅状态", + UseWhen: []string{"当你要查某应用可用的事件码、事件名称及当前订阅状态(用于选择订阅项、排查漏收事件或退订前核对)时使用;输入 unifiedAppId,可按关键词过滤并游标分页。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +event-list --unified-app-id "}, }, @@ -1209,24 +1924,31 @@ var EventList = shortcut.Shortcut{ if err != nil { return err } - events := eventListProject(data) - return rt.Output(devAppListProjection(data, "events", events)) + events, err := eventListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "events", events, "devapp/list_dev_app_events") + if err != nil { + return err + } + return rt.Output(projection) }, } // eventListProject reshapes list_dev_app_events into a clean subscribed-event // list ({eventCode, eventName, status, gmtModified}) — output-projection -// clean output projection. The list container and per-item field names are probed -// defensively across candidate keys, so an unknown/empty shape yields an empty -// list rather than a crash or fabricated data. -func eventListProject(data map[string]any) []map[string]any { - raw := eventListFindList(data) +// clean output projection. The list container and every item are validated +// before projection so unknown response shapes fail closed. +func eventListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_events", + []string{"eventCode", "event_code", "code"}, "list", "items", "events", "eventList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := eventListFirst(m, "eventCode", "event_code", "code"); ok { row["eventCode"] = v @@ -1240,36 +1962,9 @@ func eventListProject(data map[string]any) []map[string]any { if v, ok := eventListFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { row["gmtModified"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// eventListFindList locates the event list payload, tolerating a bare top-level -// array or nesting one level under a common envelope key. -func eventListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "events", "eventList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "events", "eventList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // eventListFirst returns the first present candidate key's value. @@ -1290,19 +1985,127 @@ var EventSubscribe = shortcut.Shortcut{ Description: "订阅应用事件回调", Intent: "当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_event_subscribe", + CanonicalPath: "devapp.shortcut_event_subscribe", CLIPath: "devapp +event-subscribe", PrimaryCLIPath: "devapp +event-subscribe", + }, + Description: "订阅应用事件并逐项精确读回", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter rejects empty or duplicate event codes, requires terminal success, then traverses the bounded event cursor and verifies every requested eventCode under the same unifiedAppId."}, + Selection: contract.SelectionSpec{ + AgentSummary: "订阅应用事件回调", + UseWhen: []string{"当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。"}, + AvoidWhen: []string{"只需查看现有订阅时暂用 dev app event list 原子命令;+event-list 缺少零结果分页终止事实,退订也无法证明实际移除"}, + Examples: []string{"dws devapp +event-subscribe --unified-app-id --event-codes "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表", Required: true}, + {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表至少包含一项非空且互不重复的 eventCode", Required: true}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"event-codes"}, + Description: "事件码列表至少包含一项非空且互不重复的 eventCode", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + _, err := validatedDevAppValues(rt.StrSlice("event-codes"), "--event-codes") + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + // Validate already rejects empty and duplicate values before Execute. + codes, _ := validatedDevAppValues(rt.StrSlice("event-codes"), "--event-codes") + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "eventCodes": rt.StrSlice("event-codes"), + "unifiedAppId": appID, + "eventCodes": codes, } - return rt.CallMCP("subscribe_dev_app_events", params) + if previewed, err := devAppWritePreview(rt, "subscribe_dev_app_events", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "subscribe_dev_app_events", params); err != nil { + return err + } + if err := verifyDevAppEventCodes(rt, appID, codes); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("event_subscribe", appID, map[string]any{"eventCodes": codes})) }, } +func validatedDevAppValues(values []string, flag string) ([]string, error) { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + return nil, apperrors.NewValidation(flag + " 不能包含空值") + } + if seen[value] { + return nil, apperrors.NewValidation(flag + " 不能包含重复值") + } + seen[value] = true + out = append(out, value) + } + if len(out) == 0 { + return nil, apperrors.NewValidation(flag + " 至少需要一个非空值") + } + return out, nil +} + +func verifyDevAppEventCodes(rt *shortcut.RuntimeContext, appID string, wantCodes []string) error { + want := make(map[string]bool, len(wantCodes)) + for _, code := range wantCodes { + want[code] = true + } + found := map[string]bool{} + cursor := "" + seenCursors := map[string]bool{} + for page := 0; page < devAppMaxReadbackPages; page++ { + params := map[string]any{"unifiedAppId": appID, "pageSize": 100} + if cursor != "" { + params["cursor"] = cursor + } + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_events", params) + if err != nil { + return err + } + events, err := eventListProject(raw) + if err != nil { + return err + } + for _, event := range events { + code := devAppFirstString(event, "eventCode") + if want[code] { + found[code] = true + } + } + if len(found) == len(want) { + return nil + } + projection, err := devAppListProjection(raw, "events", events, "devapp/list_dev_app_events") + if err != nil { + return err + } + more, _ := projection["hasMore"].(bool) + if !more { + return devAppResponseError("devapp/list_dev_app_events", "subscription_readback_missing", "事件订阅终态读回缺少请求的 eventCode") + } + next := devAppFirstString(projection, "nextCursor") + if next == "" || next == cursor || seenCursors[next] { + return devAppResponseError("devapp/list_dev_app_events", "cursor_stall", "事件订阅读回游标未推进") + } + seenCursors[next] = true + cursor = next + } + return devAppResponseError("devapp/list_dev_app_events", "readback_page_limit", "事件订阅读回超过有界页数,无法证明全部事件已订阅") +} + // EventUnsubscribe maps helper `unsubscribe_dev_app_events`. var EventUnsubscribe = shortcut.Shortcut{ Service: "devapp", @@ -1336,20 +2139,72 @@ var VersionCreate = shortcut.Shortcut{ Description: "基于当前配置创建应用新版本", Intent: "当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_version_create", + CanonicalPath: "devapp.shortcut_version_create", CLIPath: "devapp +version-create", PrimaryCLIPath: "devapp +version-create", + }, + Description: "创建不可变版本快照并按应用与版本双 ID 精确读回", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and a non-empty versionId, then reads get_dev_app_version_detail with the same unifiedAppId/versionId and compares supplied version metadata before verified output."}, + Selection: contract.SelectionSpec{ + AgentSummary: "基于当前配置创建应用新版本", + UseWhen: []string{"当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。"}, + AvoidWhen: []string{"只需查看历史版本使用 +version-list;正式发布使用前先运行 +version-check-approval,发布能力当前仍不可用"}, + Examples: []string{"dws devapp +version-create --unified-app-id --desc "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, {Name: "version", Type: shortcut.FlagString, Desc: "高级可选:显式版本号,如 1.0.1;默认由服务端自动递增"}, {Name: "desc", Type: shortcut.FlagString, Desc: "版本描述"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("version") { params["version"] = rt.Str("version") } if rt.Changed("desc") { params["desc"] = rt.Str("desc") } - return rt.CallMCP("create_dev_app_version", params) + if previewed, err := devAppWritePreview(rt, "create_dev_app_version", params); previewed || err != nil { + return err + } + created, err := callDevAppWrite(rt, "create_dev_app_version", params) + if err != nil { + return err + } + createdObject, err := requireDevAppObject(created, "devapp/create_dev_app_version") + if err != nil { + return err + } + versionID := devAppFirstString(createdObject, "versionId", "version_id", "id") + if versionID == "" { + return devAppResponseError("devapp/create_dev_app_version", "missing_version_id", "创建版本回执缺少稳定 versionId") + } + _, resource, err := readDevAppObject(rt, "get_dev_app_version_detail", map[string]any{ + "unifiedAppId": appID, + "versionId": versionID, + }, map[string]string{"unifiedAppId": appID, "versionId": versionID}) + if err != nil { + return err + } + if rt.Changed("version") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app_version_detail", rt.Str("version"), "version", "versionName"); err != nil { + return err + } + } + if rt.Changed("desc") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app_version_detail", rt.Str("desc"), "desc", "description", "remark"); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("version_create", appID, resource)) }, } @@ -1398,61 +2253,76 @@ var VersionList = shortcut.Shortcut{ if err != nil { return err } - versions := versionListProject(data) - return rt.Output(devAppListProjection(data, "versions", versions)) + versions, err := versionListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "versions", versions, "devapp/list_dev_app_versions") + if err != nil { + return err + } + return rt.Output(projection) }, } -func devAppListProjection(data map[string]any, key string, items []map[string]any) map[string]any { +func devAppListProjection(data map[string]any, key string, items []map[string]any, operation string) (map[string]any, error) { + if _, err := requireDevAppSuccess(data, operation); err != nil { + return nil, err + } out := map[string]any{"count": len(items), key: items} for _, candidate := range devAppPaginationCandidates(data) { - _, hasMore := candidate["hasMore"] - _, hasCursor := candidate["nextCursor"] + rawHasMore, hasMore := candidate["hasMore"] + rawCursor, hasCursor := candidate["nextCursor"] if !hasMore && !hasCursor { continue } - if hasMore { - out["hasMore"] = candidate["hasMore"] + if !hasMore { + return nil, devAppResponseError(operation, "missing_has_more", "分页响应只有 nextCursor,缺少 hasMore 终止证据") } + more, ok := rawHasMore.(bool) + if !ok { + return nil, devAppResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值") + } + out["hasMore"] = more if hasCursor { - out["nextCursor"] = candidate["nextCursor"] + cursor, ok := rawCursor.(string) + if !ok { + return nil, devAppResponseError(operation, "malformed_cursor", "分页响应 nextCursor 不是字符串") + } + if strings.TrimSpace(cursor) != "" { + out["nextCursor"] = strings.TrimSpace(cursor) + } } - break + if more && devAppFirstString(out, "nextCursor") == "" { + return nil, devAppResponseError(operation, "missing_cursor", "分页响应 hasMore=true 但缺少可推进的 nextCursor") + } + return out, nil } - return out + return nil, devAppResponseError(operation, "missing_pagination", "分页响应缺少 hasMore 终止证据") } func devAppPaginationCandidates(data map[string]any) []map[string]any { - if data == nil { - return nil - } - candidates := []map[string]any{data} - for _, key := range []string{"content", "result", "data"} { - if nested, ok := data[key].(map[string]any); ok { - candidates = append(candidates, nested) - for _, innerKey := range []string{"result", "data"} { - if inner, ok := nested[innerKey].(map[string]any); ok { - candidates = append(candidates, inner) - } - } - } - } - return candidates + // Pagination evidence can occur at any of the reviewed envelope layers. + // Reuse the same bounded breadth-first walk as response validation so a + // valid content.result (or deeper content/result/data combination) cannot + // be mistaken for a response without a pagination terminus. + return devAppContainers(data) } // versionListProject reshapes list_dev_app_versions into a clean version list // ({versionId, version, status, desc, gmtCreate}) — output-projection fidelity // for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func versionListProject(data map[string]any) []map[string]any { - raw := versionListFindList(data) +// across reviewed candidate keys, while missing, mistyped, or malformed +// collections fail closed. +func versionListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_versions", + []string{"versionId", "version_id", "id"}, "list", "items", "versions", "versionList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := versionListFirst(m, "versionId", "version_id", "id"); ok { row["versionId"] = v @@ -1469,36 +2339,9 @@ func versionListProject(data map[string]any) []map[string]any { if v, ok := versionListFirst(m, "gmtCreate", "gmt_create", "createTime", "create_time"); ok { row["gmtCreate"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// versionListFindList locates the version list payload, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func versionListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "versions", "versionList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "versions", "versionList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // versionListFirst returns the first present candidate key's value. @@ -1553,11 +2396,15 @@ var VersionGet = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, } - return rt.CallMCP("get_dev_app_version_detail", params) + return outputDevAppObject(rt, "get_dev_app_version_detail", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } @@ -1605,12 +2452,16 @@ var VersionCheckApproval = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, "precheckOnly": true, } - return rt.CallMCP("publish_dev_app_version", params) + return outputDevAppObject(rt, "publish_dev_app_version", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } @@ -1688,50 +2539,87 @@ var VersionStatus = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, } - return rt.CallMCP("get_dev_app_version_status", params) + return outputDevAppObject(rt, "get_dev_app_version_status", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } +var devAppUnavailableReasons = map[string]string{ + "+permission-add": "权限申请缺少逐项终态回执与按 scopeValue 精确读回验证", + "+permission-remove": "权限移除缺少逐项终态回执与按 scopeValue 精确读回验证", + "+security-config": "整组覆盖写入缺少读取当前值、精确写回和恢复原配置的安全闭环", + "+event-unsubscribe": "事件退订缺少可恢复的稳定订阅 fixture 与逐项读回", + "+version-publish": "版本发布可能进入审批或线上生效,缺少安全测试租户与回滚合同", +} + +func unavailableDevAppShortcut(item shortcut.Shortcut, reason string) shortcut.Shortcut { + item.Hidden = true + item.Availability = shortcut.AvailabilityUnavailable + item.OutputRollout = output.RolloutDualValidate + item.Contract = corecmd.ContractDecl{} + constraintFlags := make([]string, 0, len(item.Flags)) + for _, flag := range item.Flags { + constraintFlags = append(constraintFlags, flag.Name) + } + item.Constraints = append(item.Constraints, shortcut.Constraint{ + Kind: shortcut.ConstraintCustom, + Flags: constraintFlags, + Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足", + }) + unavailable := func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("该 DevApp Shortcut 当前 unavailable:" + reason) + } + item.Validate = unavailable + item.Execute = unavailable + return item +} + func init() { - shortcut.Register( + items := []shortcut.Shortcut{ frameworkUnified(ListApp), frameworkUnified(GetApp), - frameworkDualValidate(CreateApp), - frameworkDualValidate(UpdateApp), - frameworkDualValidate(DeleteApp), - frameworkDualValidate(EnableApp), - frameworkDualValidate(DisableApp), - // Credentials contain secrets and still lack a reviewed public result - // projection. Keep legacy bytes while shadow-validating until Safety, - // Schema identity, and sensitive-path redaction are declared together. - frameworkDualValidate(GetCredentials), + frameworkUnified(CreateApp), + frameworkUnified(UpdateApp), + frameworkUnified(DeleteApp), + frameworkUnified(EnableApp), + frameworkUnified(DisableApp), + frameworkUnified(GetCredentials), frameworkUnified(WebappGet), - frameworkDualValidate(WebappConfig), + frameworkUnified(WebappConfig), frameworkUnified(PermissionList), frameworkDualValidate(PermissionAdd), frameworkDualValidate(PermissionRemove), - frameworkDualValidate(MemberList), - frameworkDualValidate(MemberAdd), - frameworkDualValidate(MemberRemove), + frameworkUnified(MemberList), + frameworkUnified(MemberAdd), + frameworkUnified(MemberRemove), frameworkDualValidate(SecurityConfig), frameworkUnified(RobotGet), - frameworkDualValidate(RobotConfig), - frameworkDualValidate(RobotEnable), - frameworkDualValidate(RobotDisable), + frameworkUnified(RobotConfig), + frameworkUnified(RobotEnable), + frameworkUnified(RobotDisable), frameworkUnified(EventList), - frameworkDualValidate(EventSubscribe), + frameworkUnified(EventSubscribe), frameworkDualValidate(EventUnsubscribe), - frameworkDualValidate(VersionCreate), + frameworkUnified(VersionCreate), frameworkUnified(VersionList), frameworkUnified(VersionGet), frameworkUnified(VersionCheckApproval), frameworkDualValidate(VersionPublish), frameworkUnified(VersionStatus), - ) + } + for index := range items { + if reason, unavailable := devAppUnavailableReasons[items[index].Command]; unavailable { + items[index] = unavailableDevAppShortcut(items[index], reason) + } + } + shortcut.Register(items...) } func frameworkUnified(item shortcut.Shortcut) shortcut.Shortcut { diff --git a/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go b/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..62de309e --- /dev/null +++ b/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,742 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devapp + +import ( + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/spf13/cobra" +) + +func devAppSuccessResult(values map[string]any) map[string]any { + return map[string]any{"success": true, "result": values} +} + +func devAppSuccessList(key string, items []any, more bool, cursor string) map[string]any { + result := map[string]any{"success": true, key: items, "hasMore": more} + if cursor != "" { + result["nextCursor"] = cursor + } + return result +} + +func TestCrossPlatformCoverageDevAppChangedStrictHelpers(t *testing.T) { + if devAppContainers(nil) != nil { + t.Fatal("nil response unexpectedly produced containers") + } + for _, tc := range []struct { + value any + want bool + }{ + {nil, false}, {false, false}, {true, true}, + {float64(0), false}, {float64(200), false}, {float64(500), true}, + {"", false}, {"ok", false}, {"SUCCESS", false}, {"denied", true}, + {map[string]any{}, false}, {map[string]any{"code": 1}, true}, + {[]any{}, false}, {[]any{"error"}, true}, {struct{}{}, true}, + } { + if got := devAppErrorValue(tc.value); got != tc.want { + t.Fatalf("devAppErrorValue(%#v)=%v, want %v", tc.value, got, tc.want) + } + } + + if _, err := requireDevAppSuccess(map[string]any{ + "success": true, "result": map[string]any{"versionStatus": "FAILED"}, + }, "devapp/test"); err != nil { + t.Fatalf("successful query with failed business state was rejected: %v", err) + } + if !devAppOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) { + t.Fatal("known envelope was not recognized") + } + if devAppOnlyEnvelopeFields(map[string]any{"success": true, "business": true}) { + t.Fatal("business field was treated as envelope-only") + } + if _, err := requireDevAppObject(map[string]any{"success": true, "result": map[string]any{}}, "devapp/test"); err == nil { + t.Fatal("empty business object was accepted") + } + + object := map[string]any{"id": "actual", "name": "actual"} + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"ignored": ""}); err != nil { + t.Fatal(err) + } + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"missing": "wanted"}); err == nil { + t.Fatal("missing identity was accepted") + } + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"id": "wanted"}); err == nil { + t.Fatal("mismatched identity was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"missing": "wanted"}); err == nil { + t.Fatal("missing readback field was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "wanted"}); err == nil { + t.Fatal("mismatched readback field was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "actual"}); err != nil { + t.Fatal(err) + } + if err := requireDevAppStringField(object, "devapp/test", "wanted", "missing"); err == nil { + t.Fatal("missing string field was accepted") + } + + deep := map[string]any{"success": true, "content": map[string]any{ + "result": map[string]any{"hasMore": true, "nextCursor": " next "}, + }} + projection, err := devAppListProjection(deep, "items", nil, "devapp/test") + if err != nil || projection["nextCursor"] != "next" { + t.Fatalf("deep pagination projection=%#v err=%v", projection, err) + } + if _, err := devAppListProjection(map[string]any{"success": false}, "items", nil, "devapp/test"); err == nil { + t.Fatal("failed list envelope was accepted") + } +} + +func TestCrossPlatformCoverageDevAppChangedReadAndListExecutors(t *testing.T) { + t.Run("list all filters and projection", func(t *testing.T) { + item := map[string]any{ + "unified_app_id": "app", "appName": "name", "clientId": "client", + "agent_id": "agent", "app_status": "normal", "modified_time": "now", + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app": {devAppSuccessList("apps", []any{item}, true, "next")}, + }} + err := runDevAppCoverage(t, ListApp, caller, + "--name", "name", "--app-key", "client", "--app-group-id", "7", + "--creator", "owner", "--robot-name", "robot", "--develop-type", "2", + "--filter-cool-app", "1", "--sort-type", "gmt_modified", "--sort-order", "desc", + "--cursor", "cursor", "--page-size", "9") + if err != nil { + t.Fatal(err) + } + want := map[string]any{ + "name": "name", "appKey": "client", "appGroupId": 7, "creator": "owner", + "robotName": "robot", "developType": 2, "filterCoolApp": 1, + "sortType": "gmt_modified", "sortOrder": "desc", "cursor": "cursor", "pageSize": 9, + } + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, want) { + t.Fatalf("list params=%#v, want %#v", caller.calls, want) + } + badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "app"}}}}, + }} + if err := runDevAppCoverage(t, ListApp, badPage); err == nil { + t.Fatal("list without terminal pagination evidence was accepted") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + tool string + args []string + result map[string]any + }{ + {"get", GetApp, "get_dev_app", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "name": "app"}}, + {"webapp", WebappGet, "get_extension_webapp_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "homepageUrl": "https://example.invalid"}}, + {"robot", RobotGet, "get_extension_robot_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}, + {"version", VersionGet, "get_dev_app_version_detail", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version"}}, + {"precheck", VersionCheckApproval, "publish_dev_app_version", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "approvalRequired": false}}, + {"status", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "status": "PUBLISHED"}}, + {"status failed", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "FAILED"}}, + {"status expired", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "EXPIRED"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{tc.tool: {devAppSuccessResult(tc.result)}}} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + }) + } + + t.Run("read transport and identity failure", func(t *testing.T) { + if err := runDevAppCoverage(t, GetApp, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil { + t.Fatal("transport failure was accepted") + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "other"})}, + }} + if err := runDevAppCoverage(t, GetApp, caller, "--unified-app-id", "app"); err == nil { + t.Fatal("wrong object identity was accepted") + } + }) + + t.Run("permission list", func(t *testing.T) { + item := map[string]any{ + "permissionCode": "scope", "permissionName": "name", "interfaceName": "api", + "auth_status": "AUTHED", "scope_type": "APP", + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_permissions": {devAppSuccessList("permissions", []any{item}, false, "")}, + }} + err := runDevAppCoverage(t, PermissionList, caller, + "--unified-app-id", "app", "--keyword", "word", "--scope-value", "scope", + "--auth-status", "authed", "--scope-type", "app", "--api-status", "OPEN") + if err != nil { + t.Fatal(err) + } + if _, ok := permissionListFirst(item, "missing"); ok { + t.Fatal("missing permission field unexpectedly resolved") + } + }) + + t.Run("member list success and failures", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberList, caller, "--unified-app-id", "app", "--user-id", " user "); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, MemberList, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil { + t.Fatal("member transport failure was accepted") + } + bad := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_members": {{"success": true, "members": "bad"}}, + }} + if err := runDevAppCoverage(t, MemberList, bad, "--unified-app-id", "app"); err == nil { + t.Fatal("malformed member list was accepted") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + tool string + args []string + key string + item map[string]any + }{ + {"events", EventList, "list_dev_app_events", []string{"--unified-app-id", "app", "--keyword", "event"}, "events", map[string]any{"event_code": "event", "event_name": "name", "subscribe_status": "ON", "modified_time": "now"}}, + {"versions", VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", map[string]any{"version_id": "version", "version_name": "1", "versionStatus": "READY", "remark": "desc", "create_time": "now"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.tool: {devAppSuccessList(tc.key, []any{tc.item}, false, "")}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + }) + } + if _, ok := eventListFirst(map[string]any{}, "missing"); ok { + t.Fatal("missing event field unexpectedly resolved") + } + if _, ok := versionListFirst(map[string]any{}, "missing"); ok { + t.Fatal("missing version field unexpectedly resolved") + } +} + +func TestCrossPlatformCoverageDevAppChangedWriteExecutors(t *testing.T) { + t.Run("create all fields and failure branches", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{ + "unifiedAppId": "app", "name": "created", "desc": "desc", "iconMediaId": "icon", + })}, + }} + if err := runDevAppCoverage(t, CreateApp, caller, + "--name", "created", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--yes"); err == nil { + t.Fatal("create transport failure was accepted") + } + for name, responses := range map[string]map[string][]map[string]any{ + "receipt object missing": {"create_dev_app": {{"success": true, "result": map[string]any{}}}}, + "resource id missing": {"create_dev_app": {devAppSuccessResult(map[string]any{"name": "created"})}}, + "readback failure": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + }, + "name mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})}, + }, + "description mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "desc": "other"})}, + }, + "icon mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "iconMediaId": "other"})}, + }, + } { + t.Run(name, func(t *testing.T) { + args := []string{"--name", "created", "--yes"} + if strings.Contains(name, "description") { + args = append(args, "--desc", "desc") + } + if strings.Contains(name, "icon") { + args = append(args, "--icon-media-id", "icon") + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{responses: responses}, args...); err == nil { + t.Fatal("invalid create sequence was accepted") + } + }) + } + }) + + t.Run("update all fields and dry run", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "update_dev_app": {map[string]any{"success": true}}, + "get_dev_app": {devAppSuccessResult(map[string]any{ + "unifiedAppId": "app", "name": "new", "desc": "desc", "iconMediaId": "icon", + })}, + }} + if err := runDevAppCoverage(t, UpdateApp, caller, + "--unified-app-id", "app", "--name", "new", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, UpdateApp, &devAppCoverageCaller{}, + "--unified-app-id", "app", "--name", "new", "--dry-run"); err != nil { + t.Fatal(err) + } + bad := &devAppCoverageCaller{responses: map[string][]map[string]any{"update_dev_app": {map[string]any{"success": true}}}} + if err := runDevAppCoverage(t, UpdateApp, bad, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil { + t.Fatal("update readback failure was accepted") + } + }) + + t.Run("delete guards and bounded readback", func(t *testing.T) { + if err := runDevAppCoverage(t, DeleteApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", ""); err == nil { + t.Fatal("delete verification without selector was accepted") + } + missingSelector := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + }} + if err := runDevAppCoverage(t, DeleteApp, missingSelector, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete without appKey was accepted") + } + writeFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + }} + if err := runDevAppCoverage(t, DeleteApp, writeFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete transport failure was accepted") + } + stillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "app"}}, false, "")}, + }} + if err := runDevAppCoverage(t, DeleteApp, stillPresent, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete with present readback was accepted") + } + stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": { + devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"), + devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"), + }, + }} + if err := runDevAppCoverage(t, DeleteApp, stalled, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete stalled pagination was accepted") + } + malformed := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {{"success": true, "list": "bad", "hasMore": false}}, + }} + if err := runDevAppCoverage(t, DeleteApp, malformed, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete malformed readback was accepted") + } + badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "other"}}}}, + }} + if err := runDevAppCoverage(t, DeleteApp, badPage, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete pagination without termination was accepted") + } + pages := make([]map[string]any, 0, devAppMaxReadbackPages) + for page := 0; page < devAppMaxReadbackPages; page++ { + pages = append(pages, devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "cursor-"+string(rune('a'+page)))) + } + pageLimitCaller := &devAppCoverageCaller{responses: map[string][]map[string]any{"list_dev_app": pages}} + helpers.InitDeps(pageLimitCaller) + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil { + t.Fatal("delete verification page limit was accepted") + } + helpers.InitDeps(&devAppCoverageCaller{}) + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil { + t.Fatal("delete verification transport failure was accepted") + } + }) + + t.Run("credentials require client id", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appSecret": "secret"})}, + }} + if err := runDevAppCoverage(t, GetCredentials, caller, "--unified-app-id", "app"); err == nil { + t.Fatal("credential without client id was accepted") + } + }) + + t.Run("webapp all fields and failures", func(t *testing.T) { + args := []string{"--unified-app-id", "app", "--h5-page-type", "H5", "--homepage-url", "https://example.invalid/h5", "--pc-homepage-url", "https://example.invalid/pc", "--omp-url", "https://example.invalid/omp", "--yes"} + resource := map[string]any{"unifiedAppId": "app", "h5PageType": "H5", "homepageUrl": "https://example.invalid/h5", "pcHomepageUrl": "https://example.invalid/pc", "ompUrl": "https://example.invalid/omp"} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + "get_extension_webapp_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, WebappConfig, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("webapp write failure was accepted") + } + badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, WebappConfig, badRead, args...); err == nil { + t.Fatal("webapp readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + "get_extension_webapp_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "h5PageType": "other"})}, + }} + if err := runDevAppCoverage(t, WebappConfig, mismatch, "--unified-app-id", "app", "--h5-page-type", "H5", "--yes"); err == nil { + t.Fatal("webapp mismatch was accepted") + } + }) +} + +func TestCrossPlatformCoverageDevAppChangedRobotEventAndVersionBranches(t *testing.T) { + t.Run("robot validate and all fields", func(t *testing.T) { + for _, args := range [][]string{ + {"--unified-app-id", "app", "--skills", "skill", "--yes"}, + {"--unified-app-id", "app", "--add-scope=false", "--yes"}, + } { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []any{"skill"}, "addScope": false})}, + }} + if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil { + t.Fatal(err) + } + } + args := []string{"--unified-app-id", "app", "--name", "bot", "--brief", "brief", "--desc", "desc", "--icon-media-id", "icon", "--outgoing-url", "https://example.invalid/out", "--event-callback-url", "https://example.invalid/event", "--mode", "STREAM", "--skills", "skill", "--add-scope=false", "--disable-ssl-verify=false", "--yes"} + resource := map[string]any{"unifiedAppId": "app", "name": "bot", "brief": "brief", "desc": "desc", "iconMediaId": "icon", "outgoingUrl": "https://example.invalid/out", "eventCallbackUrl": "https://example.invalid/event", "mode": "STREAM", "skills": []string{"skill"}, "addScope": false, "disableSSLVerify": false} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--name", "bot", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("robot write failure was accepted") + } + badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{"set_extension_robot_config": {map[string]any{"success": true}}}} + if err := runDevAppCoverage(t, RobotConfig, badRead, args...); err == nil { + t.Fatal("robot readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})}, + }} + if err := runDevAppCoverage(t, RobotConfig, mismatch, "--unified-app-id", "app", "--name", "bot", "--yes"); err == nil { + t.Fatal("robot mismatch was accepted") + } + boolMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "addScope": true})}, + }} + if err := runDevAppCoverage(t, RobotConfig, boolMismatch, "--unified-app-id", "app", "--add-scope=false", "--yes"); err == nil { + t.Fatal("robot boolean mismatch was accepted") + } + skillsMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []string{"other"}})}, + }} + if err := runDevAppCoverage(t, RobotConfig, skillsMismatch, "--unified-app-id", "app", "--skills", "skill", "--yes"); err == nil { + t.Fatal("robot skills mismatch was accepted") + } + }) + + t.Run("status previews and readback failures", func(t *testing.T) { + if err := runDevAppCoverage(t, EnableApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + readFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, EnableApp, readFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("app status readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app": {map[string]any{"success": true}}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appStatus": "disabled"})}, + }} + if err := runDevAppCoverage(t, EnableApp, mismatch, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("app status mismatch was accepted") + } + if err := runDevAppCoverage(t, RobotEnable, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + robotReadFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app_robot": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, RobotEnable, robotReadFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("robot status readback failure was accepted") + } + }) + + t.Run("member mutations validate receipts and exact readback", func(t *testing.T) { + member := map[string]any{"userId": "user", "memberType": "DEVELOPER"} + add := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "add_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{member}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberAdd, add, + "--unified-app-id", "app", "--user-ids", " user ", "--member-type", " DEVELOPER ", "--yes"); err != nil { + t.Fatal(err) + } + if len(add.calls) != 2 || add.calls[0].tool != "add_dev_app_members" || + !reflect.DeepEqual(add.calls[0].params["userIds"], []string{"user"}) || + add.calls[0].params["memberType"] != "DEVELOPER" || add.calls[1].tool != "list_dev_app_members" { + t.Fatalf("member add calls=%#v", add.calls) + } + + remove := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "remove_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "owner", "memberType": "OWNER"}}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberRemove, remove, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"); err != nil { + t.Fatal(err) + } + if len(remove.calls) != 2 || remove.calls[0].tool != "remove_dev_app_members" || remove.calls[1].tool != "list_dev_app_members" { + t.Fatalf("member remove calls=%#v", remove.calls) + } + + for _, declaration := range []shortcut.Shortcut{MemberAdd, MemberRemove} { + unconfirmed := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, declaration, unconfirmed, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER"); err == nil { + t.Fatalf("%s succeeded without confirmation", declaration.Command) + } + if len(unconfirmed.calls) != 0 { + t.Fatalf("%s made remote calls before confirmation: %#v", declaration.Command, unconfirmed.calls) + } + dryRun := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, declaration, dryRun, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--dry-run"); err != nil { + t.Fatal(err) + } + if len(dryRun.calls) != 0 { + t.Fatalf("%s dry-run made remote calls: %#v", declaration.Command, dryRun.calls) + } + } + }) + + t.Run("member mutations reject invalid input and false success", func(t *testing.T) { + for _, values := range [][]string{nil, {""}, {"user", "user"}} { + if _, err := validatedDevAppValues(values, "--user-ids"); err == nil { + t.Fatalf("invalid member IDs accepted: %#v", values) + } + } + if _, err := validatedDevAppMemberType(" "); err == nil { + t.Fatal("blank member type was accepted") + } + if got, err := validatedDevAppMemberType(" DEVELOPER "); err != nil || got != "DEVELOPER" { + t.Fatalf("normalized member type=%q err=%v", got, err) + } + for _, args := range [][]string{ + {"--unified-app-id", "app", "--user-ids", "user,user", "--member-type", "DEVELOPER", "--yes"}, + {"--unified-app-id", "app", "--user-ids", "user", "--member-type", " ", "--yes"}, + } { + caller := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, MemberAdd, caller, args...); err == nil { + t.Fatalf("invalid member arguments accepted: %#v", args) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid member arguments made calls: %#v", caller.calls) + } + } + + baseArgs := []string{"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"} + badAddResponses := []map[string][]map[string]any{ + {}, + {"add_dev_app_members": {{"success": false}}}, + {"add_dev_app_members": {{"success": true}}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {{"success": true, "members": "bad"}}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "other", "memberType": "DEVELOPER"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user", "memberType": "OWNER"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{ + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + }, false, "")}}, + } + for index, responses := range badAddResponses { + if err := runDevAppCoverage(t, MemberAdd, &devAppCoverageCaller{responses: responses}, baseArgs...); err == nil { + t.Fatalf("member add false success case %d was accepted", index) + } + } + + removeStillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "remove_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{ + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + }, false, "")}, + }} + if err := runDevAppCoverage(t, MemberRemove, removeStillPresent, baseArgs...); err == nil { + t.Fatal("member remove accepted target still present") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + writeTool string + status any + }{ + {"enable missing status", RobotEnable, "enable_dev_app_robot", nil}, + {"disable wrong status", RobotDisable, "disable_dev_app_robot", "ONLINE"}, + } { + t.Run(tc.name, func(t *testing.T) { + resource := map[string]any{"unifiedAppId": "app"} + if tc.status != nil { + resource["robotStatus"] = tc.status + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.writeTool: {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("invalid robot status was accepted") + } + }) + } + + t.Run("event validation and readback failures", func(t *testing.T) { + for _, values := range [][]string{nil, {""}, {"event", "event"}} { + if _, err := validatedDevAppValues(values, "--event-codes"); err == nil { + t.Fatalf("invalid event values accepted: %#v", values) + } + } + if got, err := validatedDevAppValues([]string{" event "}, "--event-codes"); err != nil || !reflect.DeepEqual(got, []string{"event"}) { + t.Fatalf("normalized event values=%#v err=%v", got, err) + } + if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("event write failure was accepted") + } + if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--dry-run"); err != nil { + t.Fatal(err) + } + for name, listResponses := range map[string][]map[string]any{ + "transport": nil, + "malformed": {{"success": true, "list": "bad", "hasMore": false}}, + "projection": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "")}, + "stalled": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same")}, + } { + t.Run(name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": listResponses, + }} + if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("invalid event readback was accepted") + } + }) + } + stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": { + devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"), + devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"), + }, + }} + if err := runDevAppCoverage(t, EventSubscribe, stalled, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("stalled event cursor was accepted") + } + pages := make([]map[string]any, 0, devAppMaxReadbackPages) + for page := 0; page < devAppMaxReadbackPages; page++ { + pages = append(pages, devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "cursor-"+string(rune('a'+page)))) + } + pageLimit := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": pages, + }} + if err := runDevAppCoverage(t, EventSubscribe, pageLimit, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("event readback page limit was accepted") + } + }) + + t.Run("version create all fields and failures", func(t *testing.T) { + args := []string{"--unified-app-id", "app", "--version", "1.0", "--desc", "desc", "--yes"} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "desc"})}, + }} + if err := runDevAppCoverage(t, VersionCreate, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, "--unified-app-id", "app", "--desc", "desc", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("version write failure was accepted") + } + for name, responses := range map[string]map[string][]map[string]any{ + "receipt object": {"create_dev_app_version": {{"success": true, "result": map[string]any{}}}}, + "missing id": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"version": "1.0"})}}, + "readback": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}}, + "version mismatch": { + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "other"})}, + }, + "description mismatch": { + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "other"})}, + }, + } { + t.Run(name, func(t *testing.T) { + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{responses: responses}, args...); err == nil { + t.Fatal("invalid version create sequence was accepted") + } + }) + } + }) + + t.Run("list pagination projection failures", func(t *testing.T) { + for _, tc := range []struct { + declaration shortcut.Shortcut + tool string + args []string + key string + idKey string + }{ + {PermissionList, "list_dev_app_permissions", []string{"--unified-app-id", "app"}, "permissions", "scopeValue"}, + {EventList, "list_dev_app_events", []string{"--unified-app-id", "app"}, "events", "eventCode"}, + {VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", "versionId"}, + } { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.tool: {{"success": true, tc.key: []any{map[string]any{tc.idKey: "id"}}}}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatalf("%s accepted missing pagination", tc.declaration.Command) + } + } + malformedEvent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_events": {{"success": true, "events": "bad", "hasMore": false}}, + }} + if err := runDevAppCoverage(t, EventList, malformedEvent, "--unified-app-id", "app"); err == nil { + t.Fatal("malformed event collection was accepted") + } + }) +} diff --git a/internal/shortcut/devapp/devapp_hardening_test.go b/internal/shortcut/devapp/devapp_hardening_test.go new file mode 100644 index 00000000..3a151f0e --- /dev/null +++ b/internal/shortcut/devapp/devapp_hardening_test.go @@ -0,0 +1,527 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devapp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type devAppCoverageCall struct { + tool string + params map[string]any +} + +type devAppCoverageCaller struct { + responses map[string][]map[string]any + calls []devAppCoverageCall +} + +func (c *devAppCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, devAppCoverageCall{tool: tool, params: params}) + queue := c.responses[tool] + if len(queue) == 0 { + return nil, errors.New("unexpected devapp coverage call: " + tool) + } + payload := queue[0] + c.responses[tool] = queue[1:] + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (*devAppCoverageCaller) Format() string { return "json" } +func (*devAppCoverageCaller) DryRun() bool { return false } +func (*devAppCoverageCaller) Fields() string { return "" } +func (*devAppCoverageCaller) JQ() string { return "" } + +func runDevAppCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devapp"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"devapp", declaration.Command}, args...)) + return root.Execute() +} + +func runDevAppUnifiedCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDeps(caller) + ctx, _ := output.WithResultStore(context.Background()) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.SetContext(ctx) + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devapp"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.PersistentPostRunE = func(executed *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(executed) + return err + } + root.SetArgs(append([]string{"devapp", declaration.Command}, args...)) + if err := root.Execute(); err != nil { + return nil, err + } + return stdout.Bytes(), nil +} + +func devAppRegistered(command string) (shortcut.Shortcut, bool) { + for _, item := range shortcut.All() { + if item.Service == "devapp" && item.Command == command { + return item, true + } + } + return shortcut.Shortcut{}, false +} + +func TestCrossPlatformCoverageDevAppStrictResponseValidation(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "empty", data: nil}, + {name: "missing success", data: map[string]any{"list": []any{}}}, + {name: "wrong success type", data: map[string]any{"success": "true", "list": []any{}}}, + {name: "explicit failure", data: map[string]any{"success": false, "list": []any{}}}, + {name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "list": []any{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := listAppProject(tc.data); err == nil { + t.Fatal("malformed response was accepted") + } + }) + } + + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing collection", data: map[string]any{"success": true, "hasMore": false}}, + {name: "wrong collection type", data: map[string]any{"success": true, "list": map[string]any{}, "hasMore": false}}, + {name: "bad element", data: map[string]any{"success": true, "list": []any{"bad"}, "hasMore": false}}, + {name: "missing stable id", data: map[string]any{"success": true, "list": []any{map[string]any{"name": "app"}}, "hasMore": false}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := listAppProject(tc.data); err == nil { + t.Fatal("malformed collection was accepted") + } + }) + } + + valid := []map[string]any{{"unifiedAppId": "app"}} + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing pagination", data: map[string]any{"success": true}}, + {name: "wrong has more type", data: map[string]any{"success": true, "hasMore": "false"}}, + {name: "cursor without has more", data: map[string]any{"success": true, "nextCursor": "next"}}, + {name: "more without cursor", data: map[string]any{"success": true, "hasMore": true}}, + {name: "wrong cursor type", data: map[string]any{"success": true, "hasMore": true, "nextCursor": 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := devAppListProjection(tc.data, "apps", valid, "devapp/test"); err == nil { + t.Fatal("malformed pagination was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageDevAppConfirmationAndValidationPrecedeCalls(t *testing.T) { + writes := []struct { + declaration shortcut.Shortcut + args []string + }{ + {CreateApp, []string{"--name", "app"}}, + {UpdateApp, []string{"--unified-app-id", "app", "--name", "new"}}, + {DeleteApp, []string{"--unified-app-id", "app"}}, + {EnableApp, []string{"--unified-app-id", "app"}}, + {DisableApp, []string{"--unified-app-id", "app"}}, + {WebappConfig, []string{"--unified-app-id", "app", "--homepage-url", "https://example.invalid"}}, + {RobotConfig, []string{"--unified-app-id", "app", "--name", "bot"}}, + {RobotEnable, []string{"--unified-app-id", "app"}}, + {RobotDisable, []string{"--unified-app-id", "app"}}, + {EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one"}}, + {VersionCreate, []string{"--unified-app-id", "app", "--desc", "version"}}, + } + for _, tc := range writes { + t.Run(tc.declaration.Command, func(t *testing.T) { + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, tc.declaration, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "confirmation_required" { + t.Fatalf("unconfirmed error = %#v, want confirmation_required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed command called MCP: %#v", caller.calls) + } + }) + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + args []string + }{ + {UpdateApp, []string{"--unified-app-id", "app", "--yes"}}, + {WebappConfig, []string{"--unified-app-id", "app", "--yes"}}, + {RobotConfig, []string{"--unified-app-id", "app", "--yes"}}, + {EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one,event.one", "--yes"}}, + } { + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, tc.declaration, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason == "confirmation_required" { + t.Fatalf("invalid input error = %#v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input called MCP: %#v", caller.calls) + } + } +} + +func TestCrossPlatformCoverageDevAppResidualAdapters(t *testing.T) { + t.Run("credentials require stable identity client and secret", func(t *testing.T) { + valid := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", "appSecret": "secret", + }}}, + }} + if err := runDevAppCoverage(t, GetCredentials, valid, "--unified-app-id", "app"); err != nil { + t.Fatal(err) + } + missingSecret := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", + }}}, + }} + if err := runDevAppCoverage(t, GetCredentials, missingSecret, "--unified-app-id", "app"); err == nil { + t.Fatal("credential response without a secret was accepted") + } + }) + + t.Run("credentials unified data matches declared sensitive paths", func(t *testing.T) { + declaration, ok := devAppRegistered("+credentials-get") + if !ok || declaration.OutputRollout != output.RolloutUnifiedActive { + t.Fatalf("registered credentials shortcut is not unified: found=%t rollout=%q", ok, declaration.OutputRollout) + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", "appSecret": "test-app-secret", + "clientSecret": "test-client-secret", "secret": "test-secret", + }}}, + }} + encoded, err := runDevAppUnifiedCoverage(t, declaration, caller, "--unified-app-id", "app") + if err != nil { + t.Fatal(err) + } + var envelope map[string]any + if err := json.Unmarshal(encoded, &envelope); err != nil { + t.Fatalf("unified output is not JSON: %v", err) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified output data type = %T, want object", envelope["data"]) + } + if _, nested := data["result"]; nested { + t.Fatal("credential business object is nested under data.result; declared sensitive paths would miss it") + } + for _, path := range declaration.Contract.Result.SensitivePaths { + if value, present := data[path]; !present || strings.TrimSpace(fmt.Sprint(value)) == "" { + t.Fatalf("declared sensitive path %q does not resolve to a non-empty data field", path) + } + } + }) + + t.Run("member projector supports known and guaranteed zero", func(t *testing.T) { + members := []any{ + map[string]any{"userId": "user-1", "memberType": "OWNER"}, + map[string]any{"userId": "user-2", "memberType": "DEVELOPER"}, + } + if got := projectDevAppMembers(members, "user-1"); len(got) != 1 || got[0]["userId"] != "user-1" { + t.Fatalf("known member projection = %#v", got) + } + if got := projectDevAppMembers(members, "guaranteed-missing"); len(got) != 0 { + t.Fatalf("zero member projection = %#v", got) + } + for _, bad := range []map[string]any{ + {"success": true, "members": []any{"bad"}}, + {"success": true, "members": []any{map[string]any{"name": "missing-id"}}}, + } { + if _, _, err := requireDevAppCollection(bad, "devapp/list_dev_app_members", []string{"userId"}, "members"); err == nil { + t.Fatalf("malformed member response accepted: %#v", bad) + } + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string][]map[string]any + wantTools []string + }{ + { + name: "robot config", declaration: RobotConfig, + args: []string{"--unified-app-id", "app", "--name", "bot", "--mode", "STREAM", "--yes"}, + responses: map[string][]map[string]any{ + "set_extension_robot_config": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "bot", "mode": "STREAM"}}}, + }, + wantTools: []string{"set_extension_robot_config", "get_extension_robot_config"}, + }, + { + name: "robot enable", declaration: RobotEnable, + args: []string{"--unified-app-id", "app", "--yes"}, + responses: map[string][]map[string]any{ + "enable_dev_app_robot": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}}, + }, + wantTools: []string{"enable_dev_app_robot", "get_extension_robot_config"}, + }, + { + name: "robot disable", declaration: RobotDisable, + args: []string{"--unified-app-id", "app", "--yes"}, + responses: map[string][]map[string]any{ + "disable_dev_app_robot": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "UNCONFIGURED"}}}, + }, + wantTools: []string{"disable_dev_app_robot", "get_extension_robot_config"}, + }, + { + name: "event subscribe", declaration: EventSubscribe, + args: []string{"--unified-app-id", "app", "--event-codes", "event.one", "--yes"}, + responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {{"success": true}}, + "list_dev_app_events": {{"success": true, "list": []any{map[string]any{"eventCode": "event.one"}}, "hasMore": false}}, + }, + wantTools: []string{"subscribe_dev_app_events", "list_dev_app_events"}, + }, + { + name: "version create", declaration: VersionCreate, + args: []string{"--unified-app-id", "app", "--desc", "snapshot", "--yes"}, + responses: map[string][]map[string]any{ + "create_dev_app_version": {{"success": true, "result": map[string]any{"versionId": "version"}}}, + "get_dev_app_version_detail": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "versionId": "version", "desc": "snapshot"}}}, + }, + wantTools: []string{"create_dev_app_version", "get_dev_app_version_detail"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: tc.responses} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + gotTools := make([]string, 0, len(caller.calls)) + for _, call := range caller.calls { + gotTools = append(gotTools, call.tool) + } + if !reflect.DeepEqual(gotTools, tc.wantTools) { + t.Fatalf("tools = %#v, want %#v", gotTools, tc.wantTools) + } + }) + } + + t.Run("event false receipt is rejected by readback", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {{"success": true}}, + "list_dev_app_events": {{"success": true, "list": []any{}, "hasMore": false}}, + }} + if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event.one", "--yes"); err == nil { + t.Fatal("event subscription without exact readback was accepted") + } + }) +} + +func TestCrossPlatformCoverageDevAppWritesUseExactReadback(t *testing.T) { + t.Run("create", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app"}}}, + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "created"}}}, + }} + if err := runDevAppCoverage(t, CreateApp, caller, "--name", "created", "--yes"); err != nil { + t.Fatal(err) + } + want := []devAppCoverageCall{ + {tool: "create_dev_app", params: map[string]any{"name": "created"}}, + {tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + } + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("update mismatch fails", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "update_dev_app": {{"success": true}}, + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "old"}}}, + }} + if err := runDevAppCoverage(t, UpdateApp, caller, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil { + t.Fatal("write/readback mismatch was accepted") + } + if len(caller.calls) != 2 || caller.calls[0].tool != "update_dev_app" || caller.calls[1].tool != "get_dev_app" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + writeTool string + status string + }{ + {name: "enable", declaration: EnableApp, writeTool: "enable_dev_app", status: "normal"}, + {name: "disable", declaration: DisableApp, writeTool: "disable_dev_app", status: "disabled"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.writeTool: {{"success": true}}, + "get_dev_app": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appStatus": tc.status, + }}}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != tc.writeTool || caller.calls[1].tool != "get_dev_app" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + } + + t.Run("webapp config", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {{"success": true}}, + "get_extension_webapp_config": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "homepageUrl": "https://example.invalid", + }}}, + }} + if err := runDevAppCoverage(t, WebappConfig, caller, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != "set_extension_webapp_config" || caller.calls[1].tool != "get_extension_webapp_config" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + t.Run("delete pre-read and absence", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "appKey": "key"}}}, + "delete_dev_app": {{"success": true}}, + "list_dev_app": {{"success": true, "list": []any{}, "hasMore": false}}, + }} + if err := runDevAppCoverage(t, DeleteApp, caller, "--unified-app-id", "app", "--yes"); err != nil { + t.Fatal(err) + } + want := []devAppCoverageCall{ + {tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + {tool: "delete_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + {tool: "list_dev_app", params: map[string]any{"appKey": "key", "pageSize": 20}}, + } + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) +} + +func TestCrossPlatformCoverageDevAppReviewedInventory(t *testing.T) { + public := 0 + unavailable := 0 + total := 0 + for _, item := range shortcut.All() { + if item.Service != "devapp" { + continue + } + total++ + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog(item.Service, item.Command) { + t.Errorf("unavailable %s visibility/public = hidden=%v compatibility=%v public=%v", + item.Command, item.Hidden, item.CompatibilityVisible, shortcut.InPublicCatalog(item.Service, item.Command)) + } + if strings.TrimSpace(devAppUnavailableReasons[item.Command]) == "" { + t.Errorf("unavailable %s lacks concrete reason", item.Command) + } + if !item.Contract.Empty() { + t.Errorf("hidden unavailable %s must not publish an incomplete Contract", item.Command) + } + continue + } + if shortcut.InPublicCatalog(item.Service, item.Command) { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability) + } + if item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout) + } + if item.Contract.Result == nil || item.Contract.Interface == nil || item.Contract.Description == "" { + t.Errorf("public %s has incomplete Contract/Result", item.Command) + } + if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" { + t.Errorf("public %s has incomplete Safety", item.Command) + } + } + } + if total != 30 || public != 25 || unavailable != 5 { + t.Fatalf("devapp inventory total/public/unavailable = %d/%d/%d, want 30/25/5", total, public, unavailable) + } +} + +func TestCrossPlatformCoverageDevAppUnavailableCommandsMakeZeroCalls(t *testing.T) { + for command, reason := range devAppUnavailableReasons { + declaration, found := devAppRegistered(command) + if !found { + t.Fatalf("registered unavailable command missing: %s", command) + } + args := []string{} + for _, flag := range declaration.Flags { + if !flag.Required { + continue + } + args = append(args, "--"+flag.Name) + if flag.Type != shortcut.FlagBool { + args = append(args, "fixture") + } + } + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, declaration, caller, args...) + if err == nil || !strings.Contains(err.Error(), reason) { + t.Errorf("%s unavailable error = %v", command, err) + } + if len(caller.calls) != 0 { + t.Errorf("%s unavailable command called MCP: %#v", command, caller.calls) + } + } +} diff --git a/internal/shortcut/devapp/devapp_pagination_projection_test.go b/internal/shortcut/devapp/devapp_pagination_projection_test.go index eeb6048b..3404e162 100644 --- a/internal/shortcut/devapp/devapp_pagination_projection_test.go +++ b/internal/shortcut/devapp/devapp_pagination_projection_test.go @@ -32,12 +32,15 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) { name string data map[string]any }{ - {name: "top level", data: map[string]any{"hasMore": true, "nextCursor": "next"}}, - {name: "nested result", data: map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "next"}}}, - {name: "exhausted", data: map[string]any{"hasMore": false}}, + {name: "top level", data: map[string]any{"success": true, "hasMore": true, "nextCursor": "next"}}, + {name: "nested result", data: map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "next"}}}, + {name: "exhausted", data: map[string]any{"success": true, "hasMore": false}}, } { t.Run(tc.name, func(t *testing.T) { - got := devAppListProjection(tc.data, "items", items) + got, err := devAppListProjection(tc.data, "items", items, "devapp/test") + if err != nil { + t.Fatal(err) + } if got["hasMore"] != devAppPaginationCandidates(tc.data)[len(devAppPaginationCandidates(tc.data))-1]["hasMore"] && tc.name == "nested result" { t.Fatalf("hasMore not preserved: %#v", got) } @@ -52,14 +55,23 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) { if got := devAppPaginationCandidates(nil); got != nil { t.Fatalf("nil candidates=%#v", got) } - deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}} - if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" { + deep := map[string]any{"content": map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "deep"}}} + if got, err := devAppListProjection(deep, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deep" { t.Fatalf("deep projection=%#v", got) } + deeper := map[string]any{"success": true, "content": map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true, "nextCursor": "deeper"}}}} + if got, err := devAppListProjection(deeper, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deeper" { + t.Fatalf("deeper projection=%#v", got) + } + cyclic := map[string]any{"success": true} + cyclic["content"] = cyclic + if got := devAppPaginationCandidates(cyclic); len(got) != 13 { + t.Fatalf("cyclic candidate walk length=%d, want bounded length 13", len(got)) + } } func TestFrameworkPaginatedShortcutExecutionMovesCursorToMeta(t *testing.T) { - caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`} + caller := &paginationCaller{text: `{"content":{"success":true,"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`} helpers.InitDepsForTest(t, caller) helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{}) for _, tc := range []struct { diff --git a/internal/shortcut/devapp/devapp_rollout_test.go b/internal/shortcut/devapp/devapp_rollout_test.go index d89ac792..dd46a96e 100644 --- a/internal/shortcut/devapp/devapp_rollout_test.go +++ b/internal/shortcut/devapp/devapp_rollout_test.go @@ -12,10 +12,16 @@ import ( func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) { active := map[string]bool{ - "+list": true, "+get": true, "+webapp-get": true, - "+permission-list": true, "+event-list": true, "+version-list": true, + "+list": true, "+get": true, "+create": true, "+update": true, + "+delete": true, "+enable": true, "+disable": true, "+webapp-get": true, + "+webapp-config": true, + "+permission-list": true, "+version-list": true, "+robot-get": true, "+version-get": true, "+version-check-approval": true, "+version-status": true, + "+credentials-get": true, "+member-list": true, + "+member-add": true, "+member-remove": true, + "+robot-config": true, "+robot-enable": true, "+robot-disable": true, + "+event-list": true, "+event-subscribe": true, "+version-create": true, } seen := map[string]bool{} for _, item := range shortcut.All() { @@ -42,7 +48,7 @@ func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) { t.Errorf("active pilot shortcut %s is not registered", name) } } - for _, paginated := range []string{"+list", "+permission-list", "+event-list", "+version-list"} { + for _, paginated := range []string{"+list", "+permission-list", "+version-list"} { if !seen[paginated] { t.Errorf("paginated shortcut %s is not registered", paginated) } diff --git a/internal/shortcut/devdoc/devdoc.go b/internal/shortcut/devdoc/devdoc.go new file mode 100644 index 00000000..d930926e --- /dev/null +++ b/internal/shortcut/devdoc/devdoc.go @@ -0,0 +1,240 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package devdoc registers strict Shortcut adapters for DingTalk Open Platform +// documentation discovery. +package devdoc + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const devdocCompositeReason = "Unavailable after exact Shortcut plus raw isolation: the downstream interface performs semantic recall and returned nonempty neighbors for runtime-random zero-match candidates, so no guaranteed zero-match query can satisfy the public list/search proof contract." + +type devdocPageEvidence struct { + CurrentPage int + PageSize int + TotalCount int + HasMore bool +} + +func devdocSearchResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验但未满足公开 zero-match 证明的语义文档搜索页","properties":{"count":{"type":"integer","description":"当前页文档数量"},"documents":{"type":"array","description":"当前页开放平台文档","items":{"type":"object","description":"带稳定 URL 的开放平台文档","properties":{"title":{"type":"string","description":"文档标题"},"url":{"type":"string","description":"文档稳定 URL"},"description":{"type":"string","description":"文档摘要"}},"required":["title","url"],"additionalProperties":false}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的语义召回总数"},"complete":{"type":"boolean","description":"是否到达语义召回末页;不表示 query zero-match 已证明"},"matchSemantics":{"type":"string","description":"下游搜索匹配语义","enum":["semantic"]},"zeroMatchProven":{"type":"boolean","description":"是否通过保证零匹配 query 证明公开投影;当前固定为 false"}},"required":["count","documents","currentPage","pageSize","totalCount","complete","matchSemantics","zeroMatchProven"],"additionalProperties":false}`), + } +} + +func devdocPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "page", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +var SearchDocs = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "devdoc", + Command: "+search-docs", + Product: "devdoc", + Description: "审计钉钉开放平台语义文档搜索结果", + Intent: "需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。", + Risk: shortcut.RiskRead, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devdoc", Name: "shortcut_search_docs", CanonicalPath: "devdoc.shortcut_search_docs", + CLIPath: "devdoc +search-docs", PrimaryCLIPath: "devdoc +search-docs", + }, + Description: "审计钉钉开放平台语义文档搜索结果", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: devdocCompositeReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "审计钉钉开放平台语义文档搜索结果", + UseWhen: []string{"需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。"}, + AvoidWhen: []string{"需要精确匹配或保证零命中时不要使用;搜索用户业务文档时使用 drive/wiki/doc"}, + Examples: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "query", Property: "keyword"}, + {Name: "page", Property: "page", InterfaceType: "number"}, + {Name: "size", Property: "size", InterfaceType: "number"}, + }, + Result: devdocSearchResult(), + Pagination: devdocPagination(), + }, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词去除空白后必须非空"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "size", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量必须在 1 到 50 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "搜索关键词去除空白后必须非空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "每页数量必须在 1 到 50 之间"}, + }, + Tips: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空") + } + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于 0") + } + if size := rt.Int("size"); size < 1 || size > 50 { + return apperrors.NewValidation("--size 必须在 1 到 50 之间") + } + return nil + }, + Execute: executeSearchDocs, +} + +func executeSearchDocs(rt *shortcut.RuntimeContext) error { + page, size := rt.Int("page"), rt.Int("size") + data, err := rt.CallMCPData("devdoc", "search_open_platform_docs", map[string]any{ + "keyword": strings.TrimSpace(rt.Str("query")), + "page": page, + "size": size, + }) + if err != nil { + return err + } + documents, evidence, err := devdocProjectSearch(data, page, size) + if err != nil { + return err + } + payload := map[string]any{ + "count": len(documents), + "documents": documents, + "currentPage": evidence.CurrentPage, + "pageSize": evidence.PageSize, + "totalCount": evidence.TotalCount, + "complete": !evidence.HasMore, + "matchSemantics": "semantic", + "zeroMatchProven": false, + } + next := "" + if evidence.HasMore { + next = strconv.Itoa(evidence.CurrentPage + 1) + } + // Strict page evidence guarantees the only two valid framework states: + // terminal+empty-next or continuing+positive next page. + pagination, _ := output.NewPagination(!evidence.HasMore, next) + meta := &output.Meta{Count: output.NewCount(len(documents)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func devdocProjectSearch(data map[string]any, requestedPage, requestedSize int) ([]map[string]any, devdocPageEvidence, error) { + const operation = "devdoc/search_open_platform_docs" + items, err := responsecheck.RequireObjectCollection(data, operation, "result.items") + if err != nil { + return nil, devdocPageEvidence{}, err + } + // RequireObjectCollection already proved that result is an object while + // resolving result.items, so a second fallible shape check is unreachable. + result, _ := data["result"].(map[string]any) + evidence, err := devdocParsePage(result, requestedPage, requestedSize, len(items)) + if err != nil { + return nil, devdocPageEvidence{}, err + } + documents := make([]map[string]any, 0, len(items)) + seenURLs := make(map[string]struct{}, len(items)) + for index, item := range items { + title, titleOK := devdocNonEmptyString(item["title"]) + url, urlOK := devdocNonEmptyString(item["url"]) + if !titleOK || !urlOK { + return nil, devdocPageEvidence{}, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("文档结果第 %d 项缺少非空 title 或 url", index)) + } + if _, duplicate := seenURLs[url]; duplicate { + return nil, devdocPageEvidence{}, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("文档结果第 %d 项 URL 重复", index)) + } + seenURLs[url] = struct{}{} + document := map[string]any{"title": title, "url": url} + if description, ok := devdocOptionalString(item["desc"]); ok { + document["description"] = description + } + documents = append(documents, document) + } + return documents, evidence, nil +} + +func devdocParsePage(result map[string]any, requestedPage, requestedSize, itemCount int) (devdocPageEvidence, error) { + const operation = "devdoc/search_open_platform_docs" + currentPage, ok := devdocInteger(result["currentPage"]) + if !ok || currentPage < 1 || currentPage != requestedPage { + return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_page_mismatch", "响应 currentPage 缺失、错型或与请求页码不一致") + } + pageSize, ok := devdocInteger(result["pageSize"]) + if !ok || pageSize < 1 || pageSize != requestedSize { + return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_size_mismatch", "响应 pageSize 缺失、错型或与请求分页大小不一致") + } + totalCount, ok := devdocInteger(result["totalCount"]) + if !ok || totalCount < 0 || totalCount < itemCount { + return devdocPageEvidence{}, responsecheck.Error(operation, "invalid_total_count", "响应 totalCount 缺失、错型、为负数或小于当前页条数") + } + if itemCount > pageSize { + return devdocPageEvidence{}, responsecheck.Error(operation, "page_size_exceeded", "当前页结果数量超过服务端 pageSize") + } + hasMore, ok := result["hasMore"].(bool) + if !ok { + return devdocPageEvidence{}, responsecheck.Error(operation, "malformed_pagination", "响应 hasMore 缺失或不是布尔值") + } + pageEnd := currentPage * pageSize + if hasMore && pageEnd >= totalCount { + return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=true 但 currentPage/pageSize/totalCount 已到达末页") + } + if !hasMore && pageEnd < totalCount { + return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=false 但 totalCount 表明仍有下一页") + } + return devdocPageEvidence{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil +} + +func devdocInteger(value any) (int, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || typed != math.Trunc(typed) || typed > float64(math.MaxInt) { + return 0, false + } + return int(typed), true + case int: + return typed, true + default: + return 0, false + } +} + +func devdocNonEmptyString(value any) (string, bool) { + text, ok := value.(string) + text = strings.TrimSpace(text) + return text, ok && text != "" +} + +func devdocOptionalString(value any) (string, bool) { + if value == nil { + return "", false + } + return devdocNonEmptyString(value) +} + +func init() { + shortcut.Register(SearchDocs) +} diff --git a/internal/shortcut/devdoc/devdoc_cross_platform_coverage_test.go b/internal/shortcut/devdoc/devdoc_cross_platform_coverage_test.go new file mode 100644 index 00000000..6baaf0e3 --- /dev/null +++ b/internal/shortcut/devdoc/devdoc_cross_platform_coverage_test.go @@ -0,0 +1,243 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devdoc + +import ( + "bytes" + "context" + "errors" + "io" + "math" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type devdocCoverageCaller struct { + response string + err error + calls int +} + +func (caller *devdocCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil +} +func (*devdocCoverageCaller) Format() string { return "json" } +func (*devdocCoverageCaller) DryRun() bool { return false } +func (*devdocCoverageCaller) Fields() string { return "" } +func (*devdocCoverageCaller) JQ() string { return "" } + +func runDevdocCoverage(t *testing.T, caller *devdocCoverageCaller, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devdoc"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(SearchDocs))) + root.AddCommand(service) + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"devdoc", SearchDocs.Command}, args...)) + executed, err := root.ExecuteC() + if err == nil { + _, _, err = output.EmitStoredResult(executed) + } + return stdout.String(), err +} + +func TestCrossPlatformCoverageDevdocUnavailableContractIsUnifiedTypedAndTruthful(t *testing.T) { + if SearchDocs.OutputRollout != output.RolloutUnifiedActive { + t.Fatalf("rollout = %q", SearchDocs.OutputRollout) + } + if !SearchDocs.Hidden || SearchDocs.Availability != "unavailable" || SearchDocs.Contract.Interface == nil || SearchDocs.Contract.Interface.Availability != "unavailable" { + t.Fatal("Devdoc semantic search must remain hidden/unavailable until a guaranteed zero-match query is provable") + } + if SearchDocs.Contract.Empty() || SearchDocs.Contract.Result == nil || SearchDocs.Contract.Pagination == nil { + t.Fatal("Devdoc Shortcut must retain Contract, Result, and Pagination for audit") + } + resultSchema := string(SearchDocs.Contract.Result.DataSchema) + if !strings.Contains(resultSchema, "matchSemantics") || !strings.Contains(resultSchema, "zeroMatchProven") || strings.TrimSpace(SearchDocs.Safety.Effect) == "" { + t.Fatal("Devdoc Result must declare semantic matching and the unproven zero-match boundary") + } +} + +func TestCrossPlatformCoverageDevdocSearchRejectsBrokenResponses(t *testing.T) { + explicitEmpty := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, "currentPage": float64(3), "pageSize": float64(10), + "totalCount": float64(2), "hasMore": false, + }, + } + documents, page, err := devdocProjectSearch(explicitEmpty, 3, 10) + if err != nil || len(documents) != 0 || page.HasMore { + t.Fatalf("explicit empty page: documents=%v page=%+v err=%v", documents, page, err) + } + // This proves only that an explicitly empty terminal page is parsed + // truthfully. It is deliberately not evidence for a zero-match query. + + broken := map[string]map[string]any{ + "empty response": {}, + "missing success": {"result": map[string]any{"items": []any{}}}, + "success false": {"success": false, "result": map[string]any{"items": []any{}}}, + "wrong success type": {"success": "true", "result": map[string]any{"items": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, + "wrong collection type": {"success": true, "result": map[string]any{"items": map[string]any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}}, + "bad item": {"success": true, "result": map[string]any{"items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}}, + "missing item identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}}, + "duplicate identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "one", "url": "https://example.invalid/doc"}, map[string]any{"title": "two", "url": "https://example.invalid/doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(2), "hasMore": false}}, + "missing pagination": {"success": true, "result": map[string]any{"items": []any{}}}, + "mismatched page": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}}, + "wrong hasMore type": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": "false"}}, + "false completion": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(11), "hasMore": false}}, + "false continuation": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(10), "hasMore": true}}, + } + for name, payload := range broken { + t.Run(name, func(t *testing.T) { + if got, _, err := devdocProjectSearch(payload, 1, 10); err == nil { + t.Fatalf("broken response accepted: %#v", got) + } + }) + } +} + +func TestCrossPlatformCoverageDevdocSearchProjectsKnownNonemptyPage(t *testing.T) { + payload := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{ + map[string]any{"title": "Authentication", "url": "https://example.invalid/auth", "desc": "Guide"}, + map[string]any{"title": "Errors", "url": "https://example.invalid/errors", "desc": nil}, + }, + "currentPage": float64(1), "pageSize": float64(2), "totalCount": float64(3), "hasMore": true, + }, + } + documents, page, err := devdocProjectSearch(payload, 1, 2) + if err != nil || len(documents) != 2 || !page.HasMore { + t.Fatalf("documents=%#v page=%+v err=%v", documents, page, err) + } + if documents[0]["description"] != "Guide" { + t.Fatalf("first document=%#v", documents[0]) + } + if _, exists := documents[1]["description"]; exists { + t.Fatalf("nil description must be omitted: %#v", documents[1]) + } +} + +func TestCrossPlatformCoverageDevdocExecuteValidationAndPagination(t *testing.T) { + blankCommand := corecmd.New(shortcut.FromShortcut(SearchDocs)) + if err := blankCommand.Flags().Set("query", " "); err != nil { + t.Fatal(err) + } + if err := SearchDocs.Validate(shortcut.RuntimeContextForTest(blankCommand, SearchDocs)); err == nil { + t.Fatal("blank query validation succeeded") + } + for name, args := range map[string][]string{ + "blank query": {"--query", " "}, + "zero page": {"--query", "query", "--page", "0"}, + "zero size": {"--query", "query", "--size", "0"}, + "large size": {"--query", "query", "--size", "51"}, + } { + t.Run(name, func(t *testing.T) { + caller := &devdocCoverageCaller{} + if _, err := runDevdocCoverage(t, caller, args...); err == nil { + t.Fatal("invalid input succeeded") + } + if caller.calls != 0 { + t.Fatalf("remote calls before validation = %d", caller.calls) + } + }) + } + + for name, response := range map[string]string{ + "terminal": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":10,"totalCount":1,"hasMore":false}}`, + "continuing": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`, + } { + t.Run(name, func(t *testing.T) { + caller := &devdocCoverageCaller{response: response} + args := []string{"--query", " query "} + if name == "continuing" { + args = append(args, "--size", "1") + } + if _, err := runDevdocCoverage(t, caller, args...); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d", caller.calls) + } + }) + } + + caller := &devdocCoverageCaller{err: errors.New("transport")} + if _, err := runDevdocCoverage(t, caller, "--query", "query"); err == nil { + t.Fatal("transport failure succeeded") + } + malformed := &devdocCoverageCaller{response: `{"success":true,"result":{"items":"bad"}}`} + if _, err := runDevdocCoverage(t, malformed, "--query", "query"); err == nil { + t.Fatal("malformed projection succeeded") + } +} + +func TestCrossPlatformCoverageDevdocPageAndScalarEdges(t *testing.T) { + base := func() map[string]any { + return map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, "currentPage": float64(1), "pageSize": float64(2), + "totalCount": float64(0), "hasMore": false, + }, + } + } + for name, mutate := range map[string]func(map[string]any){ + "page size mismatch": func(result map[string]any) { result["pageSize"] = float64(3) }, + "negative total": func(result map[string]any) { result["totalCount"] = float64(-1) }, + "total below items": func(result map[string]any) { + result["items"] = []any{map[string]any{"title": "one", "url": "https://example.invalid/one"}} + result["totalCount"] = float64(0) + }, + "items exceed size": func(result map[string]any) { + result["items"] = []any{ + map[string]any{"title": "one", "url": "https://example.invalid/one"}, + map[string]any{"title": "two", "url": "https://example.invalid/two"}, + map[string]any{"title": "three", "url": "https://example.invalid/three"}, + } + result["totalCount"] = float64(3) + }, + } { + t.Run(name, func(t *testing.T) { + payload := base() + mutate(payload["result"].(map[string]any)) + if _, _, err := devdocProjectSearch(payload, 1, 2); err == nil { + t.Fatal("invalid page succeeded") + } + }) + } + + for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "1"} { + if _, ok := devdocInteger(value); ok { + t.Fatalf("invalid integer accepted: %#v", value) + } + } + if got, ok := devdocInteger(7); !ok || got != 7 { + t.Fatalf("int projection = %d, %v", got, ok) + } + if _, ok := devdocOptionalString(7); ok { + t.Fatal("non-string optional value accepted") + } +} diff --git a/internal/shortcut/doc/content_shortcuts.go b/internal/shortcut/doc/content_shortcuts.go index df6fd28e..72d071da 100644 --- a/internal/shortcut/doc/content_shortcuts.go +++ b/internal/shortcut/doc/content_shortcuts.go @@ -176,15 +176,17 @@ var Fetch = shortcut.Shortcut{ Command: "+fetch", Product: productDoc, Description: "读取完整或局部文档内容,并按 detail 控制保真度", - Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。", + Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, Contract: docContract( "+fetch", "读取完整或局部文档内容,并按 detail 控制保真度", - "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。", + "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。", []string{`dws doc +fetch --node `, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`}, contract.ParamDecl{Name: "node", Property: "nodeId"}, contract.ParamDecl{Name: "query", Property: "keyword"}, + contract.ParamDecl{Name: "password", Property: "password"}, + contract.ParamDecl{Name: "version", Property: "historyVersion"}, ), Flags: []shortcut.Flag{ {Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL;" + fetchTargetConstraint}, @@ -198,12 +200,17 @@ var Fetch = shortcut.Shortcut{ {Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"}, {Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"}, {Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"}, - {Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"}, + {Name: "password", Type: shortcut.FlagString, Desc: "互联网公开文档开启密码保护时的访问密码;普通文档无需传入"}, + {Name: "revision", Type: shortcut.FlagInt, Desc: "不支持;revision 是文档编辑版本号(JSONML 读取响应返回、供 +update --expected-revision 条件写使用),不是历史版本号"}, + {Name: "version", Type: shortcut.FlagInt, Desc: "读取指定历史版本(版本号从 doc +version-list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版"}, }, Tips: []string{`dws doc +fetch --node `, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Changed("revision") { - return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision") + return apperrors.NewValidation("--revision 不支持:revision 是文档编辑版本号(doc read --content-format jsonml 响应返回,供 doc +update --expected-revision 条件写使用),不是历史版本号;读历史版本请用 --version") + } + if rt.Changed("version") && rt.Int("version") < 0 { + return apperrors.NewValidation("--version 必须为非负整数历史版本号(0 表示初始版本,从 doc +version-list 获取)") } if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" { return apperrors.NewValidation("--scope keyword 时必须提供 --keyword") @@ -240,6 +247,12 @@ var Fetch = shortcut.Shortcut{ if rt.Changed("max-depth") { params["maxDepth"] = rt.Int("max-depth") } + if rt.Changed("version") { + params["historyVersion"] = rt.Int("version") + } + if value := rt.Str("password"); value != "" { + params["password"] = value + } data, err := rt.CallMCPData(productDoc, "get_document_content", params) if err != nil { return err diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index db3e9441..cf87d51b 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -1097,7 +1097,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { {Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}}, {Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}}, {Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}}, - {Fetch, []string{"--node", "n", "--revision", "1"}}, + {Fetch, []string{"--node", "n", "--revision", "7"}}, + {Fetch, []string{"--node", "n", "--version", "-2"}}, {Fetch, []string{"--node", "n", "--scope", "keyword"}}, {Update, []string{"--node", "n"}}, {Update, []string{"--command", "append", "--content", "x", "--yes"}}, @@ -1128,6 +1129,27 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history) } + fetchAccess := &docCoverageCaller{} + if err := runDocCoverage(t, Fetch, fetchAccess, "--node", "n", "--version", "7", "--password", "pw"); err != nil { + t.Fatalf("fetch with access params failed: %v", err) + } + if len(fetchAccess.history) != 1 || + fetchAccess.history[0].tool != "get_document_content" || + fetchAccess.history[0].params["historyVersion"] != 7 || + fetchAccess.history[0].params["password"] != "pw" { + t.Fatalf("fetch access params were not forwarded: %#v", fetchAccess.history) + } + + fetchZero := &docCoverageCaller{} + if err := runDocCoverage(t, Fetch, fetchZero, "--node", "n", "--version", "0"); err != nil { + t.Fatalf("fetch with zero version failed: %v", err) + } + if len(fetchZero.history) != 1 || + fetchZero.history[0].tool != "get_document_content" || + fetchZero.history[0].params["historyVersion"] != 0 { + t.Fatalf("fetch zero version (initial version) was not forwarded: %#v", fetchZero.history) + } + for _, value := range []any{ map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"}, map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none", diff --git a/internal/shortcut/hrbrain/hrbrain.go b/internal/shortcut/hrbrain/hrbrain.go new file mode 100644 index 00000000..f056bbca --- /dev/null +++ b/internal/shortcut/hrbrain/hrbrain.go @@ -0,0 +1,632 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package hrbrain registers fail-closed Shortcut adapters for DingTalk +// Organization Brain reads. Every declaration remains unavailable until the +// lower service supplies a verifiable non-null result and safe live fixtures. +package hrbrain + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const ( + hrbrainBlockerAdapterBusiness = "adapter_business_service" + hrbrainBlockerTenantFixture = "tenant_fixture" +) + +func hrbrainUnavailableReason(command string) string { + switch command { + case "+get-pool", "+list-pool-employees": + return "classified=" + hrbrainBlockerTenantFixture + "; exact Shortcut and raw atomic calls reach the same remote operation and upstream error, but no safe nonempty pool fixture exists to prove identity and pagination." + case "+list-pools", "+profile-labels": + return "classified=" + hrbrainBlockerAdapterBusiness + "; raw atomic calls return success=true with result=null and the exact Shortcut rejects the response, so the business response contract cannot prove a collection or legitimate empty result." + case "+profile-metadata", "+query-profile", "+profile-career", "+profile-performance", "+search-employees", "+search-employees-structured", "+search-fields": + return "classified=" + hrbrainBlockerAdapterBusiness + "; exact Shortcut and raw atomic calls reach the same remote server, operation, and upstream error, so no Shortcut-layer defect can be repaired without downstream capability or business-service recovery." + default: + panic("missing reviewed HRbrain blocker classification for " + command) + } +} + +type hrbrainPage struct { + CurrentPage int + PageSize int + TotalCount int + HasMore bool +} + +func hrbrainReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func hrbrainObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的 HRbrain 业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.name", "value.mobile", "value.email", "value.workNo", "value.userId"}, + } +} + +func hrbrainCollectionResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}}},"required":["count","items"],"additionalProperties":false}`, + description, description, + )), + SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"}, + } +} + +func hrbrainPageResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的总记录数"},"complete":{"type":"boolean","description":"服务端证据是否证明到达末页"}},"required":["count","items","currentPage","pageSize","totalCount","complete"],"additionalProperties":false}`, + description, description, + )), + SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"}, + } +} + +func hrbrainPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, CursorParameter: "page", + MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hrbrainContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + path := "hrbrain " + command + return corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "hrbrain", Name: name, CanonicalPath: "hrbrain." + name, + CLIPath: path, PrimaryCLIPath: path, + }, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: hrbrainUnavailableReason(command), + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"该 HRbrain Shortcut 当前 unavailable;不要把 null、业务错误或缺少安全 fixture 当作成功,基础通讯录查询改用 contact"}, + Examples: examples, + }, + Parameters: params, + Result: result, + Pagination: pagination, + } +} + +func hrbrainBase(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, flags []shortcut.Flag, params []contract.ParamDecl, examples ...string) shortcut.Shortcut { + return shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "hrbrain", + Command: command, + Product: "hrbrain", + Description: description, + Intent: intent, + Risk: shortcut.RiskRead, + Safety: hrbrainReadSafety(), + Contract: hrbrainContract(command, description, intent, result, pagination, params, examples...), + Flags: flags, + Tips: examples, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + } +} + +var ListPools = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+list-pools", "查询组织大脑人才池列表", "需要按名称、类型、创建人或标签发现人才池时使用;当前必须保持 unavailable,直到下游返回可验证分页数组。", + hrbrainPageResult("严格校验的人才池搜索页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "人才池名称关键词"}, + {Name: "pool-type", Type: shortcut.FlagString, Desc: "人才池类型"}, + {Name: "creator", Type: shortcut.FlagString, Desc: "创建人稳定身份"}, + {Name: "labels", Type: shortcut.FlagStringSlice, Desc: "标签列表"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "keyword", Property: "keyword"}, {Name: "pool-type", Property: "poolType"}, + {Name: "creator", Property: "creator"}, {Name: "labels", Property: "labels"}, + {Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +list-pools --page 1 --page-size 20 --format json`, + ) + declaration.Constraints = hrbrainPageConstraints() + declaration.Validate = hrbrainValidatePage + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")} + hrbrainAddOptional(params, rt, "keyword", "keyword") + hrbrainAddOptional(params, rt, "pool-type", "poolType") + hrbrainAddOptional(params, rt, "creator", "creator") + if rt.Changed("labels") { + params["labels"] = hrbrainCleanValues(rt.StrSlice("labels")) + } + return hrbrainCallPage(rt, "list_talent_pools", params, "poolCode") + } + return declaration +}() + +var GetPool = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+get-pool", "根据 poolCode 查询人才池详情", "已知稳定 poolCode 并需要读取同一人才池详情时使用;没有安全非空 fixture 前保持 unavailable。", + hrbrainObjectResult("身份匹配的人才池详情"), nil, + []shortcut.Flag{{Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"}}, + []contract.ParamDecl{{Name: "pool-code", Property: "poolCode"}}, + `dws hrbrain +get-pool --pool-code --format json`, + ) + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallObject(rt, "get_talent_pool_detail", map[string]any{"poolCode": rt.Str("pool-code")}, rt.Str("pool-code"), "poolCode") + } + return declaration +}() + +var ListPoolEmployees = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+list-pool-employees", "查询指定人才池内的员工列表", "已知稳定 poolCode 并需要分页读取池内员工时使用;缺少安全人才池与员工 fixture 前保持 unavailable。", + hrbrainPageResult("严格校验的人才池员工页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "pool-code", Property: "poolCode"}, {Name: "page", Property: "currentPage", InterfaceType: "number"}, + {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +list-pool-employees --pool-code --page 1 --page-size 20 --format json`, + ) + declaration.Constraints = hrbrainPageConstraints() + declaration.Validate = hrbrainValidatePage + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallPage(rt, "list_pool_employees", map[string]any{ + "poolCode": rt.Str("pool-code"), "currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"), + }, "workNo", "staffId", "userId") + } + return declaration +}() + +var ProfileMetadata = hrbrainCollectionShortcut( + "+profile-metadata", "查询员工档案元数据结构", "已知工号并需要发现可查询档案模块与字段时使用;下游工具当前不可验证,保持 unavailable。", + "get_profile_metadata", "work-no", "workNo", "档案元数据", []string{"modelCode", "fieldCode", "code"}, +) + +var QueryProfile = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+query-profile", "按模块批量查询员工档案数据", "已知工号、模块与字段编码并需要批量读取档案时使用;下游可用性和结果合同未通过 live 验证。", + hrbrainCollectionResult("严格校验的员工档案模块结果"), nil, + []shortcut.Flag{ + {Name: "work-no", Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"}, + {Name: "data-queries", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的模块查询条件"}, + }, + []contract.ParamDecl{{Name: "work-no", Property: "workNo"}, {Name: "data-queries", Property: "dataQueries"}}, + `dws hrbrain +query-profile --work-no --data-queries '[{"modelCode":"basic","fields":["name"]}]' --format json`, + ) + declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"data-queries"}, Description: "必须是非空 JSON 数组的模块查询条件"}} + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + _, err := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries") + return err + } + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + // Validate already established the exact non-empty object-array shape. + queries, _ := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries") + return hrbrainCallCollection(rt, "query_profile_data", map[string]any{"workNo": rt.Str("work-no"), "dataQueries": queries}, "modelCode", "moduleCode") + } + return declaration +}() + +var ProfileLabels = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+profile-labels", "批量查询员工档案标签", "已知一组稳定工号并需要读取标签时使用;下游当前 success=true 但 result=null,保持 unavailable。", + hrbrainCollectionResult("严格校验的员工标签结果"), nil, + []shortcut.Flag{ + {Name: "staff-ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "一个或多个非空员工工号"}, + {Name: "all-label", Type: shortcut.FlagBool, Desc: "是否返回全部标签"}, + }, + []contract.ParamDecl{{Name: "staff-ids", Property: "staffIds"}, {Name: "all-label", Property: "allLabel"}}, + `dws hrbrain +profile-labels --staff-ids --format json`, + ) + declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "必须包含一个或多个非空员工工号"}} + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + if len(hrbrainCleanValues(rt.StrSlice("staff-ids"))) == 0 { + return apperrors.NewValidation("--staff-ids 必须包含一个或多个非空员工工号") + } + return nil + } + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"staffIds": hrbrainCleanValues(rt.StrSlice("staff-ids"))} + if rt.Changed("all-label") { + params["allLabel"] = rt.Bool("all-label") + } + return hrbrainCallCollection(rt, "get_profile_label", params, "workNo", "staffId", "userId") + } + return declaration +}() + +var ProfileCareer = hrbrainCollectionShortcut( + "+profile-career", "查询员工公司内职业历程", "已知稳定工号并需要读取岗位或职级变动历史时使用;下游未通过 live 可用性验证。", + "get_employee_career", "work-no", "workNo", "职业历程", []string{"careerId", "id", "workNo"}, +) + +var ProfilePerformance = hrbrainCollectionShortcut( + "+profile-performance", "查询员工历史绩效记录", "已知稳定工号并需要读取历史绩效时使用;下游未通过 live 可用性验证。", + "get_employee_performance", "work-no", "workNo", "绩效记录", []string{"performanceId", "id", "workNo"}, +) + +var SearchEmployees = func() shortcut.Shortcut { + declaration := hrbrainEmployeeSearchBase( + "+search-employees", "按关键词、部门、职务、职级或人才池搜索员工", "需要按至少一个基础过滤条件搜索员工时使用;当前下游返回业务错误且缺少安全非空 fixture。", + "search_employees", + ) + declaration.Constraints = append(hrbrainPageConstraints(), shortcut.Constraint{ + Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "dept-name", "position-name", "job-level", "pool-code"}, + Description: "至少提供一个员工搜索过滤条件,禁止无条件枚举组织人员", + }) + declaration.Validate = hrbrainValidatePage + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")} + for _, pair := range [][2]string{{"keyword", "keyword"}, {"dept-name", "deptName"}, {"position-name", "positionName"}, {"job-level", "jobLevel"}, {"pool-code", "poolCode"}} { + hrbrainAddOptional(params, rt, pair[0], pair[1]) + } + return hrbrainCallPage(rt, "search_employees", params, "workNo", "staffId", "userId") + } + return declaration +}() + +var SearchEmployeesStructured = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+search-employees-structured", "使用高级条件表达式搜索员工", "已先取得有权限字段,并需要组合过滤表达式搜索员工时使用;下游工具当前不可用。", + hrbrainPageResult("严格校验的高级员工搜索页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "origin-json", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 对象的搜索表达式"}, + {Name: "fields", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的返回字段"}, + {Name: "order-by", Type: shortcut.FlagStringSlice, Desc: "排序字段列表"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "origin-json", Property: "originJson"}, {Name: "fields", Property: "fields"}, {Name: "order-by", Property: "orderByClauses"}, + {Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +search-employees-structured --origin-json '{"rules":[],"combinator":"and"}' --fields '[{"label":"name","value":"name"}]' --format json`, + ) + declaration.Constraints = append(hrbrainPageConstraints(), + shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"origin-json"}, Description: "必须是非空 JSON 对象的搜索表达式"}, + shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"fields"}, Description: "必须是非空 JSON 数组的返回字段"}, + ) + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + if err := hrbrainValidatePage(rt); err != nil { + return err + } + if _, err := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json"); err != nil { + return err + } + _, err := hrbrainJSONArray(rt.Str("fields"), "--fields") + return err + } + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + // Validate already established both structured input shapes. + origin, _ := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json") + fields, _ := hrbrainJSONArray(rt.Str("fields"), "--fields") + params := map[string]any{ + "originJson": string(mustJSON(origin)), "fields": fields, + "currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"), + } + if rt.Changed("order-by") { + params["orderByClauses"] = hrbrainCleanValues(rt.StrSlice("order-by")) + } + return hrbrainCallPage(rt, "search_employees_structured", params, "workNo", "staffId", "userId") + } + return declaration +}() + +var SearchFields = func() shortcut.Shortcut { + declaration := hrbrainBase( + "+search-fields", "获取当前身份可用的高级人才搜索字段", "构造高级员工搜索表达式前发现字段与操作符时使用;下游工具当前返回业务错误,保持 unavailable。", + hrbrainCollectionResult("严格校验的高级搜索字段"), nil, nil, nil, + `dws hrbrain +search-fields --format json`, + ) + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallCollection(rt, "get_search_fields", nil, "fieldCode", "code", "value") + } + return declaration +}() + +func hrbrainCollectionShortcut(command, description, intent, tool, flag, property, resultDescription string, identityKeys []string) shortcut.Shortcut { + declaration := hrbrainBase( + command, description, intent, hrbrainCollectionResult("严格校验的"+resultDescription), nil, + []shortcut.Flag{{Name: flag, Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"}}, + []contract.ParamDecl{{Name: flag, Property: property}}, + fmt.Sprintf("dws hrbrain %s --%s --format json", command, flag), + ) + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + return hrbrainCallCollection(rt, tool, map[string]any{property: rt.Str(flag)}, identityKeys...) + } + return declaration +} + +func hrbrainEmployeeSearchBase(command, description, intent, _ string) shortcut.Shortcut { + return hrbrainBase( + command, description, intent, hrbrainPageResult("严格校验的员工搜索页"), hrbrainPagination(), + []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "姓名或工号关键词"}, + {Name: "dept-name", Type: shortcut.FlagString, Desc: "部门名称"}, + {Name: "position-name", Type: shortcut.FlagString, Desc: "职务名称"}, + {Name: "job-level", Type: shortcut.FlagString, Desc: "职级"}, + {Name: "pool-code", Type: shortcut.FlagString, Desc: "人才池稳定 poolCode"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"}, + }, + []contract.ParamDecl{ + {Name: "keyword", Property: "keyword"}, {Name: "dept-name", Property: "deptName"}, {Name: "position-name", Property: "positionName"}, + {Name: "job-level", Property: "jobLevel"}, {Name: "pool-code", Property: "poolCode"}, + {Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"}, + }, + `dws hrbrain +search-employees --keyword --page 1 --page-size 20 --format json`, + ) +} + +func hrbrainPageConstraints() []shortcut.Constraint { + return []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量必须在 1 到 100 之间"}, + } +} + +func hrbrainValidatePage(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于 0") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + return nil +} + +func hrbrainCallObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected string, identityKeys ...string) error { + data, err := rt.CallMCPData("hrbrain", tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, "hrbrain/"+tool) + if err != nil { + return err + } + identity := hrbrainIdentity(value, identityKeys...) + if identity == "" { + return responsecheck.Error("hrbrain/"+tool, "missing_stable_id", "HRbrain 详情对象缺少稳定身份") + } + if expected != "" && identity != strings.TrimSpace(expected) { + return responsecheck.Error("hrbrain/"+tool, "identity_mismatch", "HRbrain 详情对象身份与请求目标不一致") + } + return rt.Output(map[string]any{"value": value}) +} + +func hrbrainCallCollection(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error { + data, err := rt.CallMCPData("hrbrain", tool, params) + if err != nil { + return err + } + items, err := hrbrainRequireCollectionResult(data, "hrbrain/"+tool, identityKeys...) + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "items": items}) +} + +func hrbrainCallPage(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error { + page, size := rt.Int("page"), rt.Int("page-size") + data, err := rt.CallMCPData("hrbrain", tool, params) + if err != nil { + return err + } + items, evidence, err := hrbrainProjectPage(data, "hrbrain/"+tool, page, size, identityKeys...) + if err != nil { + return err + } + payload := map[string]any{ + "count": len(items), "items": items, "currentPage": evidence.CurrentPage, + "pageSize": evidence.PageSize, "totalCount": evidence.TotalCount, "complete": !evidence.HasMore, + } + next := "" + if evidence.HasMore { + next = strconv.Itoa(evidence.CurrentPage + 1) + } + // The strict page evidence guarantees either terminal+empty-next or + // continuing+positive next page, the two valid framework states. + pagination, _ := output.NewPagination(!evidence.HasMore, next) + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(&output.Meta{ + Count: output.NewCount(len(items)), Pagination: pagination, + }))) +} + +func hrbrainRequireCollectionResult(data map[string]any, operation string, identityKeys ...string) ([]map[string]any, error) { + result, err := responsecheck.RequireResult(data, operation) + if err != nil { + return nil, err + } + raw, ok := result.([]any) + if !ok { + return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result 应为数组,实际为 %T", result)) + } + return hrbrainValidateItems(raw, operation, identityKeys...) +} + +func hrbrainProjectPage(data map[string]any, operation string, requestedPage, requestedSize int, identityKeys ...string) ([]map[string]any, hrbrainPage, error) { + result, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return nil, hrbrainPage{}, err + } + raw, ok := result["items"].([]any) + if !ok { + if _, present := result["items"]; !present { + return nil, hrbrainPage{}, responsecheck.Error(operation, "missing_collection", "成功响应缺少 result.items 数组") + } + return nil, hrbrainPage{}, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result.items 应为数组,实际为 %T", result["items"])) + } + items, err := hrbrainValidateItems(raw, operation, identityKeys...) + if err != nil { + return nil, hrbrainPage{}, err + } + evidence, err := hrbrainParsePage(result, operation, requestedPage, requestedSize, len(items)) + if err != nil { + return nil, hrbrainPage{}, err + } + return items, evidence, nil +} + +func hrbrainValidateItems(raw []any, operation string, identityKeys ...string) ([]map[string]any, error) { + items := make([]map[string]any, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("结果第 %d 项不是非空对象", index)) + } + identity := hrbrainIdentity(object, identityKeys...) + if identity == "" { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定身份", index)) + } + if _, duplicate := seen[identity]; duplicate { + return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("结果第 %d 项稳定身份重复", index)) + } + seen[identity] = struct{}{} + items = append(items, object) + } + return items, nil +} + +func hrbrainParsePage(result map[string]any, operation string, requestedPage, requestedSize, itemCount int) (hrbrainPage, error) { + currentPage, ok := hrbrainInteger(result["currentPage"]) + if !ok || currentPage != requestedPage || currentPage < 1 { + return hrbrainPage{}, responsecheck.Error(operation, "pagination_page_mismatch", "currentPage 缺失、错型或与请求页码不一致") + } + pageSize, ok := hrbrainInteger(result["pageSize"]) + if !ok || pageSize != requestedSize || pageSize < 1 || itemCount > pageSize { + return hrbrainPage{}, responsecheck.Error(operation, "pagination_size_mismatch", "pageSize 缺失、错型、与请求不一致或小于结果条数") + } + totalCount, ok := hrbrainInteger(result["totalCount"]) + if !ok || totalCount < 0 || totalCount < itemCount { + return hrbrainPage{}, responsecheck.Error(operation, "invalid_total_count", "totalCount 缺失、错型、为负数或小于当前页条数") + } + hasMore, ok := result["hasMore"].(bool) + if !ok { + return hrbrainPage{}, responsecheck.Error(operation, "malformed_pagination", "hasMore 缺失或不是布尔值") + } + pageEnd := currentPage * pageSize + if hasMore && pageEnd >= totalCount || !hasMore && pageEnd < totalCount { + return hrbrainPage{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore 与 currentPage/pageSize/totalCount 相互矛盾") + } + return hrbrainPage{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil +} + +func hrbrainIdentity(object map[string]any, keys ...string) string { + for _, key := range keys { + value := object[key] + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return text + } + case float64: + if typed == math.Trunc(typed) && !math.IsNaN(typed) && !math.IsInf(typed, 0) { + return strconv.FormatInt(int64(typed), 10) + } + } + } + return "" +} + +func hrbrainInteger(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case float64: + if typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) || typed < float64(math.MinInt) { + return 0, false + } + return int(typed), true + default: + return 0, false + } +} + +func hrbrainAddOptional(params map[string]any, rt *shortcut.RuntimeContext, flag, property string) { + if value := strings.TrimSpace(rt.Str(flag)); value != "" { + params[property] = value + } +} + +func hrbrainCleanValues(values []string) []string { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + cleaned = append(cleaned, value) + } + } + return cleaned +} + +func hrbrainJSONArray(raw, flag string) ([]any, error) { + var value []any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 数组: %v", flag, err)) + } + if len(value) == 0 { + return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 数组") + } + for index, item := range value { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 第 %d 项必须是非空对象", flag, index)) + } + } + return value, nil +} + +func hrbrainJSONObject(raw, flag string) (map[string]any, error) { + var value map[string]any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 对象: %v", flag, err)) + } + if len(value) == 0 { + return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 对象") + } + return value, nil +} + +func mustJSON(value any) []byte { + encoded, err := json.Marshal(value) + if err != nil { + panic(err) + } + return encoded +} + +func init() { + shortcut.Register( + ListPools, GetPool, ListPoolEmployees, + ProfileMetadata, QueryProfile, ProfileLabels, ProfileCareer, ProfilePerformance, + SearchEmployees, SearchEmployeesStructured, SearchFields, + ) +} diff --git a/internal/shortcut/hrbrain/hrbrain_cross_platform_coverage_test.go b/internal/shortcut/hrbrain/hrbrain_cross_platform_coverage_test.go new file mode 100644 index 00000000..62049917 --- /dev/null +++ b/internal/shortcut/hrbrain/hrbrain_cross_platform_coverage_test.go @@ -0,0 +1,385 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package hrbrain + +import ( + "context" + "errors" + "io" + "math" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type hrbrainCoverageCaller struct{ calls int } + +func (caller *hrbrainCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":[]}`}}}, nil +} +func (*hrbrainCoverageCaller) Format() string { return "json" } +func (*hrbrainCoverageCaller) DryRun() bool { return false } +func (*hrbrainCoverageCaller) Fields() string { return "" } +func (*hrbrainCoverageCaller) JQ() string { return "" } + +type hrbrainParityCaller struct { + calls int + server string + tool string + params map[string]any + response string + err error +} + +func (caller *hrbrainParityCaller) CallTool(_ context.Context, server, tool string, params map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.server = server + caller.tool = tool + caller.params = params + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil +} +func (*hrbrainParityCaller) Format() string { return "json" } +func (*hrbrainParityCaller) DryRun() bool { return false } +func (*hrbrainParityCaller) Fields() string { return "" } +func (*hrbrainParityCaller) JQ() string { return "" } + +func runHRbrainCoverage(t *testing.T, declaration shortcut.Shortcut, caller edition.ToolCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "hrbrain"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetArgs(append([]string{"hrbrain", declaration.Command}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageHRbrainDeclarationsStayUnavailableAndTyped(t *testing.T) { + declarations := []shortcut.Shortcut{ + ListPools, GetPool, ListPoolEmployees, ProfileMetadata, QueryProfile, ProfileLabels, + ProfileCareer, ProfilePerformance, SearchEmployees, SearchEmployeesStructured, SearchFields, + } + if len(declarations) != 11 { + t.Fatalf("declarations = %d", len(declarations)) + } + for _, declaration := range declarations { + if !declaration.Hidden || declaration.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s must remain hidden/unavailable", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive || declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks unified Contract/Result", declaration.Command) + } + if strings.TrimSpace(declaration.Safety.Effect) == "" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s lacks unavailable interface and Safety", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageHRbrainBlockersAreClassifiedWithoutShortcutDefects(t *testing.T) { + commands := []string{ + ListPools.Command, GetPool.Command, ListPoolEmployees.Command, ProfileMetadata.Command, + QueryProfile.Command, ProfileLabels.Command, ProfileCareer.Command, ProfilePerformance.Command, + SearchEmployees.Command, SearchEmployeesStructured.Command, SearchFields.Command, + } + counts := map[string]int{} + for _, command := range commands { + reason := hrbrainUnavailableReason(command) + if strings.Contains(reason, "shortcut_defect") || !strings.Contains(reason, "classified=") { + t.Fatalf("%s has unreviewed blocker reason %q", command, reason) + } + switch { + case strings.Contains(reason, "classified="+hrbrainBlockerAdapterBusiness): + counts[hrbrainBlockerAdapterBusiness]++ + case strings.Contains(reason, "classified="+hrbrainBlockerTenantFixture): + counts[hrbrainBlockerTenantFixture]++ + default: + t.Fatalf("%s has unknown blocker classification %q", command, reason) + } + } + if counts[hrbrainBlockerAdapterBusiness] != 9 || counts[hrbrainBlockerTenantFixture] != 2 { + t.Fatalf("blocker counts = %#v", counts) + } +} + +func TestCrossPlatformCoverageHRbrainExactParameterProjectionForAllElevenShortcuts(t *testing.T) { + pageResponse := `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}` + employeePageResponse := `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}` + queryItems := []any{map[string]any{"modelCode": "model", "fields": []any{"field"}}} + structuredFields := []any{map[string]any{"label": "field", "value": "field"}} + cases := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + params map[string]any + response string + }{ + {name: "list pools", declaration: ListPools, tool: "list_talent_pools", params: map[string]any{"currentPage": 1, "pageSize": 20}, response: pageResponse}, + {name: "get pool", declaration: GetPool, args: []string{"--pool-code", "pool"}, tool: "get_talent_pool_detail", params: map[string]any{"poolCode": "pool"}, response: `{"success":true,"result":{"poolCode":"pool"}}`}, + {name: "list pool employees", declaration: ListPoolEmployees, args: []string{"--pool-code", "pool"}, tool: "list_pool_employees", params: map[string]any{"poolCode": "pool", "currentPage": 1, "pageSize": 20}, response: employeePageResponse}, + {name: "profile metadata", declaration: ProfileMetadata, args: []string{"--work-no", "worker"}, tool: "get_profile_metadata", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"modelCode":"model"}]}`}, + {name: "query profile", declaration: QueryProfile, args: []string{"--work-no", "worker", "--data-queries", `[{"modelCode":"model","fields":["field"]}]`}, tool: "query_profile_data", params: map[string]any{"workNo": "worker", "dataQueries": queryItems}, response: `{"success":true,"result":[{"modelCode":"model"}]}`}, + {name: "profile labels", declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, tool: "get_profile_label", params: map[string]any{"staffIds": []string{"worker"}}, response: `{"success":true,"result":[{"workNo":"worker"}]}`}, + {name: "profile career", declaration: ProfileCareer, args: []string{"--work-no", "worker"}, tool: "get_employee_career", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"careerId":"career"}]}`}, + {name: "profile performance", declaration: ProfilePerformance, args: []string{"--work-no", "worker"}, tool: "get_employee_performance", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"performanceId":"performance"}]}`}, + {name: "search employees", declaration: SearchEmployees, args: []string{"--keyword", "worker"}, tool: "search_employees", params: map[string]any{"keyword": "worker", "currentPage": 1, "pageSize": 20}, response: employeePageResponse}, + {name: "search employees structured", declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`}, tool: "search_employees_structured", params: map[string]any{"originJson": `{"rules":[{"field":"field"}]}`, "fields": structuredFields, "currentPage": 1, "pageSize": 20}, response: employeePageResponse}, + {name: "search fields", declaration: SearchFields, tool: "get_search_fields", params: map[string]any{}, response: `{"success":true,"result":[{"fieldCode":"field"}]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &hrbrainParityCaller{response: tc.response} + if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatalf("exact Shortcut failed against valid synthetic response: %v", err) + } + if caller.calls != 1 || caller.server != "hrbrain" || caller.tool != tc.tool { + t.Fatalf("remote route = calls:%d server:%q tool:%q, want 1/hrbrain/%s", caller.calls, caller.server, caller.tool, tc.tool) + } + if !reflect.DeepEqual(caller.params, tc.params) { + t.Fatalf("params = %#v, want %#v", caller.params, tc.params) + } + }) + } +} + +func TestCrossPlatformCoverageHRbrainRejectsNullWrongCollectionsAndBadItems(t *testing.T) { + brokenCollections := []map[string]any{ + {}, + {"success": false, "result": []any{}}, + {"success": true, "result": nil}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"name": "missing-id"}}}, + {"success": true, "result": []any{map[string]any{"poolCode": "same"}, map[string]any{"poolCode": "same"}}}, + } + for index, payload := range brokenCollections { + if got, err := hrbrainRequireCollectionResult(payload, "hrbrain/test", "poolCode"); err == nil { + t.Errorf("broken collection %d accepted: %#v", index, got) + } + } + explicitEmpty := map[string]any{"success": true, "result": []any{}} + if got, err := hrbrainRequireCollectionResult(explicitEmpty, "hrbrain/test", "poolCode"); err != nil || len(got) != 0 { + t.Fatalf("explicit empty collection: got=%#v err=%v", got, err) + } +} + +func TestCrossPlatformCoverageHRbrainPaginationFailsClosed(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{ + "items": []any{map[string]any{"poolCode": "pool-1"}}, + "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true, + }} + items, page, err := hrbrainProjectPage(valid, "hrbrain/test", 1, 1, "poolCode") + if err != nil || len(items) != 1 || !page.HasMore { + t.Fatalf("valid page: items=%#v page=%+v err=%v", items, page, err) + } + broken := []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{}}}, + {"success": true, "result": map[string]any{"items": "bad", "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(21), "hasMore": false}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(20), "hasMore": true}}, + {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": "false"}}, + } + for index, payload := range broken { + if got, evidence, err := hrbrainProjectPage(payload, "hrbrain/test", 1, 20, "poolCode"); err == nil { + t.Errorf("broken page %d accepted: items=%#v evidence=%+v", index, got, evidence) + } + } +} + +func TestCrossPlatformCoverageHRbrainStructuredInputsRejectBadShapes(t *testing.T) { + for _, raw := range []string{"", "{}", "[]", "[1]", "[{}]"} { + if _, err := hrbrainJSONArray(raw, "--items"); err == nil { + t.Errorf("bad array accepted: %q", raw) + } + } + for _, raw := range []string{"", "[]", "{}"} { + if _, err := hrbrainJSONObject(raw, "--object"); err == nil { + t.Errorf("bad object accepted: %q", raw) + } + } +} + +func TestCrossPlatformCoverageHRbrainInvalidInputMakesZeroRemoteCalls(t *testing.T) { + for name, tc := range map[string]struct { + declaration shortcut.Shortcut + args []string + }{ + "unfiltered employee search": {declaration: SearchEmployees}, + "invalid page": {declaration: ListPools, args: []string{"--page", "0"}}, + "invalid page size": {declaration: ListPoolEmployees, args: []string{"--pool-code", "fixture", "--page-size", "101"}}, + "bad query json": {declaration: QueryProfile, args: []string{"--work-no", "fixture", "--data-queries", `{}`}}, + "bad structured json": {declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{}`, "--fields", `[]`}}, + } { + t.Run(name, func(t *testing.T) { + caller := &hrbrainCoverageCaller{} + if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid input unexpectedly succeeded") + } + if caller.calls != 0 { + t.Fatalf("remote calls before validation = %d, want 0", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageHRbrainExecutionFailuresAndContinuation(t *testing.T) { + for name, tc := range map[string]struct { + declaration shortcut.Shortcut + args []string + response string + err error + }{ + "object transport": {declaration: GetPool, args: []string{"--pool-code", "pool"}, err: errors.New("transport")}, + "object malformed": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":null}`}, + "object missing id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"name":"pool"}}`}, + "object wrong id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"poolCode":"other"}}`}, + "collection transport": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, err: errors.New("transport")}, + "collection malformed": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, response: `{"success":true,"result":null}`}, + "page transport": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, err: errors.New("transport")}, + "page malformed": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, response: `{"success":true,"result":null}`}, + } { + t.Run(name, func(t *testing.T) { + caller := &hrbrainParityCaller{response: tc.response, err: tc.err} + if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid remote result succeeded") + } + if caller.calls != 1 { + t.Fatalf("calls = %d", caller.calls) + } + }) + } + + continuing := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`} + if err := runHRbrainCoverage(t, SearchEmployees, continuing, "--keyword", "worker", "--page-size", "1"); err != nil { + t.Fatal(err) + } + if continuing.calls != 1 { + t.Fatalf("continuing calls = %d", continuing.calls) + } + + optionalPool := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`} + if err := runHRbrainCoverage(t, ListPools, optionalPool, + "--keyword", "pool", "--pool-type", "type", "--creator", "creator", "--labels", "one,two"); err != nil { + t.Fatal(err) + } + if labels, ok := optionalPool.params["labels"].([]string); !ok || len(labels) != 2 { + t.Fatalf("labels = %#v", optionalPool.params["labels"]) + } + + labelsCaller := &hrbrainParityCaller{response: `{"success":true,"result":[{"workNo":"worker"}]}`} + if err := runHRbrainCoverage(t, ProfileLabels, labelsCaller, "--staff-ids", "worker", "--all-label"); err != nil { + t.Fatal(err) + } + if labelsCaller.params["allLabel"] != true { + t.Fatalf("allLabel = %#v", labelsCaller.params["allLabel"]) + } + + structured := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`} + if err := runHRbrainCoverage(t, SearchEmployeesStructured, structured, + "--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`, "--order-by", "field"); err != nil { + t.Fatal(err) + } + if order, ok := structured.params["orderByClauses"].([]string); !ok || len(order) != 1 { + t.Fatalf("orderByClauses = %#v", structured.params["orderByClauses"]) + } +} + +func TestCrossPlatformCoverageHRbrainRemainingValidationAndScalarEdges(t *testing.T) { + t.Run("unknown blocker", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unknown blocker classification did not panic") + } + }() + _ = hrbrainUnavailableReason("+unknown") + }) + + profileLabelsCommand := corecmd.New(shortcut.FromShortcut(ProfileLabels)) + if err := profileLabelsCommand.Flags().Set("staff-ids", ","); err != nil { + t.Fatal(err) + } + if err := ProfileLabels.Validate(shortcut.RuntimeContextForTest(profileLabelsCommand, ProfileLabels)); err == nil { + t.Fatal("blank staff IDs succeeded") + } + badPage := &hrbrainCoverageCaller{} + if err := runHRbrainCoverage(t, SearchEmployeesStructured, badPage, + "--origin-json", `{"rule":"value"}`, "--fields", `[{"field":"value"}]`, "--page", "0"); err == nil { + t.Fatal("structured search invalid page succeeded") + } + if badPage.calls != 0 { + t.Fatalf("structured invalid page remote calls = %d", badPage.calls) + } + + if got := hrbrainIdentity(map[string]any{"id": float64(7)}, "id"); got != "7" { + t.Fatalf("numeric identity = %q", got) + } + for _, value := range []any{math.NaN(), math.Inf(1), 1.5} { + if got := hrbrainIdentity(map[string]any{"id": value}, "id"); got != "" { + t.Fatalf("invalid identity = %q", got) + } + } + if got, ok := hrbrainInteger(7); !ok || got != 7 { + t.Fatalf("integer = %d, %v", got, ok) + } + for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "7"} { + if _, ok := hrbrainInteger(value); ok { + t.Fatalf("invalid integer accepted: %#v", value) + } + } + + t.Run("marshal panic", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("mustJSON did not panic for unsupported value") + } + }() + _ = mustJSON(make(chan int)) + }) +} + +func TestCrossPlatformCoverageHRbrainPageSizeAndTotalContradictions(t *testing.T) { + missing := map[string]any{"success": true, "result": map[string]any{}} + if _, _, err := hrbrainProjectPage(missing, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("missing page collection succeeded") + } + badItem := map[string]any{"success": true, "result": map[string]any{ + "items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(1), "hasMore": false, + }} + if _, _, err := hrbrainProjectPage(badItem, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("bad page item succeeded") + } + tooMany := map[string]any{"success": true, "result": map[string]any{ + "items": []any{map[string]any{"workNo": "one"}, map[string]any{"workNo": "two"}}, + "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true, + }} + if _, _, err := hrbrainProjectPage(tooMany, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("page larger than pageSize succeeded") + } + badTotal := map[string]any{"success": true, "result": map[string]any{ + "items": []any{map[string]any{"workNo": "one"}}, + "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(0), "hasMore": false, + }} + if _, _, err := hrbrainProjectPage(badTotal, "hrbrain/test", 1, 1, "workNo"); err == nil { + t.Fatal("total below item count succeeded") + } +} diff --git a/internal/shortcut/live/live.go b/internal/shortcut/live/live.go new file mode 100644 index 00000000..d76d5205 --- /dev/null +++ b/internal/shortcut/live/live.go @@ -0,0 +1,155 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package live defines strict task-oriented Shortcut candidates for DingTalk +// live broadcasts. The current list capability remains fail-closed because the +// downstream response can contradict its own completion and total fields. +package live + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const liveListGapReason = "Exact bounded live probes returned total greater than zero together with hasFinish=true and an empty liveDetailModelList; the response cannot prove either a complete list or a legitimate empty result." + +var ListMyLives = shortcut.Shortcut{ + OutputRollout: output.RolloutLegacyOnly, + Service: "live", Command: "+list-my-lives", Product: "live", + Description: "查看当前用户发起的直播列表与基础统计", + Intent: "需要查看本人发起的直播、状态或观看统计时使用;当前因下游列表数量与完成状态矛盾保持不可用。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Description: "查看当前用户发起的直播列表与基础统计", + Identity: contract.ToolIdentitySpec{ + ProductID: "live", Name: "shortcut_list_my_lives", CanonicalPath: "live.shortcut_list_my_lives", + CLIPath: "live +list-my-lives", PrimaryCLIPath: "live +list-my-lives", + }, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: liveListGapReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查看当前用户发起的直播列表与基础统计", + UseWhen: []string{"需要查看本人发起的直播、状态或观看统计时使用;当前因下游列表数量与完成状态矛盾保持不可用。"}, + AvoidWhen: []string{ + "需要创建、开播、结束、观看或控制直播时不要使用;当前 DWS 原子面没有这些能力", + "下游列表总数、完成状态和项目数组未自洽前不要把空数组当作没有直播", + }, + Examples: []string{"dws live +list-my-lives --format json"}, + }, + }, + Execute: func(*shortcut.RuntimeContext) error { + return responsecheck.Error("live/get_my_lives", "capability_unavailable", liveListGapReason) + }, +} + +// projectLiveList is retained as the reviewed activation gate. It accepts only +// an explicit complete, unpaginated list whose total exactly equals its items. +func projectLiveList(data map[string]any) (map[string]any, error) { + operation := "live/get_my_lives" + envelope, err := responsecheck.RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, key := range []string{"errorCode", "errorMsg", "errorMessage", "error"} { + if liveFailureValue(envelope[key]) { + return nil, responsecheck.Error(operation, "conflicting_failure_evidence", "success=true 响应同时携带失败字段 "+key) + } + } + result, err := responsecheck.RequireObjectResult(envelope, operation) + if err != nil { + return nil, err + } + total, ok := liveNonNegativeInt(result["total"]) + if !ok { + return nil, responsecheck.Error(operation, "malformed_total", "响应 result.total 必须是非负整数") + } + complete, ok := result["hasFinish"].(bool) + if !ok { + return nil, responsecheck.Error(operation, "malformed_completion", "响应 result.hasFinish 必须是布尔值") + } + if !complete { + return nil, responsecheck.Error(operation, "unpageable_incomplete_result", "响应未完结但接口没有 cursor 或下一页参数,不能声称列表成功") + } + items, err := responsecheck.RequireObjectCollection(envelope, operation, "result.liveDetailModelList") + if err != nil { + return nil, err + } + if total != len(items) { + return nil, responsecheck.Error(operation, "inconsistent_total", fmt.Sprintf("响应 total=%d、项目数=%d 且 hasFinish=true,不能证明完整列表", total, len(items))) + } + seen := make(map[string]bool, len(items)) + for index, item := range items { + id := liveStableID(item) + if id == "" { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("直播结果第 %d 项缺少稳定直播 ID", index)) + } + if seen[id] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("直播结果第 %d 项重复稳定直播 ID", index)) + } + seen[id] = true + } + return map[string]any{"count": total, "lives": items, "complete": true}, nil +} + +func liveFailureValue(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + trimmed := strings.TrimSpace(typed) + return trimmed != "" && trimmed != "0" && !strings.EqualFold(trimmed, "success") + case float64: + return typed != 0 + case int: + return typed != 0 + case bool: + return typed + case map[string]any: + return len(typed) != 0 + case []any: + return len(typed) != 0 + default: + return true + } +} + +func liveNonNegativeInt(value any) (int, bool) { + switch typed := value.(type) { + case float64: + if typed < 0 || typed != float64(int(typed)) { + return 0, false + } + return int(typed), true + case int: + return typed, typed >= 0 + case json.Number: + parsed, err := typed.Int64() + return int(parsed), err == nil && parsed >= 0 + default: + return 0, false + } +} + +func liveStableID(item map[string]any) string { + for _, key := range []string{"liveId", "liveUuid", "uuid", "id"} { + if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func init() { + shortcut.Register(ListMyLives) +} diff --git a/internal/shortcut/live/live_cross_platform_coverage_test.go b/internal/shortcut/live/live_cross_platform_coverage_test.go new file mode 100644 index 00000000..d267f325 --- /dev/null +++ b/internal/shortcut/live/live_cross_platform_coverage_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package live + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type liveCaller struct{ calls int } + +func (caller *liveCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"total":0,"hasFinish":true,"liveDetailModelList":[]}}`}}}, nil +} +func (*liveCaller) Format() string { return "json" } +func (*liveCaller) DryRun() bool { return false } +func (*liveCaller) Fields() string { return "" } +func (*liveCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageLiveListStrictProjection(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{"total": float64(1), "hasFinish": true, "liveDetailModelList": []any{map[string]any{"liveId": "stable-live"}}}} + projected, err := projectLiveList(valid) + if err != nil || projected["count"] != 1 { + t.Fatalf("valid list rejected: projected=%v err=%v", projected, err) + } + empty, err := projectLiveList(map[string]any{"success": true, "result": map[string]any{"total": float64(0), "hasFinish": true, "liveDetailModelList": []any{}}}) + if err != nil || empty["count"] != 0 { + t.Fatalf("explicit zero rejected: projected=%v err=%v", empty, err) + } + + broken := []map[string]any{ + {}, + {"result": map[string]any{}}, + {"success": false, "result": map[string]any{}}, + {"success": true, "errorCode": "FAILED", "result": map[string]any{"total": float64(0), "hasFinish": true, "liveDetailModelList": []any{}}}, + {"success": true}, + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{"total": float64(0), "hasFinish": true}}, + {"success": true, "result": map[string]any{"total": "0", "hasFinish": true, "liveDetailModelList": []any{}}}, + {"success": true, "result": map[string]any{"total": float64(0), "hasFinish": "true", "liveDetailModelList": []any{}}}, + {"success": true, "result": map[string]any{"total": float64(0), "hasFinish": false, "liveDetailModelList": []any{}}}, + {"success": true, "result": map[string]any{"total": float64(4), "hasFinish": true, "liveDetailModelList": []any{}}}, + {"success": true, "result": map[string]any{"total": float64(1), "hasFinish": true, "liveDetailModelList": []any{"bad"}}}, + {"success": true, "result": map[string]any{"total": float64(1), "hasFinish": true, "liveDetailModelList": []any{map[string]any{"title": "no-id"}}}}, + {"success": true, "result": map[string]any{"total": float64(2), "hasFinish": true, "liveDetailModelList": []any{map[string]any{"liveId": "duplicate"}, map[string]any{"liveId": "duplicate"}}}}, + } + for index, data := range broken { + if got, projectErr := projectLiveList(data); projectErr == nil { + t.Errorf("broken response %d returned success: %v", index, got) + } + } +} + +func TestCrossPlatformCoverageUnavailableLiveMakesNoRemoteCall(t *testing.T) { + caller := &liveCaller{} + helpers.InitDepsForTest(t, caller) + err := ListMyLives.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: ListMyLives.Command}, ListMyLives)) + if err == nil || !strings.Contains(err.Error(), "cannot prove") { + t.Fatalf("unavailable error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("unavailable Live shortcut made %d remote calls", caller.calls) + } +} + +func TestCrossPlatformCoverageLiveContract(t *testing.T) { + if ListMyLives.OutputRollout != output.RolloutLegacyOnly || ListMyLives.Contract.Result != nil || strings.TrimSpace(ListMyLives.Safety.Effect) == "" || ListMyLives.Contract.Interface == nil || ListMyLives.Contract.Interface.Availability != "unavailable" { + t.Fatalf("Live unavailable contract drift: rollout=%q result=%v interface=%#v", ListMyLives.OutputRollout, ListMyLives.Contract.Result, ListMyLives.Contract.Interface) + } +} + +func TestCrossPlatformCoverageLivePrimitiveMatrices(t *testing.T) { + for index, test := range []struct { + value any + want bool + }{ + {nil, false}, {"", false}, {"0", false}, {"success", false}, {"bad", true}, + {float64(0), false}, {float64(1), true}, {0, false}, {1, true}, {false, false}, {true, true}, + {map[string]any{}, false}, {map[string]any{"x": 1}, true}, {[]any{}, false}, {[]any{1}, true}, {struct{}{}, true}, + } { + if got := liveFailureValue(test.value); got != test.want { + t.Errorf("failure matrix %d = %v, want %v", index, got, test.want) + } + } + for index, test := range []struct { + value any + want int + ok bool + }{ + {float64(7), 7, true}, {float64(-1), 0, false}, {float64(7.5), 0, false}, + {8, 8, true}, {-1, -1, false}, {json.Number("9"), 9, true}, {json.Number("bad"), 0, false}, {"9", 0, false}, + } { + got, ok := liveNonNegativeInt(test.value) + if got != test.want || ok != test.ok { + t.Errorf("integer matrix %d = (%d,%v), want (%d,%v)", index, got, ok, test.want, test.ok) + } + } + for _, field := range []string{"liveId", "liveUuid", "uuid", "id"} { + if got := liveStableID(map[string]any{field: " stable "}); got != "stable" { + t.Errorf("stable ID field %s = %q", field, got) + } + } +} diff --git a/internal/shortcut/pat/pat.go b/internal/shortcut/pat/pat.go new file mode 100644 index 00000000..16d1ee42 --- /dev/null +++ b/internal/shortcut/pat/pat.go @@ -0,0 +1,154 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package pat registers safe Shortcut routing for PAT authorization tasks. +package pat + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var safeAgentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +var ( + patBrowserPolicyConfigDir = patcore.BrowserPolicyConfigDir + patSetBrowserPolicy = patcore.SetBrowserPolicy + patReadBrowserPolicy = patcore.ReadStoredBrowserPolicy +) + +func browserPolicyResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"本地 PAT 浏览器策略预览或已验证写入结果","properties":{"scope":{"type":"string","description":"写入范围:default 或 agent","enum":["default","agent"]},"openBrowser":{"type":"boolean","description":"策略是否允许打开浏览器"},"executed":{"type":"boolean","description":"本次是否实际写入本地策略"},"verified":{"type":"boolean","description":"是否对同一目标完成磁盘读回验证"}},"required":["scope","openBrowser","executed","verified"],"additionalProperties":false}`), + } +} + +func authorizeResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"由 routed 原子命令 pat chmod 持有的 PAT 授权计划或逐 scope 终态账本","properties":{"selected":{"type":"integer","description":"计划选择的 scope 数量"},"skipped":{"type":"integer","description":"计划跳过的 scope 数量"},"pending":{"type":"integer","description":"仍待授权的 scope 数量"},"verified":{"type":"boolean","description":"授权身份与逐 scope 终态是否已验证"}},"required":["selected","skipped","pending","verified"],"additionalProperties":false}`), + } +} + +var BrowserPolicy = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "pat", + Command: "+browser-policy", + Product: "pat", + Description: "安全配置 PAT 授权时是否允许打开本地浏览器", + Intent: "需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "pat", Name: "shortcut_browser_policy", CanonicalPath: "pat.shortcut_browser_policy", + CLIPath: "pat +browser-policy", PrimaryCLIPath: "pat +browser-policy", + }, + Description: "安全配置 PAT 授权时是否允许打开本地浏览器", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, + Reason: "The Shortcut writes only the local PAT policy file, requires confirmation, supports a no-write request preview, omits agent identity from output, and verifies the exact persisted target.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "安全配置 PAT 授权时是否允许打开本地浏览器", + UseWhen: []string{"需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。"}, + AvoidWhen: []string{"需要预览或授予行为 scope 时使用原子命令 pat chmod;普通 OAuth 登录使用 auth"}, + Examples: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "enabled"}, {Name: "agent-code"}, + }, + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest}, + Result: browserPolicyResult(), + }, + Flags: []shortcut.Flag{ + {Name: "enabled", Type: shortcut.FlagBool, Required: true, Desc: "是否允许打开本地浏览器;必须显式传 true 或 false"}, + {Name: "agent-code", Type: shortcut.FlagString, Desc: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符;只用于定位本地策略项且不会出现在结果中"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"agent-code"}, Description: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符"}, + }, + Tips: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`}, + Validate: func(rt *shortcut.RuntimeContext) error { + value := strings.TrimSpace(rt.Str("agent-code")) + if value != "" && !safeAgentCodePattern.MatchString(value) { + return apperrors.NewValidation("--agent-code 格式无效;只允许 1 到 64 位字母、数字、下划线或连字符") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + scope := "default" + agentCode := strings.TrimSpace(rt.Str("agent-code")) + if agentCode != "" { + scope = "agent" + } + enabled := rt.Bool("enabled") + if rt.DryRun() { + return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": false, "verified": false}) + } + configDir := patBrowserPolicyConfigDir() + written, err := patSetBrowserPolicy(configDir, agentCode, enabled) + if err != nil { + return err + } + stored, err := patReadBrowserPolicy(configDir, agentCode) + if err != nil { + return err + } + if written.Scope != scope || stored.Scope != scope || written.OpenBrowser != enabled || stored.OpenBrowser != enabled || written.AgentCode != stored.AgentCode { + return apperrors.NewInternal("PAT 浏览器策略写后读回不一致;结果未验证") + } + return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": true, "verified": true}) + }, +} + +var Authorize = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "pat", + Command: "+authorize", + Product: "pat", + Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod", + Intent: "命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。", + Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "pat", Name: "shortcut_authorize", CanonicalPath: "pat.shortcut_authorize", + CLIPath: "pat +authorize", PrimaryCLIPath: "pat +authorize", + }, + Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, + Reason: "classified=routed; pat chmod already owns the reviewed plan, fallback, pending, per-scope ledger, identity, and confirmation chain. The Shortcut runtime cannot reuse that Cobra/ToolCaller closure without duplicating safety semantics, and a live write remains blocked_fixture, so +authorize stays unavailable while routing to pat chmod.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "将 PAT 行为 scope 授权路由到原子命令 pat chmod", + UseWhen: []string{"命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。"}, + AvoidWhen: []string{"不要向此 unavailable Shortcut 传入任何真实身份、会话、scope 或授权材料;本地浏览器策略使用 pat +browser-policy"}, + Examples: []string{`dws pat +authorize --format json`}, + }, + Result: authorizeResult(), + }, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Execute: func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("PAT 授权任务 classified=routed;+authorize 当前 unavailable,请使用审核后的原子命令 pat chmod,并先执行 --dry-run") + }, +} + +func init() { + shortcut.Register(BrowserPolicy, Authorize) +} diff --git a/internal/shortcut/pat/pat_cross_platform_coverage_test.go b/internal/shortcut/pat/pat_cross_platform_coverage_test.go new file mode 100644 index 00000000..e7c1d620 --- /dev/null +++ b/internal/shortcut/pat/pat_cross_platform_coverage_test.go @@ -0,0 +1,353 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package pat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "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 patShortcutCaller struct{ calls int } + +func (caller *patShortcutCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return nil, nil +} +func (*patShortcutCaller) Format() string { return "json" } +func (*patShortcutCaller) DryRun() bool { return false } +func (*patShortcutCaller) Fields() string { return "" } +func (*patShortcutCaller) JQ() string { return "" } + +type patAtomicCall struct { + tool string + args map[string]any +} + +type patAtomicFixtureCaller struct { + dryRun bool + responses []string + calls []patAtomicCall +} + +func (caller *patAtomicFixtureCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + copied := make(map[string]any, len(args)) + for key, value := range args { + copied[key] = value + } + caller.calls = append(caller.calls, patAtomicCall{tool: tool, args: copied}) + response := `{"success":true,"code":"OK","data":{}}` + if index := len(caller.calls) - 1; index < len(caller.responses) { + response = caller.responses[index] + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil +} +func (*patAtomicFixtureCaller) Format() string { return "json" } +func (caller *patAtomicFixtureCaller) DryRun() bool { return caller.dryRun } +func (*patAtomicFixtureCaller) Fields() string { return "" } +func (*patAtomicFixtureCaller) JQ() string { return "" } + +func runPATShortcut(t *testing.T, declaration shortcut.Shortcut, caller *patShortcutCaller, input string, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + service := &cobra.Command{Use: "pat"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader(input)) + root.SetArgs(append([]string{"pat", declaration.Command}, args...)) + executed, err := root.ExecuteC() + if err == nil { + _, _, err = output.EmitStoredResult(executed) + } + return stdout.String(), err +} + +func runPATAtomicRoute(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().Bool("verbose", false, "") + patcore.RegisterCommands(root, caller) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"pat", "chmod"}, args...)) + + oldStdout := os.Stdout + readPipe, writePipe, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writePipe + _, runErr := root.ExecuteC() + _ = writePipe.Close() + os.Stdout = oldStdout + outputBytes, readErr := io.ReadAll(readPipe) + _ = readPipe.Close() + if readErr != nil { + t.Fatal(readErr) + } + return string(outputBytes), runErr +} + +func runPATNativeBrowserPolicy(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().Bool("verbose", false, "") + patcore.RegisterCommands(root, caller) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"pat", "browser-policy"}, args...)) + _, err := root.ExecuteC() + return stdout.String(), err +} + +func TestCrossPlatformCoveragePATAuthorizeRouteUsesAtomicPlanFallbackPendingAndLedger(t *testing.T) { + plan := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","disposition":"selected"}],"selectedScopes":["synthetic.item:read"],"skippedScopes":[],"pendingScopes":[]}}` + pending := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"pending"}],"selectedScopes":["synthetic.item:read"],"grantedScopes":[],"skippedScopes":[],"pendingScopes":["synthetic.item:read"]}}` + + preconfirm := &patAtomicFixtureCaller{responses: []string{plan, pending}} + if _, err := runPATAtomicRoute(t, preconfirm, "--product", "synthetic", "--grant-type", "once", "--format", "json"); err == nil { + t.Fatal("unconfirmed atomic authorization unexpectedly succeeded") + } + if len(preconfirm.calls) != 0 { + t.Fatalf("atomic calls before confirmation = %d, want 0", len(preconfirm.calls)) + } + + dryRun := &patAtomicFixtureCaller{dryRun: true, responses: []string{plan}} + dryOutput, err := runPATAtomicRoute(t, dryRun, "--product", "synthetic", "--grant-type", "once", "--dry-run", "--format", "json") + if err != nil { + t.Fatalf("atomic plan dry-run: %v", err) + } + if len(dryRun.calls) != 1 || dryRun.calls[0].tool != "pat.batch_plan" || dryRun.calls[0].args["dryRun"] != true { + t.Fatalf("dry-run route = %#v", dryRun.calls) + } + if !strings.Contains(dryOutput, `"items"`) || !strings.Contains(dryOutput, `"selectedScopes"`) || !strings.Contains(dryOutput, `"pendingScopes"`) { + t.Fatal("dry-run did not preserve the synthetic per-scope plan ledger") + } + + confirmed := &patAtomicFixtureCaller{responses: []string{plan, pending}} + confirmedOutput, err := runPATAtomicRoute(t, confirmed, "--product", "synthetic", "--grant-type", "once", "--yes", "--format", "json") + if err != nil { + t.Fatalf("confirmed atomic route: %v", err) + } + if len(confirmed.calls) != 2 || confirmed.calls[0].tool != "pat.batch_plan" || confirmed.calls[1].tool != "pat.batch_grant" { + t.Fatalf("confirmed route = %#v", confirmed.calls) + } + if scopes, ok := confirmed.calls[1].args["scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "synthetic.item:read" { + t.Fatalf("grant scopes = %#v", confirmed.calls[1].args["scopes"]) + } + for _, key := range []string{"agentCode", "sessionId", "orgId", "uid"} { + if _, present := confirmed.calls[1].args[key]; present { + t.Fatalf("synthetic fixture unexpectedly carried identity key %s", key) + } + } + if !strings.Contains(confirmedOutput, `"items"`) || !strings.Contains(confirmedOutput, `"pendingScopes"`) || !strings.Contains(confirmedOutput, `"status":"pending"`) { + t.Fatal("confirmed route did not preserve the synthetic pending per-scope ledger") + } + + fallback := &patAtomicFixtureCaller{responses: []string{ + `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{}}`, + `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"granted"}],"grantedScopes":["synthetic.item:read"],"pendingScopes":[]}}`, + }} + if _, err := runPATAtomicRoute(t, fallback, "synthetic.item:read", "--grant-type", "once", "--yes", "--format", "json"); err != nil { + t.Fatalf("atomic fallback route: %v", err) + } + if len(fallback.calls) != 2 || fallback.calls[0].tool != "pat.batch_grant" || fallback.calls[1].tool != "pat.grant" { + t.Fatalf("fallback route = %#v", fallback.calls) + } +} + +func TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup(t *testing.T) { + proofRoot := t.TempDir() + exactDir := filepath.Join(proofRoot, "exact") + if err := os.Mkdir(exactDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", exactDir) + policyPath := filepath.Join(exactDir, "pat_policy.json") + caller := &patShortcutCaller{} + + if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true"); err == nil { + t.Fatal("unconfirmed write unexpectedly succeeded") + } + if caller.calls != 0 { + t.Fatalf("remote calls before confirmation = %d", caller.calls) + } + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Fatalf("policy file exists before confirmation: %v", err) + } + + if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--dry-run"); err != nil { + t.Fatalf("dry-run: %v", err) + } + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Fatalf("dry-run created policy file: %v", err) + } + + if _, err := runPATShortcut(t, BrowserPolicy, caller, "yes\n", "--enabled=true"); err != nil { + t.Fatalf("confirmed write: %v", err) + } + if caller.calls != 0 { + t.Fatalf("local policy Shortcut made %d remote calls", caller.calls) + } + stored, err := os.ReadFile(policyPath) + if err != nil { + t.Fatalf("read policy: %v", err) + } + if bytes.Contains(stored, []byte("agentCode")) || !bytes.Contains(stored, []byte(`"openBrowser": true`)) { + t.Fatal("default policy did not persist the expected redacted setting") + } + if err := os.Remove(policyPath); err != nil { + t.Fatalf("cleanup policy: %v", err) + } + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Fatalf("policy cleanup left residue: %v", err) + } + + rawDir := filepath.Join(proofRoot, "raw") + if err := os.Mkdir(rawDir, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", rawDir) + rawPolicyPath := filepath.Join(rawDir, "pat_policy.json") + rawCaller := &patAtomicFixtureCaller{} + rawOutput, err := runPATNativeBrowserPolicy(t, rawCaller, "--enabled=false", "--format", "json") + if err != nil { + t.Fatalf("raw browser policy write: %v", err) + } + if len(rawCaller.calls) != 0 { + t.Fatalf("raw local policy command made %d remote calls", len(rawCaller.calls)) + } + var rawReceipt patcore.BrowserPolicySelection + if err := json.Unmarshal([]byte(rawOutput), &rawReceipt); err != nil { + t.Fatalf("parse raw browser policy receipt: %v", err) + } + if rawReceipt.Scope != "default" || rawReceipt.Source != "default" || rawReceipt.OpenBrowser || rawReceipt.AgentCode != "" { + t.Fatal("raw browser policy did not return a terminal receipt for the requested target") + } + rawStored, err := os.ReadFile(rawPolicyPath) + if err != nil { + t.Fatalf("read raw policy: %v", err) + } + if bytes.Contains(rawStored, []byte("agentCode")) || !bytes.Contains(rawStored, []byte(`"openBrowser": false`)) { + t.Fatal("raw default policy did not persist the expected redacted setting") + } + if err := os.Remove(rawPolicyPath); err != nil { + t.Fatalf("cleanup raw policy: %v", err) + } + if _, err := os.Stat(rawPolicyPath); !os.IsNotExist(err) { + t.Fatalf("raw policy cleanup left residue: %v", err) + } +} + +func TestCrossPlatformCoveragePATBrowserPolicyOutputOmitsAgentIdentity(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + caller := &patShortcutCaller{} + outputText, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=false", "--agent-code", "DWS_TEST_AGENT", "--yes") + if err != nil { + t.Fatal(err) + } + if strings.Contains(outputText, "DWS_TEST_AGENT") || strings.Contains(outputText, "agentCode") || strings.Contains(outputText, "agent-code") { + t.Fatal("public PAT Shortcut output exposed agent identity") + } +} + +func TestCrossPlatformCoveragePATBrowserPolicyWriteReadbackFailures(t *testing.T) { + failure := errors.New("fixture failure") + t.Run("write failure", func(t *testing.T) { + testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() }) + testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{}, failure + }) + if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) { + t.Fatalf("write error = %v", err) + } + }) + + t.Run("read failure", func(t *testing.T) { + testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() }) + testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil + }) + testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{}, failure + }) + if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) { + t.Fatalf("read error = %v", err) + } + }) + + t.Run("mismatched readback", func(t *testing.T) { + testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() }) + testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil + }) + testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) { + return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: false}, nil + }) + if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); err == nil { + t.Fatal("mismatched readback succeeded") + } + }) +} + +func TestCrossPlatformCoveragePATInvalidAndUnavailablePathsMakeZeroRemoteCalls(t *testing.T) { + caller := &patShortcutCaller{} + if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--agent-code", "invalid value", "--yes"); err == nil { + t.Fatal("invalid agent code unexpectedly succeeded") + } + if _, err := runPATShortcut(t, Authorize, caller, "", "--yes"); err == nil { + t.Fatal("unavailable authorization Shortcut unexpectedly succeeded") + } + if caller.calls != 0 { + t.Fatalf("invalid/unavailable PAT paths made %d remote calls", caller.calls) + } +} + +func TestCrossPlatformCoveragePATShortcutContracts(t *testing.T) { + if BrowserPolicy.Hidden || BrowserPolicy.Availability == shortcut.AvailabilityUnavailable { + t.Fatal("browser policy must be public/available before semantic decoration") + } + if BrowserPolicy.OutputRollout != output.RolloutUnifiedActive || BrowserPolicy.Contract.Result == nil || BrowserPolicy.Contract.DryRun == nil || BrowserPolicy.Contract.DryRun.PreviewKind != "request" || strings.TrimSpace(BrowserPolicy.Safety.Effect) == "" { + t.Fatal("public browser policy lacks Result/Safety/DryRun/unified output") + } + if !Authorize.Hidden || Authorize.Availability != shortcut.AvailabilityUnavailable || Authorize.Contract.Interface.Availability != "unavailable" { + t.Fatal("PAT authorization Shortcut must stay hidden/unavailable") + } + if !strings.Contains(Authorize.Contract.Interface.Reason, "classified=routed") || !strings.Contains(Authorize.Contract.Interface.Reason, "blocked_fixture") { + t.Fatal("PAT authorization must separate the routed implementation from unproved live write evidence") + } +} diff --git a/internal/shortcut/public_catalog_consistency_test.go b/internal/shortcut/public_catalog_consistency_test.go index afd3a2a1..e6388848 100644 --- a/internal/shortcut/public_catalog_consistency_test.go +++ b/internal/shortcut/public_catalog_consistency_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -24,8 +25,9 @@ func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) var catalog struct { Count int `json:"count"` Results []struct { - Service string `json:"service"` - Command string `json:"command"` + Service string `json:"service"` + Command string `json:"command"` + SemanticDelta string `json:"semantic_delta"` } `json:"results"` } if err := json.Unmarshal(raw, &catalog); err != nil { @@ -50,6 +52,14 @@ func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) if _, generated := publicShortcutCatalog[key]; !generated { t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key) } + if row.Service == "contact" && (row.Command == "+search-mobile" || row.Command == "+by-mobile") { + if !strings.Contains(row.SemanticDelta, "专用手机号精确查询接口") { + t.Fatalf("public catalog result %d %q has stale mobile lookup semantics", i, key) + } + if strings.Contains(row.SemanticDelta, "关键词能力") || strings.Contains(row.SemanticDelta, "关键词数组") { + t.Fatalf("public catalog result %d %q references the retired keyword-search route", i, key) + } + } } for key := range publicShortcutCatalog { if _, documented := seen[key]; !documented { diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 1d81b103..dd44d89d 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -6,6 +6,12 @@ package shortcut // command discovery and skill generation. func generatedPublicShortcutCatalog() map[string]struct{} { return map[string]struct{}{ + "agoal\u0000+contract-fields": {}, + "agoal\u0000+obj-template-list": {}, + "agoal\u0000+report-statistics-list": {}, + "agoal\u0000+report-submit-detail": {}, + "agoal\u0000+user-rules": {}, + "aisearch\u0000+search-person": {}, "aitable\u0000+advperm-disable": {}, "aitable\u0000+advperm-enable": {}, "aitable\u0000+attachment-put": {}, @@ -33,6 +39,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "aitable\u0000+dashboard-share-get": {}, "aitable\u0000+dashboard-share-update": {}, "aitable\u0000+dashboard-update": {}, + "aitable\u0000+datasource-create": {}, + "aitable\u0000+datasource-get-config": {}, + "aitable\u0000+datasource-get-fields": {}, + "aitable\u0000+datasource-list-sources": {}, + "aitable\u0000+datasource-sync": {}, + "aitable\u0000+datasource-sync-status": {}, + "aitable\u0000+datasource-update": {}, "aitable\u0000+export-data": {}, "aitable\u0000+field-delete": {}, "aitable\u0000+field-get": {}, @@ -237,7 +250,6 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "contact\u0000+list-dept-members": {}, "contact\u0000+list-followings": {}, "contact\u0000+list-role-members": {}, - "contact\u0000+list-roles": {}, "contact\u0000+list-sub-depts": {}, "contact\u0000+lookup": {}, "contact\u0000+me": {}, @@ -247,19 +259,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "contact\u0000+search-user": {}, "contact\u0000+team": {}, "devapp\u0000+create": {}, + "devapp\u0000+credentials-get": {}, "devapp\u0000+delete": {}, "devapp\u0000+disable": {}, "devapp\u0000+enable": {}, "devapp\u0000+event-list": {}, + "devapp\u0000+event-subscribe": {}, "devapp\u0000+get": {}, "devapp\u0000+list": {}, "devapp\u0000+member-add": {}, "devapp\u0000+member-list": {}, "devapp\u0000+member-remove": {}, "devapp\u0000+permission-list": {}, + "devapp\u0000+robot-config": {}, + "devapp\u0000+robot-disable": {}, + "devapp\u0000+robot-enable": {}, "devapp\u0000+robot-get": {}, "devapp\u0000+update": {}, "devapp\u0000+version-check-approval": {}, + "devapp\u0000+version-create": {}, "devapp\u0000+version-get": {}, "devapp\u0000+version-list": {}, "devapp\u0000+version-status": {}, @@ -375,6 +393,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, "oa\u0000+search-forms": {}, + "pat\u0000+browser-policy": {}, "report\u0000+inbox-list": {}, "report\u0000+outbox-list": {}, "report\u0000+report-latest": {}, @@ -402,6 +421,8 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "todo\u0000+search": {}, "todo\u0000+todo-done": {}, "todo\u0000+update": {}, + "whiteboard\u0000+query": {}, + "whiteboard\u0000+update": {}, "wiki\u0000+delete-space": {}, "wiki\u0000+feed-list": {}, "wiki\u0000+member-add": {}, diff --git a/internal/shortcut/register.go b/internal/shortcut/register.go index cab1c429..7857a8dd 100644 --- a/internal/shortcut/register.go +++ b/internal/shortcut/register.go @@ -16,6 +16,7 @@ package shortcut import ( "sort" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "github.com/spf13/cobra" ) @@ -68,10 +69,7 @@ func build(shortcuts []Shortcut) []*cobra.Command { for _, s := range shortcuts { parent, ok := byService[s.Service] if !ok { - parent = &cobra.Command{ - Use: s.Service, - Short: s.Service + " shortcuts", - } + parent = cobracmd.NewGroupCommand(s.Service, s.Service+" shortcuts") byService[s.Service] = parent order = append(order, s.Service) } diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index f265ce60..6e50f376 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -336,6 +336,23 @@ func (rt *RuntimeContext) Output(payload any) error { return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) } +// OutputForTool stores a once-fetched MCP payload while retaining the tool name +// for product-specific outcome normalization. It is intended for composite +// shortcuts that must validate stable identity or collection shape before the +// shared renderer sees the response; callers must not use it to issue a second +// business request. +func (rt *RuntimeContext) OutputForTool(tool string, payload any) error { + if output.UsesUnifiedResult(rt.cmd) { + return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload)) + } + if output.CommandRollout(rt.cmd) == output.RolloutDualValidate { + if err := validateShadowResult(rt.resultForPayload(tool, payload)); err != nil { + return err + } + } + return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) +} + func (rt *RuntimeContext) storePayload(tool string, payload any) error { return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload)) } diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index bb83d228..9d2ce47b 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -14,6 +14,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -231,3 +232,53 @@ func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) { t.Fatalf("devapp result=%s", got.Outcome()) } } + +func TestCrossPlatformCoverageRuntimeOutputForToolRollouts(t *testing.T) { + t.Run("unified", func(t *testing.T) { + ctx, _ := output.WithResultStore(context.Background()) + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(ctx) + output.SetCommandRollout(cmd, output.RolloutUnifiedActive) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dual validate", func(t *testing.T) { + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + output.SetCommandRollout(cmd, output.RolloutDualValidate) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dual validate failure", func(t *testing.T) { + testseam.Swap(t, &validateShadowResult, func(output.CommandResult) error { return context.Canceled }) + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + output.SetCommandRollout(cmd, output.RolloutDualValidate) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true}); !errors.Is(err, context.Canceled) { + t.Fatalf("dual validation error=%v", err) + } + }) + + t.Run("legacy", func(t *testing.T) { + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + output.SetCommandRollout(cmd, output.RolloutLegacyOnly) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get", map[string]any{"id": "item"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Fatalf("legacy output=%q", stdout.String()) + } + }) +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 60aa42c6..4713ad3f 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -40,6 +40,15 @@ var attendanceSemanticCatalogJSON []byte //go:embed semantic_catalog_mail.json var mailSemanticCatalogJSON []byte +//go:embed semantic_catalog_aisearch.json +var aisearchSemanticCatalogJSON []byte + +//go:embed semantic_catalog_contact.json +var contactSemanticCatalogJSON []byte + +//go:embed semantic_catalog_live.json +var liveSemanticCatalogJSON []byte + //go:embed semantic_catalog_oa.json var oaSemanticCatalogJSON []byte @@ -49,6 +58,27 @@ var dingSemanticCatalogJSON []byte //go:embed semantic_catalog_report.json var reportSemanticCatalogJSON []byte +//go:embed semantic_catalog_sheet.json +var sheetSemanticCatalogJSON []byte + +//go:embed semantic_catalog_whiteboard.json +var whiteboardSemanticCatalogJSON []byte + +//go:embed semantic_catalog_devdoc.json +var devdocSemanticCatalogJSON []byte + +//go:embed semantic_catalog_hrbrain.json +var hrbrainSemanticCatalogJSON []byte + +//go:embed semantic_catalog_pat.json +var patSemanticCatalogJSON []byte + +//go:embed semantic_catalog_devapp.json +var devAppSemanticCatalogJSON []byte + +//go:embed semantic_catalog_agoal.json +var agoalSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -78,9 +108,19 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( todoSemanticCatalogJSON, attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, + aisearchSemanticCatalogJSON, + contactSemanticCatalogJSON, + liveSemanticCatalogJSON, oaSemanticCatalogJSON, dingSemanticCatalogJSON, reportSemanticCatalogJSON, + sheetSemanticCatalogJSON, + whiteboardSemanticCatalogJSON, + devdocSemanticCatalogJSON, + hrbrainSemanticCatalogJSON, + patSemanticCatalogJSON, + devAppSemanticCatalogJSON, + agoalSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_agoal.json b/internal/shortcut/semantic_catalog_agoal.json new file mode 100644 index 00000000..9db46f1f --- /dev/null +++ b/internal/shortcut/semantic_catalog_agoal.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "agoal", + "default_availability": "available", + "shortcuts": { + "+report-statistics-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。","risk":"read","public":true,"reviewed":true}, + "+obj-template-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。","risk":"read","public":true,"reviewed":true}, + "+strategy-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+strategy-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+strategy-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+contract-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contract-fields": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+contract-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contract-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-get": {"disposition":"schema_leaf","semantic_delta":"可访问部门与多个周期的 exact+raw 均返回无 success 的 null。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-entity-get": {"disposition":"schema_leaf","semantic_delta":"不存在双 ID 的 raw 响应为无 success 的 null,缺少安全实体 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少计分卡旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+user-rules": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+user-objectives": {"disposition":"schema_leaf","semantic_delta":"当前用户全部可见规则/周期的 exact+raw 均只为空,缺少非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+report-submit-detail": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。","risk":"read","public":true,"reviewed":true}, + "+obj-template-upsert": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;无删除或版本恢复,无法验证 upsert 终态与零残留。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_aisearch.json b/internal/shortcut/semantic_catalog_aisearch.json new file mode 100644 index 00000000..82a7f9a7 --- /dev/null +++ b/internal/shortcut/semantic_catalog_aisearch.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "service": "aisearch", + "default_availability": "available", + "shortcuts": { + "+search-person": { + "disposition": "semantic_adapter", + "semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+search-enterprise": { + "disposition": "semantic_adapter", + "semantic_delta": "IM 搜索可返回稳定会话身份,但 fresh 随机查询仍返回业务对象,无法证明严格相关性或合法零结果;保持 unavailable,其他内容类型继续路由到原子命令。", + "risk": "read", + "availability": "unavailable", + "public": false, + "reviewed": true + }, + "+search-behavior": { + "disposition": "semantic_adapter", + "semantic_delta": "严格校验行为搜索响应,但随机零命中标记仍返回非空完整业务对象,当前无法证明相关性或合法空结果。", + "risk": "read", + "availability": "unavailable", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_aitable.json b/internal/shortcut/semantic_catalog_aitable.json index e4afda87..56be4968 100644 --- a/internal/shortcut/semantic_catalog_aitable.json +++ b/internal/shortcut/semantic_catalog_aitable.json @@ -108,6 +108,14 @@ "+list-tables": {"disposition":"semantic_adapter","semantic_delta":"把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。","risk":"read","public":true,"reviewed":true}, "+record-share-links": {"disposition":"primary_smart","semantic_delta":"对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。","risk":"read","public":true,"reviewed":true}, "+resolve-base": {"disposition":"primary_smart","semantic_delta":"按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。","risk":"read","public":true,"reviewed":true}, - "+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true} + "+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true}, + + "+datasource-create": {"disposition":"schema_leaf","semantic_delta":"为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。","risk":"write","public":true,"reviewed":true}, + "+datasource-update": {"disposition":"schema_leaf","semantic_delta":"更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。","risk":"write","public":true,"reviewed":true}, + "+datasource-sync": {"disposition":"schema_leaf","semantic_delta":"对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。","risk":"write","public":true,"reviewed":true}, + "+datasource-sync-status": {"disposition":"schema_leaf","semantic_delta":"批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。","risk":"read","public":true,"reviewed":true}, + "+datasource-get-config": {"disposition":"schema_leaf","semantic_delta":"读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+datasource-list-sources": {"disposition":"schema_leaf","semantic_delta":"列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。","risk":"read","public":true,"reviewed":true}, + "+datasource-get-fields": {"disposition":"schema_leaf","semantic_delta":"获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。","risk":"read","public":true,"reviewed":true} } } diff --git a/internal/shortcut/semantic_catalog_contact.json b/internal/shortcut/semantic_catalog_contact.json new file mode 100644 index 00000000..2a756a18 --- /dev/null +++ b/internal/shortcut/semantic_catalog_contact.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "contact", + "default_availability": "available", + "shortcuts": { + "+list-followings": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-user": {"disposition":"semantic_adapter","semantic_delta":"按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true}, + "+search-mobile": {"disposition":"semantic_adapter","semantic_delta":"使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。","risk":"read","public":true,"reviewed":true}, + "+list-roles": {"disposition":"semantic_adapter","semantic_delta":"历史 CLI 继续执行严格 get_org_labels 适配:校验角色分组、每个角色元素、正整数 labelId、非空名称与唯一身份,并传播真实下游错误;当前 exact raw 仍含坏元素,整批拒绝,因此保持 Agent/public unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-role-members": {"disposition":"semantic_adapter","semantic_delta":"按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true}, + "+list-sub-depts": {"disposition":"semantic_adapter","semantic_delta":"按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true}, + "+list-dept-members": {"disposition":"semantic_adapter","semantic_delta":"按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true}, + "+list-roster-fields": {"disposition":"semantic_adapter","semantic_delta":"exact raw 在当前授权/平台层被阻断,未获得可校验的业务字段集合;Agent/public 保持 unavailable,历史 CLI 继续透传原 MCP 调用与真实错误。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-roster": {"disposition":"semantic_adapter","semantic_delta":"exact raw 使用随机员工夹具仍在当前授权/平台层被阻断,无法安全获得业务详情合同;Agent/public 保持 unavailable,历史 CLI 继续透传原 MCP 调用与真实错误。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+by-mobile": {"disposition":"primary_smart","semantic_delta":"使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。","risk":"read","public":true,"reviewed":true}, + "+dept-members": {"disposition":"primary_smart","semantic_delta":"按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。","risk":"read","public":true,"reviewed":true}, + "+lookup": {"disposition":"primary_smart","semantic_delta":"按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。","risk":"read","public":true,"reviewed":true}, + "+org": {"disposition":"primary_smart","semantic_delta":"按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。","risk":"read","public":true,"reviewed":true}, + "+resolve-dept": {"disposition":"primary_smart","semantic_delta":"按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。","risk":"read","public":true,"reviewed":true}, + "+team": {"disposition":"primary_smart","semantic_delta":"按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。","risk":"read","public":true,"reviewed":true}, + "+me": {"disposition":"primary_smart","semantic_delta":"读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。","risk":"read","public":true,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_devapp.json b/internal/shortcut/semantic_catalog_devapp.json new file mode 100644 index 00000000..886d6ab2 --- /dev/null +++ b/internal/shortcut/semantic_catalog_devapp.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "service": "devapp", + "default_availability": "available", + "shortcuts": { + "+list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。","risk":"read","public":true,"reviewed":true}, + "+get": {"disposition":"semantic_adapter","semantic_delta":"拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+create": {"disposition":"semantic_adapter","semantic_delta":"创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。","risk":"write","public":true,"reviewed":true}, + "+update": {"disposition":"semantic_adapter","semantic_delta":"要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。","risk":"write","public":true,"reviewed":true}, + "+delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。","risk":"high-risk-write","public":true,"reviewed":true}, + "+enable": {"disposition":"semantic_adapter","semantic_delta":"启用后按同一 unifiedAppId 读回并要求 appStatus=normal。","risk":"write","public":true,"reviewed":true}, + "+disable": {"disposition":"semantic_adapter","semantic_delta":"停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。","risk":"high-risk-write","public":true,"reviewed":true}, + "+credentials-get": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。","risk":"read","public":true,"reviewed":true}, + "+webapp-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+webapp-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。","risk":"write","public":true,"reviewed":true}, + "+permission-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。","risk":"read","public":true,"reviewed":true}, + "+permission-add": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+permission-remove": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+member-add": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。","risk":"write","public":true,"reviewed":true}, + "+member-remove": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。","risk":"high-risk-write","public":true,"reviewed":true}, + "+security-config": {"disposition":"schema_leaf","semantic_delta":"整组覆盖写入缺少读取旧值、精确写回和恢复原配置的安全闭环,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+robot-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+robot-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。","risk":"write","public":true,"reviewed":true}, + "+robot-enable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。","risk":"write","public":true,"reviewed":true}, + "+robot-disable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。","risk":"high-risk-write","public":true,"reviewed":true}, + "+event-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。","risk":"read","public":true,"reviewed":true}, + "+event-subscribe": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。","risk":"write","public":true,"reviewed":true}, + "+event-unsubscribe": {"disposition":"schema_leaf","semantic_delta":"缺少可恢复的稳定订阅 fixture 与逐项退订读回,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+version-create": {"disposition":"semantic_adapter","semantic_delta":"创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。","risk":"write","public":true,"reviewed":true}, + "+version-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。","risk":"read","public":true,"reviewed":true}, + "+version-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+version-check-approval": {"disposition":"semantic_adapter","semantic_delta":"只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。","risk":"read","public":true,"reviewed":true}, + "+version-publish": {"disposition":"schema_leaf","semantic_delta":"可能进入审批或线上生效,缺少安全测试租户与回滚合同,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+version-status": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_devdoc.json b/internal/shortcut/semantic_catalog_devdoc.json new file mode 100644 index 00000000..35765f80 --- /dev/null +++ b/internal/shortcut/semantic_catalog_devdoc.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "service": "devdoc", + "default_availability": "unavailable", + "shortcuts": { + "+search-docs": { + "disposition": "schema_leaf", + "semantic_delta": "严格验证 success、result.items、文档 URL 身份和分页一致性;但 exact 与 raw 对运行时随机 zero-match 候选都返回语义近邻,无法满足 public list/search 的保证零命中证明,因此保持 unavailable。", + "risk": "read", + "availability": "unavailable", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_hrbrain.json b/internal/shortcut/semantic_catalog_hrbrain.json new file mode 100644 index 00000000..64fc52a2 --- /dev/null +++ b/internal/shortcut/semantic_catalog_hrbrain.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "service": "hrbrain", + "default_availability": "unavailable", + "shortcuts": { + "+list-pools": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,无法证明列表成功或合法空。","risk":"read","public":false,"reviewed":true}, + "+get-pool": {"disposition":"schema_leaf","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空 pool fixture,不能验证同 ID 详情。","risk":"read","public":false,"reviewed":true}, + "+list-pool-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空人才池与员工 fixture,分页合同未获 live 证明。","risk":"read","public":false,"reviewed":true}, + "+profile-metadata": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案元数据能力仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+query-profile": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案查询业务链仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+profile-labels": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,不能把未知结果投成空标签。","risk":"read","public":false,"reviewed":true}, + "+profile-career": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,职业历程读取仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+profile-performance": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,且敏感绩效数据缺少受控 live 证明。","risk":"read","public":false,"reviewed":true}, + "+search-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,无法完成已知非空与保证零命中证明。","risk":"read","public":false,"reviewed":true}, + "+search-employees-structured": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,字段发现与结构化搜索链仍未解锁。","risk":"read","public":false,"reviewed":true}, + "+search-fields": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;无资源输入的 exact/raw 仍返回同一远端错误,字段发现能力未解锁。","risk":"read","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_live.json b/internal/shortcut/semantic_catalog_live.json new file mode 100644 index 00000000..a3491993 --- /dev/null +++ b/internal/shortcut/semantic_catalog_live.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "service": "live", + "default_availability": "available", + "shortcuts": { + "+list-my-lives": { + "disposition": "semantic_adapter", + "semantic_delta": "严格校验 success、result 对象、total、hasFinish、liveDetailModelList、坏元素、稳定且不重复的直播 ID;live MCP 元数据没有筛选或分页参数,有界 exact raw 仍出现 total>0、hasFinish=true 与空数组冲突,保持不可用。", + "risk": "read", + "availability": "unavailable", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_pat.json b/internal/shortcut/semantic_catalog_pat.json new file mode 100644 index 00000000..8cab68d5 --- /dev/null +++ b/internal/shortcut/semantic_catalog_pat.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "pat", + "default_availability": "unavailable", + "shortcuts": { + "+browser-policy": { + "disposition": "primary_smart", + "semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。", + "risk": "write", + "availability": "available", + "public": true, + "reviewed": true + }, + "+authorize": { + "disposition": "alias_internal", + "semantic_delta": "classified=routed;pat chmod 已持有 plan、fallback、pending、逐 scope ledger、身份校验与确认链,+authorize 不复制执行语义;真实授权写仍因 blocked_fixture 未获证明。", + "risk": "high-risk-write", + "primary": "pat chmod", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_sheet.json b/internal/shortcut/semantic_catalog_sheet.json new file mode 100644 index 00000000..3107cb70 --- /dev/null +++ b/internal/shortcut/semantic_catalog_sheet.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "service": "sheet", + "default_availability": "available", + "shortcuts": { + "+list-sheets": { + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+read": { + "disposition": "semantic_adapter", + "semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。", + "risk": "read", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_whiteboard.json b/internal/shortcut/semantic_catalog_whiteboard.json new file mode 100644 index 00000000..2cc8a086 --- /dev/null +++ b/internal/shortcut/semantic_catalog_whiteboard.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "whiteboard", + "default_availability": "available", + "shortcuts": { + "+query": { + "disposition": "semantic_adapter", + "semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+update": { + "disposition": "primary_smart", + "semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。", + "risk": "high-risk-write", + "availability": "available", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/sheet/sheet.go b/internal/shortcut/sheet/sheet.go index 8f67cbc0..52b33818 100644 --- a/internal/shortcut/sheet/sheet.go +++ b/internal/shortcut/sheet/sheet.go @@ -17,9 +17,17 @@ package sheet import ( + "encoding/json" + "fmt" + "math" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) // ── workbook & worksheet ────────────────────────────────────────────── @@ -27,12 +35,13 @@ import ( // Create creates a new DingTalk online spreadsheet document. // ListSheets lists all worksheets in a spreadsheet document. var ListSheets = shortcut.Shortcut{ - Service: "sheet", - Command: "+list-sheets", - Product: "sheet", - Description: "获取表格文档中全部工作表列表", - Intent: "当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。", - Risk: shortcut.RiskRead, + OutputRollout: output.RolloutUnifiedActive, + Service: "sheet", + Command: "+list-sheets", + Product: "sheet", + Description: "严格列出在线电子表格的工作表,并可按完整标题精确筛选", + Intent: "需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时", + Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", @@ -45,107 +54,95 @@ var ListSheets = shortcut.Shortcut{ CLIPath: "sheet +list-sheets", PrimaryCLIPath: "sheet +list-sheets", }, - Description: "获取表格文档中全部工作表列表", + Description: "严格列出在线电子表格的工作表", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "获取表格文档中全部工作表列表", - UseWhen: []string{"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AgentSummary: "严格列出在线电子表格的工作表,并可按完整标题精确筛选", + UseWhen: []string{"需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时"}, + AvoidWhen: []string{"需要管理 AITable/Base 的数据表或记录时改用 aitable;需要原始 get_all_sheets 响应时改用 sheet list"}, Examples: []string{"dws sheet +list-sheets --node NODE_ID"}, }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "node"}, + {Name: "title", Property: "title"}, + }, + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"经过严格校验的工作表列表","properties":{"count":{"type":"integer","description":"精确筛选后的工作表数量"},"sheets":{"type":"array","description":"工作表条目;显式空数组表示合法零命中","items":{"type":"object","description":"带稳定身份的工作表","properties":{"sheetId":{"type":"string","description":"稳定工作表 ID"},"title":{"type":"string","description":"工作表完整标题"},"index":{"type":"integer","description":"可选工作表顺序"},"visibility":{"description":"可选工作表可见性"},"rowCount":{"type":"integer","description":"可选行数"},"columnCount":{"type":"integer","description":"可选列数"}},"required":["sheetId","title"],"additionalProperties":false}}},"required":["count","sheets"],"additionalProperties":false}`), + SensitivePaths: []string{"sheets.sheetId", "sheets.title"}, + }, }, Flags: []shortcut.Flag{ - {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true}, + {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "按完整工作表标题精确筛选(区分大小写);显式传入时去除空白后不能为空"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"title"}, Description: "--title 显式传入时去除空白后不能为空"}, }, Tips: []string{`dws sheet +list-sheets --node NODE_ID`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := validateSheetNode(rt); err != nil { + return err + } + return validateOptionalSheetString(rt, "title") + }, Execute: func(rt *shortcut.RuntimeContext) error { data, err := rt.CallMCPData("sheet", "get_all_sheets", map[string]any{"nodeId": rt.Str("node")}) if err != nil { return err } - sheets := listSheetsProject(data) + sheets, err := projectSheetList(data, rt.Str("title")) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(sheets), "sheets": sheets}) }, } -// listSheetsProject reshapes the raw get_all_sheets response into a clean -// worksheet list (sheetId/title/index/visibility/rowCount/columnCount) — the -// the clean output projection applied to every list command. 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 listSheetsProject(data map[string]any) []map[string]any { - raw := listSheetsResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { +func projectSheetList(data map[string]any, exactTitle string) ([]map[string]any, error) { + items, err := responsecheck.RequireObjectCollection(data, "sheet/get_all_sheets", "sheets") + if err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(items)) + out := make([]map[string]any, 0, len(items)) + for index, item := range items { + sheetID, err := requireSheetString(item, index, "sheetId") + if err != nil { + return nil, err + } + if _, duplicate := seen[sheetID]; duplicate { + return nil, responsecheck.Error("sheet/get_all_sheets", "duplicate_sheet_id", fmt.Sprintf("响应 sheets[%d] 的 sheetId 重复", index)) + } + seen[sheetID] = struct{}{} + title, err := requireSheetStringAliases(item, index, "name", "title") + if err != nil { + return nil, err + } + if exactTitle != "" && title != exactTitle { continue } - row := map[string]any{} - if v, ok := listSheetsFirst(m, "sheetId", "sheet_id", "id"); ok { - row["sheetId"] = v + row := map[string]any{"sheetId": sheetID, "title": title} + if err := copySheetOptionalNumber(row, item, index, "index", "index", "sheetIndex"); err != nil { + return nil, err } - if v, ok := listSheetsFirst(m, "title", "name", "sheetName", "sheet_name"); ok { - row["title"] = v + if err := copySheetOptionalVisibility(row, item, index, "visibility", "visibility", "hidden", "visible"); err != nil { + return nil, err } - if v, ok := listSheetsFirst(m, "index", "sheetIndex"); ok { - row["index"] = v + if err := copySheetOptionalNumber(row, item, index, "rowCount", "rowCount", "row_count", "rows"); err != nil { + return nil, err } - if v, ok := listSheetsFirst(m, "visibility", "hidden", "visible"); ok { - row["visibility"] = v - } - if v, ok := listSheetsFirst(m, "rowCount", "row_count", "rows"); ok { - row["rowCount"] = v - } - if v, ok := listSheetsFirst(m, "columnCount", "column_count", "columns", "colCount"); ok { - row["columnCount"] = v - } - if len(row) > 0 { - out = append(out, row) + if err := copySheetOptionalNumber(row, item, index, "columnCount", "columnCount", "column_count", "columns", "colCount"); err != nil { + return nil, err } + out = append(out, row) } - return out -} - -// listSheetsResolveList locates the worksheet array inside the response, -// tolerating a bare top-level array container or nesting one level deeper under -// a common envelope key. -func listSheetsResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"sheets", "result", "data", "list", "items", "worksheets"} { - 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{"sheets", "list", "items", "worksheets", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// listSheetsFirst returns the first present candidate key's value. -func listSheetsFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false + return out, nil } // SheetInfo returns the detail of a single worksheet. @@ -157,12 +154,13 @@ func listSheetsFirst(m map[string]any, keys ...string) (any, bool) { // Read reads structured per-cell data from a worksheet range. var Read = shortcut.Shortcut{ - Service: "sheet", - Command: "+read", - Product: "sheet", - Description: "读取工作表指定范围的结构化单元格数据", - Intent: "当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。", - Risk: shortcut.RiskRead, + OutputRollout: output.RolloutUnifiedActive, + Service: "sheet", + Command: "+read", + Product: "sheet", + Description: "完整读取并严格校验在线电子表格范围;截断结果失败关闭", + Intent: "需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时", + Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", @@ -175,26 +173,57 @@ var Read = shortcut.Shortcut{ CLIPath: "sheet +read", PrimaryCLIPath: "sheet +read", }, - Description: "读取工作表指定范围的结构化单元格数据", + Description: "完整读取并严格校验工作表范围", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "读取工作表指定范围的结构化单元格数据", - UseWhen: []string{"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AgentSummary: "完整读取并严格校验在线电子表格范围;截断结果失败关闭", + UseWhen: []string{"需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时"}, + AvoidWhen: []string{"需要纯 CSV 时用 sheet csv-get;需要 AITable/Base 结构化记录时用 aitable;超大范围应拆小后重试,不能把截断结果当完整成功"}, Examples: []string{"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""}, }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "node"}, + {Name: "sheet-id", Property: "sheetId"}, + {Name: "range", Property: "range"}, + {Name: "value-render-option", Property: "valueRenderOption"}, + }, + DryRun: &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewRequest, + RemoteReads: false, + }, + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"完整且未截断的单元格范围","properties":{"cells":{"type":"array","description":"与行列坐标对齐的二维单元格对象数组","items":{"type":"array","description":"一行单元格对象","items":{"type":"object","description":"单元格结构化数据","additionalProperties":true}}},"colIndices":{"type":"array","description":"与 cells 列对齐的 A1 列标","items":{"type":"string"}},"rowIndices":{"type":"array","description":"与 cells 行对齐的一基行号","items":{"type":"integer"}},"complete":{"type":"boolean","description":"结果已证明完整;成功结果恒为 true"},"hasMore":{"type":"boolean","description":"服务端截断标记;成功结果恒为 false"},"truncationReasons":{"type":"array","description":"截断原因;成功结果恒为空数组","items":{"type":"string"}},"resolvedRange":{"type":"string","description":"服务解析后的请求范围"},"returnedRange":{"type":"string","description":"服务实际返回的范围"},"message":{"type":"string","description":"可选服务说明"}},"required":["cells","colIndices","rowIndices","complete","hasMore","truncationReasons"],"additionalProperties":false}`), + SensitivePaths: []string{"cells"}, + }, }, Flags: []shortcut.Flag{ - {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true}, - {Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表)"}, - {Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据)"}, + {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表);显式传入时去除空白后不能为空"}, + {Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据);显式传入时去除空白后不能为空"}, {Name: "value-render-option", Type: shortcut.FlagString, Desc: "取值模式", Enum: []string{"formatted_value", "raw_value", "formula"}}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"sheet-id"}, Description: "--sheet-id 显式传入时去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"range"}, Description: "--range 显式传入时去除空白后不能为空"}, + }, Tips: []string{`dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range "A1:D10"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := validateSheetNode(rt); err != nil { + return err + } + for _, name := range []string{"sheet-id", "range"} { + if err := validateOptionalSheetString(rt, name); err != nil { + return err + } + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{"nodeId": rt.Str("node")} if rt.Changed("sheet-id") { @@ -206,10 +235,204 @@ var Read = shortcut.Shortcut{ if rt.Changed("value-render-option") { params["valueRenderOption"] = rt.Str("value-render-option") } - return rt.CallMCP("get_cell_infos", params) + if rt.DryRun() { + return rt.CallMCP("get_cell_infos", params) + } + data, err := rt.CallMCPData("sheet", "get_cell_infos", params) + if err != nil { + return err + } + projected, err := projectSheetRead(data) + if err != nil { + return err + } + return rt.Output(projected) }, } +func validateSheetNode(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("node")) == "" { + return apperrors.NewValidation("--node 去除空白后不能为空") + } + return nil +} + +func validateOptionalSheetString(rt *shortcut.RuntimeContext, name string) error { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 显式传入时去除空白后不能为空", name)) + } + return nil +} + +func requireSheetString(item map[string]any, index int, key string) (string, error) { + value, present := item[key] + if !present { + return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_identity", fmt.Sprintf("响应 sheets[%d] 缺少 %s", index, key)) + } + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return "", responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_identity", fmt.Sprintf("响应 sheets[%d].%s 必须是非空字符串", index, key)) + } + return strings.TrimSpace(text), nil +} + +func requireSheetStringAliases(item map[string]any, index int, keys ...string) (string, error) { + for _, key := range keys { + if _, present := item[key]; present { + return requireSheetString(item, index, key) + } + } + return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_title", fmt.Sprintf("响应 sheets[%d] 缺少工作表标题", index)) +} + +func copySheetOptionalNumber(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error { + for _, key := range sourceKeys { + if value, present := source[key]; present { + number, ok := value.(float64) + if !ok || number < 0 || math.Trunc(number) != number { + return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是非负整数", itemIndex, key)) + } + target[targetKey] = value + return nil + } + } + return nil +} + +func copySheetOptionalVisibility(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error { + for _, key := range sourceKeys { + value, present := source[key] + if !present { + continue + } + switch typed := value.(type) { + case bool: + target[targetKey] = typed + case string: + if strings.TrimSpace(typed) == "" { + return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 不能为空字符串", itemIndex, key)) + } + target[targetKey] = strings.TrimSpace(typed) + default: + return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是布尔值或字符串", itemIndex, key)) + } + return nil + } + return nil +} + +func projectSheetRead(data map[string]any) (map[string]any, error) { + envelope, err := responsecheck.RequireSuccess(data, "sheet/get_cell_infos") + if err != nil { + return nil, err + } + cellsRaw, err := requireSheetArray(envelope, "cells") + if err != nil { + return nil, err + } + columnsRaw, err := requireSheetArray(envelope, "colIndices") + if err != nil { + return nil, err + } + rowsRaw, err := requireSheetArray(envelope, "rowIndices") + if err != nil { + return nil, err + } + columns := make([]any, len(columnsRaw)) + for index, value := range columnsRaw { + column, ok := value.(string) + if !ok || strings.TrimSpace(column) == "" { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_column_index", fmt.Sprintf("响应 colIndices[%d] 必须是非空字符串", index)) + } + columns[index] = strings.TrimSpace(column) + } + rows := make([]any, len(rowsRaw)) + for index, value := range rowsRaw { + number, ok := value.(float64) + if !ok || number < 1 || math.Trunc(number) != number { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_row_index", fmt.Sprintf("响应 rowIndices[%d] 必须是正整数", index)) + } + rows[index] = int(number) + } + if len(cellsRaw) != len(rows) { + return nil, responsecheck.Error("sheet/get_cell_infos", "row_count_mismatch", "响应 cells 行数与 rowIndices 数量不一致") + } + cells := make([]any, len(cellsRaw)) + for rowIndex, rowValue := range cellsRaw { + row, ok := rowValue.([]any) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell_row", fmt.Sprintf("响应 cells[%d] 必须是数组", rowIndex)) + } + if len(row) != len(columns) { + return nil, responsecheck.Error("sheet/get_cell_infos", "column_count_mismatch", fmt.Sprintf("响应 cells[%d] 列数与 colIndices 数量不一致", rowIndex)) + } + clean := make([]any, len(row)) + for columnIndex, cellValue := range row { + cell, ok := cellValue.(map[string]any) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell", fmt.Sprintf("响应 cells[%d][%d] 必须是对象", rowIndex, columnIndex)) + } + clean[columnIndex] = cell + } + cells[rowIndex] = clean + } + hasMoreValue, present := envelope["hasMore"] + if !present { + return nil, responsecheck.Error("sheet/get_cell_infos", "missing_completion_evidence", "响应缺少 hasMore,无法证明读取完整") + } + hasMore, ok := hasMoreValue.(bool) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_completion_evidence", "响应 hasMore 必须是布尔值") + } + reasonsRaw, err := requireSheetArray(envelope, "truncationReasons") + if err != nil { + return nil, err + } + reasons := make([]any, len(reasonsRaw)) + for index, value := range reasonsRaw { + reason, ok := value.(string) + if !ok || strings.TrimSpace(reason) == "" { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_truncation_reason", fmt.Sprintf("响应 truncationReasons[%d] 必须是非空字符串", index)) + } + reasons[index] = strings.TrimSpace(reason) + } + if hasMore { + if len(reasons) == 0 { + return nil, responsecheck.Error("sheet/get_cell_infos", "missing_truncation_reason", "响应 hasMore=true 但缺少截断原因") + } + return nil, responsecheck.Error("sheet/get_cell_infos", "incomplete_result", "工作表读取结果被服务端截断;请缩小 --range 后重试,不能把部分数据当作完整成功") + } + if len(reasons) != 0 { + return nil, responsecheck.Error("sheet/get_cell_infos", "conflicting_completion_evidence", "响应 hasMore=false 但仍声明截断原因") + } + out := map[string]any{ + "cells": cells, "colIndices": columns, "rowIndices": rows, + "complete": true, "hasMore": false, "truncationReasons": reasons, + } + for _, key := range []string{"resolvedRange", "returnedRange", "message"} { + if value, present := envelope[key]; present { + text, ok := value.(string) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_optional_field", fmt.Sprintf("响应 %s 必须是字符串", key)) + } + out[key] = text + } + } + return out, nil +} + +func requireSheetArray(object map[string]any, key string) ([]any, error) { + value, present := object[key] + if !present { + return nil, responsecheck.Error("sheet/get_cell_infos", "missing_"+key, fmt.Sprintf("响应缺少 %s 数组", key)) + } + array, ok := value.([]any) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_"+key, fmt.Sprintf("响应 %s 必须是数组", key)) + } + return array, nil +} + // Write updates a worksheet range with a 2D JSON array of cell objects. // Append appends rows to the end of a worksheet. // CSVGet reads worksheet data as RFC 4180 CSV. diff --git a/internal/shortcut/sheet/sheet_test.go b/internal/shortcut/sheet/sheet_test.go new file mode 100644 index 00000000..98c92ef1 --- /dev/null +++ b/internal/shortcut/sheet/sheet_test.go @@ -0,0 +1,342 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package sheet + +import ( + "context" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type sheetCoverageCaller struct { + responses map[string][]string + failures map[string]error + history []string + arguments []map[string]any +} + +func (caller *sheetCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + if err := caller.failures[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*sheetCoverageCaller) Format() string { return "json" } +func (*sheetCoverageCaller) DryRun() bool { return false } +func (*sheetCoverageCaller) Fields() string { return "" } +func (*sheetCoverageCaller) JQ() string { return "" } + +func runSheetCoverage(t *testing.T, declaration shortcut.Shortcut, caller *sheetCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + service := &cobra.Command{Use: "sheet"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"sheet", declaration.Command}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageSheetListRejectsFalseEmptySuccess(t *testing.T) { + for name, payload := range map[string]map[string]any{ + "empty response": {}, + "missing success": {"sheets": []any{}}, + "success false": {"success": false, "errorMessage": "denied"}, + "missing collection": {"success": true}, + "wrong collection type": {"success": true, "sheets": map[string]any{}}, + "bad element": {"success": true, "sheets": []any{"bad"}}, + "empty element": {"success": true, "sheets": []any{map[string]any{}}}, + "missing stable id": {"success": true, "sheets": []any{map[string]any{"name": "Sheet1"}}}, + "wrong stable id type": {"success": true, "sheets": []any{map[string]any{"sheetId": 1, "name": "Sheet1"}}}, + "missing title": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1"}}}, + "duplicate stable id": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One"}, map[string]any{"sheetId": "s1", "name": "Two"}}}, + "wrong optional index": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "sheetIndex": -1.0}}}, + "wrong optional count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "rowCount": "many"}}}, + "wrong column count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "colCount": 1.5}}}, + "empty visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "visibility": " "}}}, + "wrong visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "hidden": 1}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := projectSheetList(payload, ""); err == nil { + t.Fatalf("payload unexpectedly accepted: %#v", payload) + } + }) + } + + payload := map[string]any{"success": true, "sheets": []any{ + map[string]any{"sheetId": "s1", "name": "Alpha"}, + map[string]any{"sheetId": "s2", "name": "Beta"}, + }} + nonempty, err := projectSheetList(payload, "Alpha") + if err != nil || len(nonempty) != 1 || nonempty[0]["sheetId"] != "s1" { + t.Fatalf("known nonempty projection = %#v, err=%v", nonempty, err) + } + empty, err := projectSheetList(payload, "guaranteed-missing") + if err != nil || len(empty) != 0 { + t.Fatalf("guaranteed empty projection = %#v, err=%v", empty, err) + } + optionalPayload := map[string]any{"success": true, "sheets": []any{ + map[string]any{ + "sheetId": "s1", "title": "Alpha", "index": float64(0), + "hidden": true, "row_count": float64(5), "colCount": float64(2), + }, + map[string]any{"sheetId": "s2", "title": "Beta", "visibility": " visible "}, + }} + optional, err := projectSheetList(optionalPayload, "") + if err != nil || len(optional) != 2 { + t.Fatalf("optional field projection=%#v err=%v", optional, err) + } + if optional[0]["index"] != float64(0) || optional[0]["visibility"] != true || optional[0]["rowCount"] != float64(5) || optional[0]["columnCount"] != float64(2) { + t.Fatalf("numeric/bool optional projection=%#v", optional[0]) + } + if optional[1]["visibility"] != "visible" { + t.Fatalf("string visibility projection=%#v", optional[1]) + } +} + +func TestCrossPlatformCoverageSheetReadRejectsMalformedAndIncompleteResults(t *testing.T) { + valid := func() map[string]any { + return map[string]any{ + "success": true, "cells": []any{[]any{map[string]any{"value": "x"}}}, + "colIndices": []any{"A"}, "rowIndices": []any{float64(1)}, + "hasMore": false, "truncationReasons": []any{}, + "resolvedRange": "A1:A1", "returnedRange": "A1:A1", + } + } + for name, mutate := range map[string]func(map[string]any){ + "missing success": func(v map[string]any) { delete(v, "success") }, + "success false": func(v map[string]any) { v["success"] = false }, + "missing cells": func(v map[string]any) { delete(v, "cells") }, + "wrong cells type": func(v map[string]any) { v["cells"] = map[string]any{} }, + "bad row": func(v map[string]any) { v["cells"] = []any{"bad"} }, + "bad cell": func(v map[string]any) { v["cells"] = []any{[]any{"bad"}} }, + "missing columns": func(v map[string]any) { delete(v, "colIndices") }, + "bad column": func(v map[string]any) { v["colIndices"] = []any{1} }, + "missing rows": func(v map[string]any) { delete(v, "rowIndices") }, + "bad row index": func(v map[string]any) { v["rowIndices"] = []any{1.5} }, + "row count mismatch": func(v map[string]any) { v["rowIndices"] = []any{} }, + "column count mismatch": func(v map[string]any) { v["cells"] = []any{[]any{}} }, + "missing hasMore": func(v map[string]any) { delete(v, "hasMore") }, + "wrong hasMore type": func(v map[string]any) { v["hasMore"] = "false" }, + "truncated": func(v map[string]any) { + v["hasMore"] = true + v["truncationReasons"] = []any{"max_cells"} + }, + "truncated without reason": func(v map[string]any) { + v["hasMore"] = true + v["truncationReasons"] = []any{} + }, + "missing truncation reasons": func(v map[string]any) { delete(v, "truncationReasons") }, + "wrong truncation reason": func(v map[string]any) { v["truncationReasons"] = []any{1} }, + "contradictory completion": func(v map[string]any) { v["truncationReasons"] = []any{"max_cells"} }, + "wrong optional field": func(v map[string]any) { v["message"] = true }, + } { + t.Run(name, func(t *testing.T) { + payload := valid() + mutate(payload) + if _, err := projectSheetRead(payload); err == nil { + t.Fatalf("payload unexpectedly accepted: %#v", payload) + } + }) + } + + projected, err := projectSheetRead(valid()) + if err != nil || projected["complete"] != true || projected["hasMore"] != false { + t.Fatalf("valid projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageSheetShortcutsValidateBeforeCallsAndUseExactTools(t *testing.T) { + noCall := &sheetCoverageCaller{responses: map[string][]string{}} + if err := runSheetCoverage(t, ListSheets, noCall); err == nil || len(noCall.history) != 0 { + t.Fatalf("missing node err=%v calls=%v", err, noCall.history) + } + for _, declaration := range []shortcut.Shortcut{ListSheets, Read} { + t.Run(declaration.Command+" blank node", func(t *testing.T) { + blank := &sheetCoverageCaller{responses: map[string][]string{}} + err := runSheetCoverage(t, declaration, blank, "--node", " ") + if err == nil || !strings.Contains(err.Error(), "--node") || len(blank.history) != 0 { + t.Fatalf("blank node err=%v calls=%v", err, blank.history) + } + }) + } + for _, declaration := range []shortcut.Shortcut{ListSheets, Read} { + blankCommand := corecmd.New(shortcut.FromShortcut(declaration)) + if err := blankCommand.Flags().Set("node", " "); err != nil { + t.Fatal(err) + } + err := declaration.Validate(shortcut.RuntimeContextForTest(blankCommand, declaration)) + if err == nil || !strings.Contains(err.Error(), "--node") { + t.Fatalf("%s direct blank node validation=%v", declaration.Command, err) + } + } + for _, test := range []struct { + declaration shortcut.Shortcut + flag string + }{ + {declaration: ListSheets, flag: "title"}, + {declaration: Read, flag: "sheet-id"}, + {declaration: Read, flag: "range"}, + } { + t.Run(test.declaration.Command+" blank "+test.flag, func(t *testing.T) { + caller := &sheetCoverageCaller{responses: map[string][]string{}} + err := runSheetCoverage(t, test.declaration, caller, "--node", "node", "--"+test.flag, " ") + if err == nil || !strings.Contains(err.Error(), "--"+test.flag) || len(caller.history) != 0 { + t.Fatalf("blank --%s err=%v calls=%v", test.flag, err, caller.history) + } + }) + } + + listCaller := &sheetCoverageCaller{responses: map[string][]string{ + "get_all_sheets": {`{"success":true,"sheets":[{"sheetId":"s1","name":"Alpha"}]}`}, + }} + if err := runSheetCoverage(t, ListSheets, listCaller, "--node", "node", "--title", "Alpha"); err != nil { + t.Fatal(err) + } + if got := strings.Join(listCaller.history, ","); got != "get_all_sheets" { + t.Fatalf("list history=%q", got) + } + + readCaller := &sheetCoverageCaller{responses: map[string][]string{ + "get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":false,"truncationReasons":[]}`}, + }} + if err := runSheetCoverage(t, Read, readCaller, + "--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value"); err != nil { + t.Fatal(err) + } + if got := strings.Join(readCaller.history, ","); got != "get_cell_infos" { + t.Fatalf("read history=%q", got) + } + if len(readCaller.arguments) != 1 || readCaller.arguments[0]["nodeId"] != "node" || readCaller.arguments[0]["sheetId"] != "sheet-1" || readCaller.arguments[0]["range"] != "A1:A1" || readCaller.arguments[0]["valueRenderOption"] != "raw_value" { + t.Fatalf("read arguments=%#v", readCaller.arguments) + } + + dryRun := &sheetCoverageCaller{responses: map[string][]string{}} + if err := runSheetCoverage(t, Read, dryRun, + "--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value", "--dry-run"); err != nil { + t.Fatalf("read dry-run: %v", err) + } + if len(dryRun.history) != 0 { + t.Fatalf("read dry-run reached remote calls=%v", dryRun.history) + } + + readFailure := &sheetCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"get_cell_infos": errors.New("fixture transport failure")}, + } + if err := runSheetCoverage(t, Read, readFailure, "--node", "node"); err == nil || len(readFailure.history) != 1 { + t.Fatalf("read transport err=%v calls=%v", err, readFailure.history) + } + + incomplete := &sheetCoverageCaller{responses: map[string][]string{ + "get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A2","returnedRange":"A1:A1"}`}, + }} + err := runSheetCoverage(t, Read, incomplete, "--node", "node") + if err == nil || !strings.Contains(err.Error(), "截断") || len(incomplete.history) != 1 { + t.Fatalf("incomplete err=%v calls=%v", err, incomplete.history) + } +} + +func TestCrossPlatformCoverageSheetRuntimeRejectsEmptyMalformedAndWrongCollection(t *testing.T) { + for name, payload := range map[string]string{ + "empty body": "", + "malformed json": "{", + "success string": `{"success":"true","sheets":[]}`, + "missing array": `{"success":true}`, + "wrong array type": `{"success":true,"sheets":{}}`, + "bad item": `{"success":true,"sheets":["bad"]}`, + } { + t.Run(name, func(t *testing.T) { + caller := &sheetCoverageCaller{responses: map[string][]string{"get_all_sheets": {payload}}} + err := runSheetCoverage(t, ListSheets, caller, "--node", "node") + if err == nil || len(caller.history) != 1 { + t.Fatalf("payload=%q err=%v calls=%v", payload, err, caller.history) + } + }) + } +} + +func TestCrossPlatformCoverageSheetPublicContractsAreReviewedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{ListSheets, Read} { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s missing Contract.Result", declaration.Command) + } + if declaration.Contract.Pagination != nil { + t.Errorf("%s publishes unsupported pagination", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Safety.Confirmation != "not_required" || declaration.Safety.Effect != "read" { + t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety) + } + } + if Read.Contract.DryRun == nil || Read.Contract.DryRun.PreviewKind != contract.DryRunPreviewRequest || Read.Contract.DryRun.RemoteReads { + t.Errorf("read dry-run contract=%+v", Read.Contract.DryRun) + } + for _, test := range []struct { + declaration shortcut.Shortcut + wantProperties map[string]string + }{ + {declaration: ListSheets, wantProperties: map[string]string{"node": "node", "title": "title"}}, + {declaration: Read, wantProperties: map[string]string{"node": "node", "sheet-id": "sheetId", "range": "range", "value-render-option": "valueRenderOption"}}, + } { + got := make(map[string]string, len(test.declaration.Contract.Parameters)) + for _, parameter := range test.declaration.Contract.Parameters { + got[parameter.Name] = parameter.Property + } + if !reflect.DeepEqual(got, test.wantProperties) { + t.Errorf("%s parameter properties=%v, want %v", test.declaration.Command, got, test.wantProperties) + } + } + for _, test := range []struct { + declaration shortcut.Shortcut + sensitive []string + constraints []string + }{ + {declaration: ListSheets, sensitive: []string{"sheets.sheetId", "sheets.title"}, constraints: []string{"node", "title"}}, + {declaration: Read, sensitive: []string{"cells"}, constraints: []string{"node", "sheet-id", "range"}}, + } { + if !reflect.DeepEqual(test.declaration.Contract.Result.SensitivePaths, test.sensitive) { + t.Errorf("%s sensitive paths=%v, want %v", test.declaration.Command, test.declaration.Contract.Result.SensitivePaths, test.sensitive) + } + gotConstraints := make([]string, 0, len(test.declaration.Constraints)) + for _, constraint := range test.declaration.Constraints { + if constraint.Kind != shortcut.ConstraintCustom || len(constraint.Flags) != 1 || !strings.Contains(constraint.Description, "--"+constraint.Flags[0]) { + t.Errorf("%s malformed custom constraint=%+v", test.declaration.Command, constraint) + continue + } + gotConstraints = append(gotConstraints, constraint.Flags[0]) + } + if !reflect.DeepEqual(gotConstraints, test.constraints) { + t.Errorf("%s custom constraint flags=%v, want %v", test.declaration.Command, gotConstraints, test.constraints) + } + } +} diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 5cbf5704..d503a308 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -472,6 +472,12 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) { if got := len(byName["calendar"].Commands()); got != 1 { t.Errorf("calendar has %d leaves, want 1", got) } + for name, command := range byName { + policy, declared, err := corecmd.GroupPolicyFor(command) + if err != nil || !declared || policy.Mode != corecmd.GroupNavigationOnly { + t.Errorf("%s shortcut service parent policy = %+v, %v, %v", name, policy, declared, err) + } + } } func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) { diff --git a/internal/shortcut/smart/by_mobile.go b/internal/shortcut/smart/by_mobile.go index c126decb..743bf2ee 100644 --- a/internal/shortcut/smart/by_mobile.go +++ b/internal/shortcut/smart/by_mobile.go @@ -14,23 +14,19 @@ package smart import ( - "fmt" - "strconv" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) // ByMobile: find a person by phone number and return their full profile in one // step. // -// Steps: look the mobile up via search_user_by_mobile → resolve to a userId -// (error clearly if nobody is bound to that number) → fetch full detail via -// get_user_info_by_user_ids. Replaces `contact user search-mobile` (copy the -// userId) → `contact user get --ids `. +// Steps: resolve the number through the dedicated exact-mobile interface → +// fetch the same stable userId's full detail. The detail endpoint does not +// expose mobile under every permission profile, so identity is bound to the +// dedicated lookup result rather than inferred from keyword search. // // dws contact +by-mobile --mobile 13800138000 var ByMobile = shortcut.Shortcut{ @@ -67,7 +63,11 @@ var ByMobile = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true}, + {Name: "mobile", Type: shortcut.FlagString, Desc: "手机号;--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"mobile"}, Description: "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateContactSmartMobile(rt, "contact/by-mobile", "mobile") }, Tips: []string{`dws contact +by-mobile --mobile 13800138000`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -76,83 +76,16 @@ var ByMobile = shortcut.Shortcut{ } mobile := rt.Str("mobile") - // Step 1 — look up the userId bound to this mobile number. - // search_user_by_mobile takes {mobile} (see helpers.contact - // search-mobile). The gateway shape is not guaranteed, so probe common - // containers/field names defensively. - data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{ - "mobile": mobile, - }) + // Step 1 — resolve the mobile through the dedicated exact interface. + _, profile, err := strictResolveContactUserByMobile(rt, mobile) if err != nil { return err } - userID := byMobileExtractUserID(data) - if userID == "" { - return apperrors.NewValidation( - fmt.Sprintf("没找到绑定手机号 %q 的用户;确认号码正确、且该人在当前组织通讯录中。", mobile)) - } - - // Step 2 — fetch and print the full profile of the resolved user. - // get_user_info_by_user_ids takes {user_id_list} (see helpers.contact - // user get). - return rt.CallMCP("get_user_info_by_user_ids", map[string]any{ - "user_id_list": []string{userID}, - }) + return rt.Output(map[string]any{"profile": profile}) }, } -// byMobileExtractUserID pulls a single userId out of a search_user_by_mobile -// response. The exact shape is not contractually fixed, so it probes a few -// likely containers (top-level, result, data, user) and field names -// (userId/userid/user_id), tolerating both string and numeric encodings. -func byMobileExtractUserID(data map[string]any) string { - if data == nil { - return "" - } - if id := byMobileUserIDFromMap(data); id != "" { - return id - } - for _, key := range []string{"result", "data", "user", "userInfo"} { - switch v := data[key].(type) { - case map[string]any: - if id := byMobileUserIDFromMap(v); id != "" { - return id - } - case []any: - for _, it := range v { - if m, ok := it.(map[string]any); ok { - if id := byMobileUserIDFromMap(m); id != "" { - return id - } - } - } - case string: - if v != "" { - return v - } - case float64: - return strconv.FormatInt(int64(v), 10) - } - } - return "" -} - -func byMobileUserIDFromMap(m map[string]any) string { - for _, key := range []string{"userId", "userid", "user_id", "userID", "id"} { - switch v := m[key].(type) { - case string: - if v != "" && v != "0" { - return v - } - case float64: - if v != 0 { - return strconv.FormatInt(int64(v), 10) - } - } - } - return "" -} - func init() { + finalizeContactSmart(&ByMobile) shortcut.Register(ByMobile) } diff --git a/internal/shortcut/smart/contact_strict.go b/internal/shortcut/smart/contact_strict.go new file mode 100644 index 00000000..e9ce3543 --- /dev/null +++ b/internal/shortcut/smart/contact_strict.go @@ -0,0 +1,472 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +func contactSmartResult(item *shortcut.Shortcut) *contract.ResultSpec { + result := &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}} + switch item.Command { + case "+by-mobile", "+lookup": + result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"profile":{"type":"object","description":"与请求稳定身份精确绑定的用户详情","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"orgUserId":{"type":"string","minLength":1,"description":"稳定组织用户 ID"}},"additionalProperties":true}},"required":["profile"],"additionalProperties":false}`, item.Description)) + result.SensitivePaths = []string{"profile.userId", "profile.orgUserId", "profile.orgUserName", "profile.orgUserMobile", "profile.orgAuthEmail", "profile.orgEmail", "profile.depts.deptName"} + case "+dept-members", "+team": + result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"严格校验的直属成员数量"},"members":{"type":"array","description":"带稳定 userId 的直属成员","items":{"type":"object","description":"带稳定用户身份的直属成员","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"name":{"type":"string","description":"成员姓名"}},"required":["userId"],"additionalProperties":false}}},"required":["count","members"],"additionalProperties":false}`, item.Description)) + result.SensitivePaths = []string{"members.userId", "members.name"} + case "+org": + result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"department":{"type":"object","description":"与用户主部门 ID 精确绑定的部门详情","properties":{"deptId":{"type":"integer","minimum":1,"description":"稳定部门 ID"},"deptName":{"type":"string","description":"部门名称"}},"required":["deptId"],"additionalProperties":true}},"required":["department"],"additionalProperties":false}`, item.Description)) + result.SensitivePaths = []string{"department.deptName"} + case "+resolve-dept": + result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"resolved":{"type":"boolean","description":"是否唯一解析为一个部门"},"deptId":{"type":"string","minLength":1,"description":"唯一命中时的稳定部门 ID"},"name":{"type":"string","description":"唯一命中时的部门名称"},"count":{"type":"integer","minimum":0,"description":"多命中时的候选数量"},"candidates":{"type":"array","description":"多命中时供消歧的部门候选","items":{"type":"object","description":"带稳定部门身份的候选","properties":{"deptId":{"type":"string","minLength":1,"description":"稳定部门 ID"},"name":{"type":"string","description":"部门名称"}},"required":["deptId","name"],"additionalProperties":false}}},"required":["resolved"],"additionalProperties":false}`, item.Description)) + result.SensitivePaths = []string{"name", "candidates.name"} + case "+me": + result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"userId":{"type":"string","minLength":1,"description":"当前用户稳定 ID"},"name":{"type":"string","description":"当前用户姓名"},"mobile":{"type":"string","description":"当前用户手机号"},"email":{"type":"string","description":"当前用户邮箱"},"org":{"type":"string","description":"当前用户组织名称"},"dept":{"type":"string","description":"当前用户主部门名称"}},"required":["userId"],"additionalProperties":false}`, item.Description)) + result.SensitivePaths = []string{"userId", "name", "mobile", "email", "org", "dept"} + default: + result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, item.Description)) + } + return result +} + +func finalizeContactSmart(item *shortcut.Shortcut) { + item.OutputRollout = output.RolloutUnifiedActive + item.Contract.Result = contactSmartResult(item) + operation := "contact/" + strings.TrimPrefix(item.Command, "+") + requiredStrings := make([]string, 0, len(item.Flags)) + for index := range item.Flags { + flag := &item.Flags[index] + if flag.Required && flag.Type == shortcut.FlagString { + requiredStrings = append(requiredStrings, flag.Name) + fact := "--" + flag.Name + " 不能为空白" + if !strings.Contains(flag.Desc, fact) { + flag.Desc += ";" + fact + } + item.Constraints = append(item.Constraints, shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{flag.Name}, Description: fact}) + } + } + previousValidate := item.Validate + if previousValidate != nil || len(requiredStrings) > 0 { + item.Validate = func(rt *shortcut.RuntimeContext) error { + if previousValidate != nil { + if err := previousValidate(rt); err != nil { + return err + } + } + return validateContactSmartNonBlank(rt, operation, requiredStrings...) + } + } + switch item.Command { + case "+by-mobile": + item.Contract.Parameters = []contract.ParamDecl{{Name: "mobile", Property: "mobile"}} + case "+dept-members": + item.Contract.Parameters = []contract.ParamDecl{{Name: "dept", Property: "dept"}} + case "+lookup", "+org", "+team": + item.Contract.Parameters = []contract.ParamDecl{{Name: "name", Property: "name"}} + case "+resolve-dept": + item.Contract.Parameters = []contract.ParamDecl{{Name: "name", Property: "name"}} + } +} + +func validateContactSmartNonBlank(rt *shortcut.RuntimeContext, operation string, flags ...string) error { + for _, flag := range flags { + if strings.TrimSpace(rt.Str(flag)) == "" { + return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 不能为空白") + } + } + return nil +} + +func validateContactSmartMobile(rt *shortcut.RuntimeContext, operation, flag string) error { + if _, err := normalizeContactSmartMobile(rt.Str(flag)); err != nil { + return responsecheck.Error(operation, "invalid_mobile", "--"+flag+" 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号") + } + return nil +} + +func strictContactEnvelope(data map[string]any, operation string) (map[string]any, error) { + envelope, err := responsecheck.RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, key := range []string{"errorCode", "errorMsg", "errorMessage", "error"} { + if strictContactFailure(envelope[key]) { + return nil, responsecheck.Error(operation, "conflicting_failure_evidence", "success=true 响应同时携带失败字段 "+key) + } + } + return envelope, nil +} + +func strictContactFailure(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + trimmed := strings.TrimSpace(typed) + return trimmed != "" && trimmed != "0" && !strings.EqualFold(trimmed, "success") + case float64: + return typed != 0 + case int: + return typed != 0 + case bool: + return typed + case map[string]any: + return len(typed) != 0 + case []any: + return len(typed) != 0 + default: + return true + } +} + +func strictResolveContactUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) { + data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{"keyword": name}) + if err != nil { + return contactUser{}, err + } + if _, err := strictContactEnvelope(data, "contact/search_contact_by_key_word"); err != nil { + return contactUser{}, err + } + items, err := responsecheck.RequireObjectCollection(data, "contact/search_contact_by_key_word", "result") + if err != nil { + return contactUser{}, err + } + users := make([]contactUser, 0, len(items)) + seen := make(map[string]bool, len(items)) + for index, item := range items { + user := contactUser{ + userID: strictContactString(item, "userId"), + openDingTalkID: strictContactString(item, "openDingTalkId"), + name: strictContactString(item, "name"), + } + if user.userID == "" { + return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "missing_stable_identity", fmt.Sprintf("用户结果第 %d 项缺少 userId", index)) + } + if seen[user.userID] { + return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "duplicate_stable_identity", fmt.Sprintf("用户结果第 %d 项重复 userId", index)) + } + seen[user.userID] = true + users = append(users, user) + } + switch len(users) { + case 0: + return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "not_found", "未找到匹配的通讯录用户") + case 1: + return users[0], nil + default: + return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "ambiguous_match", fmt.Sprintf("匹配到 %d 个用户;请提供更精确的姓名", len(users))) + } +} + +func strictResolveContactUserByMobile(rt *shortcut.RuntimeContext, mobile string) (contactUser, map[string]any, error) { + normalizedMobile, err := normalizeContactSmartMobile(mobile) + if err != nil { + return contactUser{}, nil, responsecheck.Error("contact/by-mobile", "invalid_mobile", "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号") + } + lookup, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{"mobile": normalizedMobile}) + if err != nil { + return contactUser{}, nil, err + } + user, found, err := strictContactMobileLookup(lookup, "contact/search_user_by_mobile") + if err != nil { + return contactUser{}, nil, err + } + if !found { + return contactUser{}, nil, responsecheck.Error("contact/search_user_by_mobile", "not_found", "未找到绑定该手机号的通讯录用户") + } + data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{ + "user_id_list": []string{user.userID}, + }) + if err != nil { + return contactUser{}, nil, err + } + profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids") + if err != nil { + return contactUser{}, nil, err + } + return user, profile, nil +} + +// strictContactMobileLookup accepts the dedicated exact-mobile wire only. A +// successful response with no result is the reviewed no-match encoding seen +// from this interface; null and every malformed present result fail closed. +func strictContactMobileLookup(data map[string]any, operation string) (contactUser, bool, error) { + envelope, err := strictContactEnvelope(data, operation) + if err != nil { + return contactUser{}, false, err + } + raw, present := envelope["result"] + if !present { + return contactUser{}, false, nil + } + result, ok := raw.(map[string]any) + if !ok || len(result) == 0 { + return contactUser{}, false, responsecheck.Error(operation, "malformed_result", "手机号精确查询 result 必须是非空对象") + } + user := contactUser{ + userID: strictContactString(result, "userId"), + openDingTalkID: strictContactString(result, "openDingTalkId"), + name: strictContactString(result, "orgUserName"), + } + if user.userID == "" { + return contactUser{}, false, responsecheck.Error(operation, "missing_stable_identity", "手机号精确查询结果缺少非空 userId") + } + for _, key := range []string{"orgUserName", "openDingTalkId"} { + if _, _, valid := strictContactOptionalString(result, key); !valid { + return contactUser{}, false, responsecheck.Error(operation, "malformed_optional_field", "手机号精确查询 "+key+" 必须是字符串") + } + } + return user, true, nil +} + +func normalizeContactSmartMobile(value string) (string, error) { + return normalizeContactSmartMobilePart(value, 6) +} + +func normalizeContactSmartMobilePart(value string, minimumDigits int) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("empty mobile") + } + var digits strings.Builder + for index, r := range trimmed { + switch { + case r >= '0' && r <= '9': + digits.WriteRune(r) + case r == '+' && index == 0: + case r == ' ' || r == '-' || r == '(' || r == ')': + default: + return "", fmt.Errorf("invalid mobile character") + } + } + normalized := digits.String() + if strings.HasPrefix(normalized, "00") { + normalized = strings.TrimPrefix(normalized, "00") + } + if len(normalized) < minimumDigits { + return "", fmt.Errorf("mobile too short") + } + return normalized, nil +} + +func strictUserDetail(data map[string]any, expectedUserID, operation string) (map[string]any, error) { + if _, err := strictContactEnvelope(data, operation); err != nil { + return nil, err + } + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return nil, err + } + if len(items) != 1 { + return nil, responsecheck.Error(operation, "unexpected_detail_count", fmt.Sprintf("用户详情应唯一,实际返回 %d 项", len(items))) + } + model, ok := items[0]["orgEmployeeModel"].(map[string]any) + if !ok || len(model) == 0 { + return nil, responsecheck.Error(operation, "malformed_result", "用户详情缺少非空 orgEmployeeModel") + } + actual := strictContactString(model, "orgUserId") + if actual == "" { + actual = strictContactString(model, "userId") + } + if actual == "" { + return nil, responsecheck.Error(operation, "missing_stable_identity", "用户详情缺少 userId/orgUserId") + } + if expectedUserID != "" && actual != expectedUserID { + return nil, responsecheck.Error(operation, "identity_mismatch", "用户详情稳定身份与请求不一致") + } + return model, nil +} + +func strictDeptCandidates(data map[string]any, operation string) ([]deptMembersDept, error) { + if _, err := strictContactEnvelope(data, operation); err != nil { + return nil, err + } + items, err := responsecheck.RequireObjectCollection(data, operation, "deptList") + if err != nil { + return nil, err + } + out := make([]deptMembersDept, 0, len(items)) + seen := map[int64]bool{} + for index, item := range items { + id, ok := strictContactInt64(item["deptId"]) + if !ok || id <= 0 { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("部门结果第 %d 项缺少有效 deptId", index)) + } + if seen[id] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("部门结果包含重复 deptId %d", id)) + } + seen[id] = true + name := stripHighlightTags(strictContactString(item, "deptName")) + if name == "" { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("部门结果第 %d 项缺少 deptName", index)) + } + out = append(out, deptMembersDept{id: id, name: name}) + } + return out, nil +} + +func strictContactMembers(data map[string]any, operation string) ([]map[string]any, error) { + if _, err := strictContactEnvelope(data, operation); err != nil { + return nil, err + } + items, err := responsecheck.RequireObjectCollection(data, operation, "deptUserList") + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + seen := make(map[string]bool, len(items)) + for index, item := range items { + user, ok := item["userInfo"].(map[string]any) + if !ok || len(user) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("deptUserList[%d].userInfo 应为非空对象", index)) + } + id := strictContactString(user, "userId") + if id == "" { + return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("部门成员第 %d 项缺少 userId", index)) + } + if seen[id] { + return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("部门成员第 %d 项重复 userId", index)) + } + seen[id] = true + row := map[string]any{"userId": id} + name, _, valid := strictContactOptionalString(user, "name") + if !valid { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("部门成员第 %d 项的 name 必须是字符串", index)) + } + if name != "" { + row["name"] = name + } + out = append(out, row) + } + return out, nil +} + +func strictDeptDetail(data map[string]any, expectedID int64, operation string) (map[string]any, error) { + if _, err := strictContactEnvelope(data, operation); err != nil { + return nil, err + } + detail, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return nil, err + } + id, ok := strictContactInt64(detail["deptId"]) + if !ok || id <= 0 { + return nil, responsecheck.Error(operation, "missing_stable_identity", "部门详情缺少有效 deptId") + } + if expectedID > 0 && id != expectedID { + return nil, responsecheck.Error(operation, "identity_mismatch", "部门详情稳定身份与请求不一致") + } + if _, _, valid := strictContactOptionalString(detail, "deptName"); !valid { + return nil, responsecheck.Error(operation, "malformed_result", "部门详情 deptName 必须是字符串") + } + detail["deptId"] = id + return detail, nil +} + +func strictPrimaryDeptID(model map[string]any, operation string) (int64, error) { + raw, ok := model["depts"].([]any) + if !ok || len(raw) == 0 { + return 0, responsecheck.Error(operation, "missing_collection", "用户详情缺少非空 depts 数组") + } + seen := make(map[int64]bool, len(raw)) + var primary int64 + for index, item := range raw { + dept, ok := item.(map[string]any) + if !ok || len(dept) == 0 { + return 0, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("depts[%d] 不是非空对象", index)) + } + id, ok := strictContactInt64(dept["deptId"]) + if !ok || id <= 0 { + return 0, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("depts[%d] 缺少有效 deptId", index)) + } + if seen[id] { + return 0, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("depts[%d] 重复 deptId", index)) + } + seen[id] = true + if index == 0 { + primary = id + } + } + return primary, nil +} + +func strictWhoami(data map[string]any) (map[string]any, error) { + model, err := strictUserDetail(data, "", "contact/get_current_user_profile") + if err != nil { + return nil, err + } + out := map[string]any{"userId": strictContactFirst(model, "userId", "orgUserId")} + for outputKey, candidates := range map[string][]string{ + "name": {"orgUserName"}, "mobile": {"orgUserMobile"}, "email": {"orgAuthEmail", "orgEmail"}, "org": {"orgName"}, + } { + if value := strictContactFirst(model, candidates...); value != "" { + out[outputKey] = value + } + } + if depts, ok := model["depts"].([]any); ok && len(depts) > 0 { + if dept, ok := depts[0].(map[string]any); ok { + if name := strictContactString(dept, "deptName"); name != "" { + out["dept"] = name + } + } + } + return out, nil +} + +func strictContactFirst(object map[string]any, keys ...string) string { + for _, key := range keys { + if value := strictContactString(object, key); value != "" { + return value + } + } + return "" +} + +func strictContactString(object map[string]any, key string) string { + value, _ := object[key].(string) + return strings.TrimSpace(value) +} + +func strictContactOptionalString(object map[string]any, key string) (string, bool, bool) { + raw, present := object[key] + if !present || raw == nil { + return "", present, true + } + value, ok := raw.(string) + if !ok { + return "", true, false + } + return strings.TrimSpace(value), true, true +} + +func strictContactInt64(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if typed != float64(int64(typed)) { + return 0, false + } + return int64(typed), true + case int64: + return typed, true + case int: + return int64(typed), true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed, err == nil + default: + return 0, false + } +} diff --git a/internal/shortcut/smart/contact_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/contact_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..e927a08e --- /dev/null +++ b/internal/shortcut/smart/contact_strict_cross_platform_coverage_test.go @@ -0,0 +1,587 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type contactSmartStrictCall struct { + tool string + args map[string]any +} + +type contactSmartStrictCaller struct { + calls []contactSmartStrictCall + searchZero bool + payloads map[string]string + errors map[string]error +} + +func (caller *contactSmartStrictCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, contactSmartStrictCall{tool: tool, args: args}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + if payload, ok := caller.payloads[tool]; ok { + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: payload}}}, nil + } + payload := `{"success":true,"result":[]}` + switch tool { + case "search_user_by_mobile": + if caller.searchZero { + payload = `{"success":true}` + } else { + payload = `{"success":true,"result":{"userId":"stable-user","orgUserName":"Fixture"}}` + } + case "search_contact_by_key_word": + payload = `{"success":true,"result":[{"userId":"stable-user","openDingTalkId":"stable-open"}]}` + case "get_user_info_by_user_ids": + payload = `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"stable-user"}}]}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: payload}}}, nil +} + +func (*contactSmartStrictCaller) Format() string { return "json" } +func (*contactSmartStrictCaller) DryRun() bool { return false } +func (*contactSmartStrictCaller) Fields() string { return "" } +func (*contactSmartStrictCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageContactSmartStrictDecoders(t *testing.T) { + profile, err := strictUserDetail(map[string]any{ + "success": true, + "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "stable-user"}}}, + }, "stable-user", "contact/detail") + if err != nil || profile["orgUserId"] != "stable-user" { + t.Fatalf("valid detail rejected: profile=%v err=%v", profile, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": []any{}}, + {"success": true, "result": []any{map[string]any{}}}, + {"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "other"}}}}, + {"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "stable-user"}}, map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "stable-user"}}}}, + } { + if got, decodeErr := strictUserDetail(data, "stable-user", "contact/detail"); decodeErr == nil { + t.Errorf("broken detail returned success: %v", got) + } + } + if _, err := strictContactMembers(map[string]any{ + "success": true, + "deptUserList": []any{ + map[string]any{"userInfo": map[string]any{"userId": "same"}}, + map[string]any{"userInfo": map[string]any{"userId": "same"}}, + }, + }, "contact/members"); err == nil { + t.Fatal("duplicate smart member identity returned success") + } + if _, err := strictContactMembers(map[string]any{ + "success": true, + "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "stable", "name": true}}}, + }, "contact/members"); err == nil { + t.Fatal("malformed optional smart member name returned success") + } + department, err := strictDeptDetail(map[string]any{ + "success": true, "result": map[string]any{"deptId": "7", "deptName": "Fixture"}, + }, 7, "contact/dept") + if err != nil || department["deptId"] != int64(7) { + t.Fatalf("department ID was not normalized: department=%v err=%v", department, err) + } + if _, err := strictPrimaryDeptID(map[string]any{"depts": []any{ + map[string]any{"deptId": float64(1)}, map[string]any{"deptId": float64(1)}, + }}, "contact/detail"); err == nil { + t.Fatal("duplicate profile department identity returned success") + } + if _, err := strictPrimaryDeptID(map[string]any{"depts": []any{ + map[string]any{"deptId": float64(1)}, map[string]any{"deptName": "missing-id"}, + }}, "contact/detail"); err == nil { + t.Fatal("malformed later profile department was silently ignored") + } +} + +func TestCrossPlatformCoverageContactSmartContracts(t *testing.T) { + items := []*shortcut.Shortcut{&ByMobile, &DeptMembers, &Lookup, &Org, &ResolveDept, &Team, &Whoami} + for _, item := range items { + if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || len(item.Contract.Result.SensitivePaths) == 0 || strings.TrimSpace(item.Safety.Effect) == "" { + t.Errorf("%s lacks Contract/Result/Safety/unified output", item.Command) + continue + } + var schema map[string]any + if err := json.Unmarshal(item.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" { + t.Errorf("%s invalid Result schema: schema=%v err=%v", item.Command, schema, err) + } + } + for item, want := range map[*shortcut.Shortcut]map[string]string{ + &ByMobile: {"mobile": "mobile"}, + &DeptMembers: {"dept": "dept"}, + &Lookup: {"name": "name"}, + &Org: {"name": "name"}, + &ResolveDept: {"name": "name"}, + &Team: {"name": "name"}, + } { + for _, parameter := range item.Contract.Parameters { + if property, ok := want[parameter.Name]; ok { + if parameter.Property != property { + t.Errorf("%s parameter %s property=%q want=%q", item.Command, parameter.Name, parameter.Property, property) + } + delete(want, parameter.Name) + } + } + if len(want) != 0 { + t.Errorf("%s missing compatibility parameters: %#v", item.Command, want) + } + } + if Whoami.Validate != nil { + t.Fatal("parameterless +me must not publish an empty runtime validator") + } +} + +func TestCrossPlatformCoverageContactSmartBlankInputsFailBeforeRemoteCall(t *testing.T) { + caller := &contactSmartStrictCaller{} + helpers.InitDepsForTest(t, caller) + tests := []struct { + declaration shortcut.Shortcut + flag string + }{ + {ByMobile, "mobile"}, + {DeptMembers, "dept"}, + {Lookup, "name"}, + {Org, "name"}, + {ResolveDept, "name"}, + {Team, "name"}, + } + for _, test := range tests { + command := &cobra.Command{Use: test.declaration.Command} + command.Flags().String(test.flag, "", "") + if err := command.Flags().Set(test.flag, " "); err != nil { + t.Fatal(err) + } + if err := test.declaration.Validate(shortcut.RuntimeContextForTest(command, test.declaration)); err == nil { + t.Errorf("%s accepted blank --%s", test.declaration.Command, test.flag) + } + } + invalidMobile := &cobra.Command{Use: "+by-mobile"} + invalidMobile.Flags().String("mobile", "", "") + if err := invalidMobile.Flags().Set("mobile", "not-a-phone"); err != nil { + t.Fatal(err) + } + if err := ByMobile.Validate(shortcut.RuntimeContextForTest(invalidMobile, ByMobile)); err == nil { + t.Fatal("+by-mobile accepted a non-phone value") + } + if len(caller.calls) != 0 { + t.Fatalf("blank smart inputs made remote calls: %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageByMobileUsesDedicatedExactLookupBeforeDetail(t *testing.T) { + caller := &contactSmartStrictCaller{} + helpers.InitDepsForTest(t, caller) + command := &cobra.Command{Use: "+by-mobile"} + command.Flags().String("mobile", "", "") + if err := command.Flags().Set("mobile", "+86 138-0013-8000"); err != nil { + t.Fatal(err) + } + declaration := ByMobile + declaration.OutputRollout = output.RolloutLegacyOnly + if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err != nil { + t.Fatalf("by-mobile known execution: %v", err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != "search_user_by_mobile" || caller.calls[0].args["mobile"] != "8613800138000" || caller.calls[1].tool != "get_user_info_by_user_ids" { + t.Fatalf("by-mobile calls = %#v", caller.calls) + } + + caller.calls = nil + caller.searchZero = true + if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil { + t.Fatal("explicit zero unexpectedly became a successful detail") + } + if len(caller.calls) != 1 || caller.calls[0].tool != "search_user_by_mobile" { + t.Fatalf("zero-match calls = %#v", caller.calls) + } + + caller.searchZero = false + caller.calls = nil + caller.payloads = map[string]string{"search_user_by_mobile": `{"success":true,"result":null}`} + if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil { + t.Fatal("malformed exact-mobile result returned success") + } + if len(caller.calls) != 1 || caller.calls[0].tool != "search_user_by_mobile" { + t.Fatalf("malformed lookup calls = %#v", caller.calls) + } + for name, detail := range map[string]string{ + "missing model": `{"success":true,"result":[{}]}`, + "wrong identity": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"other"}}]}`, + "missing detail": `{"success":true,"result":[]}`, + } { + t.Run(name, func(t *testing.T) { + caller.calls = nil + caller.payloads = map[string]string{ + "search_user_by_mobile": `{"success":true,"result":{"userId":"stable-user"}}`, + "get_user_info_by_user_ids": detail, + } + if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil { + t.Fatal("unverified mobile profile returned success") + } + if len(caller.calls) != 2 { + t.Fatalf("verification calls = %#v", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageContactSmartMobileProofHelperMatrix(t *testing.T) { + invalidCaller := &contactSmartStrictCaller{} + helpers.InitDepsForTest(t, invalidCaller) + if _, _, err := strictResolveContactUserByMobile(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+by-mobile"}, ByMobile), "not-a-phone"); err == nil { + t.Fatal("invalid mobile resolver returned success") + } + if len(invalidCaller.calls) != 0 { + t.Fatalf("invalid mobile resolver made calls: %#v", invalidCaller.calls) + } + command := &cobra.Command{Use: "+by-mobile"} + command.Flags().String("mobile", "", "") + if err := command.Flags().Set("mobile", "+86 138-0013-8000"); err != nil { + t.Fatal(err) + } + if err := ByMobile.Validate(shortcut.RuntimeContextForTest(command, ByMobile)); err != nil { + t.Fatalf("valid formatted mobile rejected: %v", err) + } + for input, want := range map[string]string{ + "+86 138-0013-8000": "8613800138000", + "008613800138000": "8613800138000", + "(138) 0013-8000": "13800138000", + } { + if got, err := normalizeContactSmartMobile(input); err != nil || got != want { + t.Errorf("normalize %q = %q, %v; want %q", input, got, err, want) + } + } + for _, input := range []string{"", "123", "1+3800138000", "13800x138000"} { + if got, err := normalizeContactSmartMobile(input); err == nil { + t.Errorf("invalid mobile %q normalized to %q", input, got) + } + } + user, found, err := strictContactMobileLookup(map[string]any{ + "success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": "Fixture", "openDingTalkId": "open"}, + }, "contact/mobile") + if err != nil || !found || user.userID != "stable-user" || user.name != "Fixture" || user.openDingTalkID != "open" { + t.Fatalf("mobile lookup=(%#v,%v,%v)", user, found, err) + } + if user, found, err := strictContactMobileLookup(map[string]any{"success": true}, "contact/mobile"); err != nil || found || user.userID != "" { + t.Fatalf("reviewed no-match=(%#v,%v,%v)", user, found, err) + } + for _, broken := range []map[string]any{ + {"success": false}, + {"success": true, "result": nil}, + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"orgUserName": "missing-id"}}, + {"success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": 7}}, + {"success": true, "result": map[string]any{"userId": "stable-user", "openDingTalkId": true}}, + } { + if user, found, err := strictContactMobileLookup(broken, "contact/mobile"); err == nil { + t.Errorf("broken lookup succeeded: user=%#v found=%v data=%#v", user, found, broken) + } + } +} + +func TestCrossPlatformCoverageContactSmartPrimitiveMatrices(t *testing.T) { + for index, test := range []struct { + value any + want bool + }{ + {nil, false}, {"", false}, {"0", false}, {"SUCCESS", false}, {"bad", true}, + {float64(0), false}, {float64(1), true}, {0, false}, {1, true}, {false, false}, {true, true}, + {map[string]any{}, false}, {map[string]any{"x": 1}, true}, {[]any{}, false}, {[]any{1}, true}, {struct{}{}, true}, + } { + if got := strictContactFailure(test.value); got != test.want { + t.Errorf("failure matrix %d = %v, want %v", index, got, test.want) + } + } + for index, test := range []struct { + value any + want int64 + ok bool + }{ + {float64(7), 7, true}, {float64(7.5), 0, false}, {int64(8), 8, true}, {9, 9, true}, + {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {" 11 ", 11, true}, {"bad", 0, false}, {true, 0, false}, + } { + got, ok := strictContactInt64(test.value) + if got != test.want || ok != test.ok { + t.Errorf("integer matrix %d = (%d,%v), want (%d,%v)", index, got, ok, test.want, test.ok) + } + } + if value, present, valid := strictContactOptionalString(map[string]any{"x": nil}, "x"); value != "" || !present || !valid { + t.Fatalf("nil optional string = (%q,%v,%v)", value, present, valid) + } + if value, present, valid := strictContactOptionalString(map[string]any{"x": true}, "x"); value != "" || !present || valid { + t.Fatalf("bad optional string = (%q,%v,%v)", value, present, valid) + } + if got := strictContactFirst(map[string]any{"a": " ", "b": " value "}, "a", "b"); got != "value" { + t.Fatalf("first string = %q", got) + } +} + +func TestCrossPlatformCoverageContactSmartStrictHelperBranches(t *testing.T) { + dummy := shortcut.Shortcut{Command: "+dummy", Description: "Dummy", Flags: []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true}}, Validate: func(*shortcut.RuntimeContext) error { return errors.New("prior") }} + if result := contactSmartResult(&dummy); result == nil || len(result.DataSchema) == 0 { + t.Fatal("default smart result missing") + } + finalizeContactSmart(&dummy) + dummyCommand := &cobra.Command{Use: "+dummy"} + dummyCommand.Flags().String("name", "value", "") + if err := dummy.Validate(shortcut.RuntimeContextForTest(dummyCommand, dummy)); err == nil { + t.Fatal("previous validator failure was discarded") + } + for _, data := range []map[string]any{ + {}, {"success": false}, {"success": true, "errorCode": "FAILED"}, + } { + if _, err := strictContactEnvelope(data, "contact/test"); err == nil { + t.Errorf("bad envelope returned success: %#v", data) + } + } + caller := &contactSmartStrictCaller{payloads: map[string]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + resolverCommand := &cobra.Command{Use: "+lookup"} + resolverCommand.Flags().String("name", "fixture", "") + resolverRuntime := shortcut.RuntimeContextForTest(resolverCommand, Lookup) + for _, payload := range []string{ + `{"success":true}`, + `{"success":true,"result":[]}`, + `{"success":true,"result":[{"name":"missing-id"}]}`, + `{"success":true,"result":[{"userId":"same"},{"userId":"same"}]}`, + `{"success":true,"result":[{"userId":"one"},{"userId":"two"}]}`, + } { + caller.payloads["search_contact_by_key_word"] = payload + if _, err := strictResolveContactUser(resolverRuntime, "fixture"); err == nil { + t.Errorf("bad resolver payload returned success: %s", payload) + } + } + for _, data := range []map[string]any{ + {"success": true}, + {"success": true, "deptList": []any{}}, + {"success": true, "deptList": []any{map[string]any{"deptName": "missing-id"}}}, + {"success": true, "deptList": []any{map[string]any{"deptId": 1, "deptName": ""}}}, + {"success": true, "deptList": []any{map[string]any{"deptId": 1, "deptName": "One"}, map[string]any{"deptId": 1, "deptName": "Two"}}}, + } { + got, err := strictDeptCandidates(data, "contact/dept") + if list, ok := data["deptList"].([]any); ok && len(list) == 0 { + if err != nil || len(got) != 0 { + t.Errorf("explicit empty departments rejected: got=%#v err=%v", got, err) + } + } else if err == nil { + t.Errorf("bad department candidates returned success: %#v", data) + } + } + candidates, err := strictDeptCandidates(map[string]any{"success": true, "deptList": []any{map[string]any{"deptId": "7", "deptName": "Fixture"}}}, "contact/dept") + if err != nil || len(candidates) != 1 || candidates[0].id != 7 || candidates[0].name != "Fixture" { + t.Fatalf("valid candidates = %#v err=%v", candidates, err) + } + for _, data := range []map[string]any{ + {"success": false}, + {"success": true}, + {"success": true, "deptUserList": []any{map[string]any{"x": 1}}}, + {"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"name": "no-id"}}}}, + } { + if _, err := strictContactMembers(data, "contact/members"); err == nil { + t.Errorf("bad members returned success: %#v", data) + } + } + members, err := strictContactMembers(map[string]any{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "u1", "name": "Fixture"}}}}, "contact/members") + if err != nil || len(members) != 1 { + t.Fatalf("valid members=%#v err=%v", members, err) + } + for _, data := range []map[string]any{ + {"success": false}, + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{"deptId": 0}}, + {"success": true, "result": map[string]any{"deptId": 8}}, + {"success": true, "result": map[string]any{"deptId": 7, "deptName": true}}, + } { + if _, err := strictDeptDetail(data, 7, "contact/dept"); err == nil { + t.Errorf("bad detail returned success: %#v", data) + } + } + for _, data := range []map[string]any{ + {"success": true, "result": []any{map[string]any{"x": 1}}}, + {"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"name": "no-id"}}}}, + } { + if _, err := strictUserDetail(data, "u1", "contact/detail"); err == nil { + t.Errorf("bad user detail returned success: %#v", data) + } + } + for _, model := range []map[string]any{ + {}, {"depts": []any{}}, {"depts": []any{"bad"}}, {"depts": []any{map[string]any{}}}, + } { + if _, err := strictPrimaryDeptID(model, "contact/detail"); err == nil { + t.Errorf("bad primary department returned success: %#v", model) + } + } + if id, err := strictPrimaryDeptID(map[string]any{"depts": []any{map[string]any{"deptId": 7}, map[string]any{"deptId": 8}}}, "contact/detail"); err != nil || id != 7 { + t.Fatalf("primary dept=(%d,%v)", id, err) + } + who, err := strictWhoami(map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{ + "userId": "u1", "orgUserName": "Fixture", "orgUserMobile": "m", "orgAuthEmail": "e", "orgName": "o", "depts": []any{map[string]any{"deptName": "d"}}, + }}}}) + if err != nil || who["userId"] != "u1" || who["dept"] != "d" { + t.Fatalf("whoami=%#v err=%v", who, err) + } +} + +func TestCrossPlatformCoverageContactSmartExecutors(t *testing.T) { + basePayloads := map[string]string{ + "search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1","openDingTalkId":"o1"}]}`, + "search_user_by_mobile": `{"success":true,"result":{"userId":"u1","orgUserName":"Fixture"}}`, + "get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1","depts":[{"deptId":7}]}}]}`, + "search_dept_by_keyword": `{"success":true,"deptList":[{"deptId":7,"deptName":"Fixture"}]}`, + "get_dept_members_by_deptId": `{"success":true,"deptUserList":[{"userInfo":{"userId":"u1","name":"Fixture"}}]}`, + "get_dept_info_by_dept_id": `{"success":true,"result":{"deptId":7,"deptName":"Fixture"}}`, + "get_current_user_profile": `{"success":true,"result":[{"orgEmployeeModel":{"userId":"u1","orgUserName":"Fixture"}}]}`, + } + tests := []struct { + declaration shortcut.Shortcut + flag string + value string + }{ + {ByMobile, "mobile", "13800138000"}, {DeptMembers, "dept", "fixture"}, {Lookup, "name", "fixture"}, + {Org, "name", "fixture"}, {ResolveDept, "name", "fixture"}, {Team, "name", "fixture"}, {Whoami, "", ""}, + } + for _, test := range tests { + t.Run(test.declaration.Command, func(t *testing.T) { + caller := &contactSmartStrictCaller{payloads: basePayloads, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + declaration := test.declaration + declaration.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: declaration.Command} + if test.flag != "" { + command.Flags().String(test.flag, "", "") + if err := command.Flags().Set(test.flag, test.value); err != nil { + t.Fatal(err) + } + } + rt := shortcut.RuntimeContextForTest(command, declaration) + if err := declaration.Execute(rt); err != nil { + t.Fatalf("success execution: %v; calls=%#v", err, caller.calls) + } + caller.calls = nil + firstTool := map[string]string{ + "+by-mobile": "search_user_by_mobile", "+dept-members": "search_dept_by_keyword", "+resolve-dept": "search_dept_by_keyword", "+me": "get_current_user_profile", + }[declaration.Command] + if firstTool == "" { + firstTool = "search_contact_by_key_word" + } + caller.errors[firstTool] = errors.New("transport") + if err := declaration.Execute(rt); err == nil { + t.Fatal("first transport error returned success") + } + }) + } + + for _, declaration := range []shortcut.Shortcut{ByMobile, Lookup, Org, Team} { + caller := &contactSmartStrictCaller{payloads: basePayloads, errors: map[string]error{"get_user_info_by_user_ids": errors.New("transport")}} + helpers.InitDepsForTest(t, caller) + copy := declaration + copy.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: copy.Command} + flag := "name" + if copy.Command == "+by-mobile" { + flag = "mobile" + } + command.Flags().String(flag, "", "") + value := "fixture" + if copy.Command == "+by-mobile" { + value = "13800138000" + } + if err := command.Flags().Set(flag, value); err != nil { + t.Fatal(err) + } + if err := copy.Execute(shortcut.RuntimeContextForTest(command, copy)); err == nil { + t.Errorf("%s second transport error returned success", copy.Command) + } + } + for _, declaration := range []shortcut.Shortcut{DeptMembers, Team} { + caller := &contactSmartStrictCaller{payloads: basePayloads, errors: map[string]error{"get_dept_members_by_deptId": errors.New("transport")}} + helpers.InitDepsForTest(t, caller) + copy := declaration + copy.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: copy.Command} + flag := "name" + if copy.Command == "+dept-members" { + flag = "dept" + } + command.Flags().String(flag, "", "") + if err := command.Flags().Set(flag, "fixture"); err != nil { + t.Fatal(err) + } + if err := copy.Execute(shortcut.RuntimeContextForTest(command, copy)); err == nil { + t.Errorf("%s final transport error returned success", copy.Command) + } + } + + runFailure := func(t *testing.T, declaration shortcut.Shortcut, flag string, payloads map[string]string, errorsByTool map[string]error) { + t.Helper() + caller := &contactSmartStrictCaller{payloads: payloads, errors: errorsByTool} + helpers.InitDepsForTest(t, caller) + declaration.OutputRollout = output.RolloutLegacyOnly + command := &cobra.Command{Use: declaration.Command} + if flag != "" { + command.Flags().String(flag, "", "") + value := "fixture" + if declaration.Command == "+by-mobile" { + value = "13800138000" + } + if err := command.Flags().Set(flag, value); err != nil { + t.Fatal(err) + } + } + if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil { + t.Fatalf("%s malformed downstream response returned success", declaration.Command) + } + } + badProfile := map[string]string{ + "search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`, + "search_user_by_mobile": `{"success":true,"result":{"userId":"u1"}}`, + "get_user_info_by_user_ids": `{"success":true,"result":[{"x":1}]}`, + } + runFailure(t, ByMobile, "mobile", badProfile, map[string]error{}) + runFailure(t, Lookup, "name", badProfile, map[string]error{}) + badMembers := map[string]string{ + "search_dept_by_keyword": `{"success":true,"deptList":[{"deptId":7,"deptName":"Fixture"}]}`, + "get_dept_members_by_deptId": `{"success":true}`, + } + runFailure(t, DeptMembers, "dept", badMembers, map[string]error{}) + badOrgProfile := map[string]string{ + "search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`, + "get_user_info_by_user_ids": `{"success":true,"result":[{"x":1}]}`, + } + runFailure(t, Org, "name", badOrgProfile, map[string]error{}) + runFailure(t, Team, "name", badOrgProfile, map[string]error{}) + missingDept := map[string]string{ + "search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`, + "get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1"}}]}`, + } + runFailure(t, Org, "name", missingDept, map[string]error{}) + runFailure(t, Team, "name", missingDept, map[string]error{}) + validOrg := map[string]string{ + "search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`, + "get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1","depts":[{"deptId":7}]}}]}`, + "get_dept_info_by_dept_id": `{"success":true}`, + } + runFailure(t, Org, "name", validOrg, map[string]error{}) + runFailure(t, Org, "name", validOrg, map[string]error{"get_dept_info_by_dept_id": errors.New("transport")}) + validTeam := map[string]string{ + "search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`, + "get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1","depts":[{"deptId":7}]}}]}`, + "get_dept_members_by_deptId": `{"success":true}`, + } + runFailure(t, Team, "name", validTeam, map[string]error{}) +} diff --git a/internal/shortcut/smart/dept_members.go b/internal/shortcut/smart/dept_members.go index 0408e223..4be0f14e 100644 --- a/internal/shortcut/smart/dept_members.go +++ b/internal/shortcut/smart/dept_members.go @@ -98,7 +98,10 @@ var DeptMembers = shortcut.Shortcut{ // Step 2 — defensively pull the matching departments out of the // response and require exactly one, otherwise disambiguate. - depts := deptMembersExtractDepts(data) + depts, err := strictDeptCandidates(data, "contact/search_dept_by_keyword") + if err != nil { + return err + } switch { case len(depts) == 0: return apperrors.NewValidation(fmt.Sprintf( @@ -112,9 +115,17 @@ var DeptMembers = shortcut.Shortcut{ // Step 3 — list that department's direct members. // get_dept_members_by_deptId expects deptIds as a string list. - return rt.CallMCP("get_dept_members_by_deptId", map[string]any{ + data, err = rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{ "deptIds": []string{strconv.FormatInt(depts[0].id, 10)}, }) + if err != nil { + return err + } + members, err := strictContactMembers(data, "contact/get_dept_members_by_deptId") + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(members), "members": members}) }, } @@ -218,5 +229,6 @@ func deptMembersLabels(depts []deptMembersDept) []string { } func init() { + finalizeContactSmart(&DeptMembers) shortcut.Register(DeptMembers) } diff --git a/internal/shortcut/smart/lookup.go b/internal/shortcut/smart/lookup.go index d9a18017..f44e2c7e 100644 --- a/internal/shortcut/smart/lookup.go +++ b/internal/shortcut/smart/lookup.go @@ -65,18 +65,27 @@ var Lookup = shortcut.Shortcut{ Tips: []string{`dws contact +lookup --name 张三`}, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — resolve the name to a unique userId. - user, err := resolveUser(rt, rt.Str("name")) + user, err := strictResolveContactUser(rt, rt.Str("name")) if err != nil { return err } // Step 2 — fetch and print the full profile of the resolved user. - return rt.CallMCP("get_user_info_by_user_ids", map[string]any{ + data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{ "user_id_list": []string{user.userID}, }) + if err != nil { + return err + } + profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids") + if err != nil { + return err + } + return rt.Output(map[string]any{"profile": profile}) }, } func init() { + finalizeContactSmart(&Lookup) shortcut.Register(Lookup) } diff --git a/internal/shortcut/smart/org.go b/internal/shortcut/smart/org.go index 4e20d9ca..a14b7569 100644 --- a/internal/shortcut/smart/org.go +++ b/internal/shortcut/smart/org.go @@ -15,13 +15,11 @@ package smart import ( "encoding/json" - "fmt" "strconv" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -75,7 +73,7 @@ var Org = shortcut.Shortcut{ Tips: []string{`dws contact +org --name 张三`}, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — resolve the person's name to a unique userId. - user, err := resolveUser(rt, rt.Str("name")) + user, err := strictResolveContactUser(rt, rt.Str("name")) if err != nil { return err } @@ -91,16 +89,25 @@ var Org = shortcut.Shortcut{ // Step 3 — defensively pull the primary deptId out of the response, // then print that department's detail. - deptID, ok := shortcutOrgExtractDeptID(data) - if !ok { - return apperrors.NewValidation(fmt.Sprintf( - "没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+ - "TA 可能没有归属部门,或返回结构与预期不符。", - user.name, user.userID)) + profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids") + if err != nil { + return err } - return rt.CallMCP("get_dept_info_by_dept_id", map[string]any{ + deptID, err := strictPrimaryDeptID(profile, "contact/get_user_info_by_user_ids") + if err != nil { + return err + } + data, err = rt.CallMCPData("contact", "get_dept_info_by_dept_id", map[string]any{ "deptId": deptID, }) + if err != nil { + return err + } + dept, err := strictDeptDetail(data, deptID, "contact/get_dept_info_by_dept_id") + if err != nil { + return err + } + return rt.Output(map[string]any{"department": dept}) }, } @@ -179,5 +186,6 @@ func shortcutOrgToInt64(v any) (int64, bool) { } func init() { + finalizeContactSmart(&Org) shortcut.Register(Org) } diff --git a/internal/shortcut/smart/resolve_dept.go b/internal/shortcut/smart/resolve_dept.go index ae7ffab4..30d08e40 100644 --- a/internal/shortcut/smart/resolve_dept.go +++ b/internal/shortcut/smart/resolve_dept.go @@ -95,12 +95,15 @@ var ResolveDept = shortcut.Shortcut{ } // Project candidates to {deptId, name}, defensively unwrapping the list. - items := resolveDeptItems(data) + items, err := strictDeptCandidates(data, "contact/search_dept_by_keyword") + if err != nil { + return err + } candidates := make([]map[string]any, 0, len(items)) for _, d := range items { candidates = append(candidates, map[string]any{ - "deptId": resolveDeptID(d), - "name": resolveDeptName(d), + "deptId": strconv.FormatInt(d.id, 10), + "name": d.name, }) } @@ -197,5 +200,6 @@ func stripHighlightTags(s string) string { } func init() { + finalizeContactSmart(&ResolveDept) shortcut.Register(ResolveDept) } diff --git a/internal/shortcut/smart/team.go b/internal/shortcut/smart/team.go index 81a70417..f904e841 100644 --- a/internal/shortcut/smart/team.go +++ b/internal/shortcut/smart/team.go @@ -14,13 +14,11 @@ package smart import ( - "fmt" "strconv" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -77,7 +75,7 @@ var Team = shortcut.Shortcut{ Tips: []string{`dws contact +team --name 张三`}, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — resolve the person's name to a unique userId. - user, err := resolveUser(rt, rt.Str("name")) + user, err := strictResolveContactUser(rt, rt.Str("name")) if err != nil { return err } @@ -94,21 +92,31 @@ var Team = shortcut.Shortcut{ // Step 3 — defensively pull the primary deptId out of the response // (reuses the same extractor as +org), then list that department's // direct members. - deptID, ok := shortcutOrgExtractDeptID(data) - if !ok { - return apperrors.NewValidation(fmt.Sprintf( - "没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+ - "TA 可能没有归属部门,或返回结构与预期不符。", - user.name, user.userID)) + profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids") + if err != nil { + return err + } + deptID, err := strictPrimaryDeptID(profile, "contact/get_user_info_by_user_ids") + if err != nil { + return err } // get_dept_members_by_deptId expects deptIds as a string list. - return rt.CallMCP("get_dept_members_by_deptId", map[string]any{ + data, err = rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{ "deptIds": []string{strconv.FormatInt(deptID, 10)}, }) + if err != nil { + return err + } + members, err := strictContactMembers(data, "contact/get_dept_members_by_deptId") + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(members), "members": members}) }, } func init() { + finalizeContactSmart(&Team) shortcut.Register(Team) } diff --git a/internal/shortcut/smart/whoami.go b/internal/shortcut/smart/whoami.go index aee9d7e9..253cea2e 100644 --- a/internal/shortcut/smart/whoami.go +++ b/internal/shortcut/smart/whoami.go @@ -70,7 +70,11 @@ var Whoami = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(whoamiProject(data)) + profile, err := strictWhoami(data) + if err != nil { + return err + } + return rt.Output(profile) }, } @@ -159,5 +163,6 @@ func whoamiStr(m map[string]any, keys ...string) string { } func init() { + finalizeContactSmart(&Whoami) shortcut.Register(Whoami) } diff --git a/internal/shortcut/whiteboard/whiteboard.go b/internal/shortcut/whiteboard/whiteboard.go new file mode 100644 index 00000000..f3490ecb --- /dev/null +++ b/internal/shortcut/whiteboard/whiteboard.go @@ -0,0 +1,837 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package whiteboard declares strict Shortcut adapters for DingTalk document +// whiteboards. The document card remains owned by doc; this package only reads +// and updates the OpenNodes content of an already identified whiteboard part. +package whiteboard + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const ( + serverWhiteboard = "whiteboard" + toolQuery = "read_whiteboard_content" + toolUpdate = "update_whiteboard" +) + +var whiteboardMarshalNodes = json.Marshal + +type updateFile struct { + Overwrite bool `json:"overwrite"` + Source *openSource `json:"source"` +} + +type openSource struct { + SchemaVersion string `json:"schemaVersion"` + CatalogVersion string `json:"catalogVersion"` + Nodes json.RawMessage `json:"nodes"` +} + +type parsedUpdate struct { + Overwrite bool + NodesJSON string + Nodes []map[string]any +} + +func whiteboardReadSafety() contract.SafetySpec { + return contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } +} + +func whiteboardWriteSafety() contract.SafetySpec { + return contract.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func queryResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"严格校验且绑定到同一文档与白板 part 的 OpenNodes 快照", + "properties":{ + "nodeId":{"type":"string","description":"承载白板的稳定文档身份"}, + "partId":{"type":"string","description":"文档内白板的稳定 part 身份"}, + "source":{"type":"object","description":"显式 OpenNodes V1 快照,包含 pages 以及每页的 nodes 数组","additionalProperties":true}, + "summary":{"type":"object","description":"服务端完整性计数、字节数与摘要证据","additionalProperties":true}, + "message":{"type":"string","description":"可选服务说明"} + }, + "required":["nodeId","partId","source","summary"], + "additionalProperties":false + }`), + SensitivePaths: []string{"nodeId", "partId", "source.pages.nodes.id"}, + } +} + +func updateResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"白板更新回执与同一稳定目标的精确读回验证", + "properties":{ + "nodeId":{"type":"string","description":"承载白板的稳定文档身份"}, + "partId":{"type":"string","description":"文档内白板的稳定 part 身份"}, + "mode":{"type":"string","description":"实际执行的 append 或 overwrite 模式"}, + "verified":{"type":"boolean","description":"远端更新成功并完成独立读回时为 true;dry-run 请求预览为 false"}, + "verifiedNodeCount":{"type":"integer","description":"按请求稳定节点身份读回验证的节点数"}, + "source":{"type":"object","description":"更新后的严格 OpenNodes V1 快照","additionalProperties":true}, + "summary":{"type":"object","description":"更新后快照的完整性计数与摘要证据","additionalProperties":true}, + "receipt":{"type":"object","description":"显式 success=true 的下游写回执;最终成功仍以独立读回为准","additionalProperties":true} + }, + "required":["nodeId","partId","mode","verified","verifiedNodeCount","source","summary","receipt"], + "additionalProperties":false + }`), + SensitivePaths: []string{"nodeId", "partId", "source.pages.nodes.id", "receipt.resultJson.createdNodeIds", "receipt.resultJson.idMap"}, + } +} + +func whiteboardContract(command, name, description, interfaceReason string, result *contract.ResultSpec, dryRun *contract.DryRunSpec, params []contract.ParamDecl, useWhen, avoidWhen, example string) corecmd.ContractDecl { + path := "whiteboard " + command + return corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "whiteboard", Name: name, CanonicalPath: "whiteboard." + name, + CLIPath: path, PrimaryCLIPath: path, + }, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: interfaceReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{useWhen}, + AvoidWhen: []string{avoidWhen}, + Examples: []string{example}, + }, + Parameters: params, + Result: result, + DryRun: dryRun, + } +} + +// Query reads one already identified DingTalk document whiteboard. +var Query = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "whiteboard", + Command: "+query", + Product: serverWhiteboard, + Description: "严格读取已有文档白板的 OpenNodes 快照", + Intent: "已知文档 nodeId 与白板 partId,需要读取节点、页面和服务端完整性摘要,并拒绝未知或畸形结果时", + Risk: shortcut.RiskRead, + Safety: whiteboardReadSafety(), + Contract: whiteboardContract( + "+query", "shortcut_query", "严格读取已有文档白板的 OpenNodes 快照", + "Reviewed composite adapter validates the whiteboard success envelope, every explicit pages[].nodes collection, stable page/node identities, and summary completeness before unified output.", + queryResultSpec(), nil, + []contract.ParamDecl{ + {Name: "node", Property: "nodeId"}, + {Name: "part-id", Property: "partId"}, + }, + "已知文档 nodeId 与白板 partId,需要读取节点、页面和服务端完整性摘要,并拒绝未知或畸形结果时", + "创建白板卡片路由到 doc whiteboard insert;Lark 风格 preview/SVG/source 导出当前不可由本命令替代", + "dws whiteboard +query --node --part-id ", + ), + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "承载白板的钉钉文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "part-id", Type: shortcut.FlagString, Desc: "文档内白板 part ID;--part-id 去除空白后不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"part-id"}, Description: "--part-id 去除空白后不能为空"}, + }, + Tips: []string{"dws whiteboard +query --node --part-id "}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateWhiteboardTarget(rt) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData(serverWhiteboard, toolQuery, whiteboardTarget(rt)) + if err != nil { + return err + } + projected, err := projectWhiteboardQuery(data, rt.Str("node"), rt.Str("part-id")) + if err != nil { + return err + } + return rt.Output(projected) + }, +} + +// Update appends or overwrites verified OpenNodes on one stable whiteboard. +var Update = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "whiteboard", + Command: "+update", + Product: serverWhiteboard, + Description: "确认后更新白板并按同一稳定目标精确读回", + Intent: "已有合规 OpenNodes V1 内容,用户确认后要 append 或 overwrite,并要求按请求节点身份验证同一白板读回时", + Risk: shortcut.RiskHighWrite, + Safety: whiteboardWriteSafety(), + Contract: whiteboardContract( + "+update", "shortcut_update", "确认后更新白板并按同一稳定目标精确读回", + "Reviewed composite adapter validates OpenNodes locally, requires confirmation, verifies the terminal receipt and request-to-real ID mapping, then reads the same target back exactly.", + updateResultSpec(), &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false}, + []contract.ParamDecl{ + {Name: "node", Property: "nodeId"}, + {Name: "part-id", Property: "partId"}, + {Name: "source", Property: "source"}, + }, + "已有合规 OpenNodes V1 内容,用户确认后要 append 或 overwrite,并要求按请求节点身份验证同一白板读回时", + "只读使用 whiteboard +query;创建卡片使用 doc whiteboard insert;Mermaid、PlantUML、SVG 和真实节点局部更新当前不可用", + `dws whiteboard +update --node --part-id --source '{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"sample-shape","type":"shape","x":40,"y":40,"width":120,"height":80,"geometry":"dml:roundRect"}]}}'`, + ), + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "承载白板的钉钉文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "part-id", Type: shortcut.FlagString, Desc: "文档内白板 part ID;--part-id 去除空白后不能为空", Required: true}, + {Name: "source", Type: shortcut.FlagString, Desc: "OpenNodes V1 JSON,不能为空;支持字面量、@相对文件或 - 从 stdin 读取", Required: true, Input: []string{"file", "stdin"}}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"part-id"}, Description: "--part-id 去除空白后不能为空"}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"source"}, + Description: "--source 不能为空且必须是单一 OpenNodes V1 对象;source.nodes 是含稳定唯一 id 和非空 type 的显式数组,append 模式至少一个节点", + }, + }, + Tips: []string{"dws whiteboard +update --node --part-id --source @whiteboard.json"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := validateWhiteboardTarget(rt); err != nil { + return err + } + _, err := parseWhiteboardSource(rt.Str("source")) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + parsed, err := parseWhiteboardSource(rt.Str("source")) + if err != nil { + return err + } + mode := "append" + if parsed.Overwrite { + mode = "overwrite" + } + target := whiteboardTarget(rt) + request := map[string]any{ + "nodeId": target["nodeId"], "partId": target["partId"], + "mode": mode, "nodes": parsed.NodesJSON, + } + if rt.DryRun() { + return rt.Output(map[string]any{ + "nodeId": target["nodeId"], "partId": target["partId"], "mode": mode, + "verified": false, "verifiedNodeCount": 0, + "source": map[string]any{"schemaVersion": "1.0", "catalogVersion": "dml-v1", "nodes": parsed.Nodes, "pages": []any{}}, + "summary": map[string]any{"preview": true}, "receipt": map[string]any{"dryRun": true, "executed": false}, + }) + } + + receiptData, err := rt.CallMCPWriteDataStrict(serverWhiteboard, toolUpdate, request) + if err != nil { + return err + } + receipt, err := requireWhiteboardUpdateReceipt(receiptData, target, mode, parsed) + if err != nil { + return err + } + readback, err := rt.CallMCPData(serverWhiteboard, toolQuery, target) + if err != nil { + return err + } + projected, err := projectWhiteboardQuery(readback, rt.Str("node"), rt.Str("part-id")) + if err != nil { + return err + } + if err := verifyWhiteboardUpdate(parsed, projected, receipt.IDMap); err != nil { + return err + } + return rt.Output(map[string]any{ + "nodeId": target["nodeId"], "partId": target["partId"], "mode": mode, + "verified": true, "verifiedNodeCount": len(parsed.Nodes), + "source": projected["source"], "summary": projected["summary"], "receipt": receipt.Envelope, + }) + }, +} + +func validateWhiteboardTarget(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("node")) == "" { + return apperrors.NewValidation("--node 去除空白后不能为空") + } + if strings.TrimSpace(rt.Str("part-id")) == "" { + return apperrors.NewValidation("--part-id 去除空白后不能为空") + } + return nil +} + +func whiteboardTarget(rt *shortcut.RuntimeContext) map[string]any { + return map[string]any{"nodeId": rt.Str("node"), "partId": rt.Str("part-id")} +} + +func parseWhiteboardSource(raw string) (*parsedUpdate, error) { + if strings.TrimSpace(raw) == "" { + return nil, apperrors.NewValidation("--source 必须是非空 OpenNodes V1 JSON") + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + decoder.UseNumber() + var input updateFile + if err := decoder.Decode(&input); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--source 不是合法 OpenNodes V1 JSON: %v", err)) + } + if err := requireJSONEOF(decoder); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--source 不是单一 JSON 对象: %v", err)) + } + if input.Source == nil { + return nil, apperrors.NewValidation("--source 缺少 source 对象") + } + if input.Source.SchemaVersion != "1.0" { + return nil, apperrors.NewValidation(`--source source.schemaVersion 必须为 "1.0"`) + } + if input.Source.CatalogVersion != "dml-v1" { + return nil, apperrors.NewValidation(`--source source.catalogVersion 必须为 "dml-v1"`) + } + nodes, err := decodeNodeArray(input.Source.Nodes, "--source source.nodes") + if err != nil { + return nil, err + } + if !input.Overwrite && len(nodes) == 0 { + return nil, apperrors.NewValidation("append 模式至少需要一个 source.nodes 节点") + } + encoded, err := whiteboardMarshalNodes(nodes) + if err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("编码 OpenNodes 请求失败: %v", err)) + } + return &parsedUpdate{Overwrite: input.Overwrite, NodesJSON: string(encoded), Nodes: nodes}, nil +} + +func decodeNodeArray(raw json.RawMessage, field string) ([]map[string]any, error) { + if len(raw) == 0 { + return nil, apperrors.NewValidation(field + " 必须是显式数组") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var values []any + if err := decoder.Decode(&values); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是数组: %v", field, err)) + } + if values == nil { + return nil, apperrors.NewValidation(field + " 不能为 null") + } + nodes := make([]map[string]any, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + node, ok := value.(map[string]any) + if !ok || len(node) == 0 { + return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d] 必须是非空对象", field, index)) + } + id, ok := nonEmptyString(node["id"]) + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d].id 必须是非空稳定身份", field, index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 含重复节点 id %q", field, id)) + } + seen[id] = struct{}{} + if _, ok := nonEmptyString(node["type"]); !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d].type 必须是非空字符串", field, index)) + } + nodes[index] = node + } + return nodes, nil +} + +func projectWhiteboardQuery(data map[string]any, nodeID, partID string) (map[string]any, error) { + envelope, err := requireWhiteboardSuccess(data, toolQuery) + if err != nil { + return nil, err + } + value, present := envelope["resultJson"] + if !present || value == nil { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_json", "成功响应缺少非空 resultJson") + } + result, err := decodeResultJSON(value) + if err != nil { + return nil, err + } + if version, ok := nonEmptyString(result["schemaVersion"]); !ok || version != "1.0" { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "invalid_schema_version", `resultJson.schemaVersion 必须为 "1.0"`) + } + if version, ok := nonEmptyString(result["catalogVersion"]); !ok || version != "dml-v1" { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "invalid_catalog_version", `resultJson.catalogVersion 必须为 "dml-v1"`) + } + pagesValue, present := result["pages"] + if !present { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_pages", "resultJson 缺少显式 pages 数组") + } + pages, nodes, err := validateWhiteboardPages(pagesValue) + if err != nil { + return nil, err + } + summaryValue, present := envelope["resultSummary"] + if !present { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_summary", "成功响应缺少 resultSummary 完整性证据") + } + summary, ok := summaryValue.(map[string]any) + if !ok || len(summary) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_result_summary", "resultSummary 必须是非空对象") + } + if err := validateSummary(summary, len(nodes), len(pages)); err != nil { + return nil, err + } + result["pages"] = mapsToAny(pages) + out := map[string]any{ + "nodeId": strings.TrimSpace(nodeID), "partId": strings.TrimSpace(partID), + "source": result, "summary": summary, + } + if message, present := envelope["message"]; present { + text, ok := message.(string) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_message", "响应 message 必须是字符串") + } + out["message"] = text + } + return out, nil +} + +func requireWhiteboardSuccess(data map[string]any, tool string) (map[string]any, error) { + envelope, err := responsecheck.RequireSuccess(data, serverWhiteboard+"/"+tool) + if err != nil { + return nil, err + } + for _, key := range []string{"error", "errorMessage", "errorMsg"} { + if text, ok := envelope[key].(string); ok && strings.TrimSpace(text) != "" { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "conflicting_error", "响应同时声明 success=true 与非空 "+key) + } + } + return envelope, nil +} + +type verifiedUpdateReceipt struct { + Envelope map[string]any + Result map[string]any + IDMap map[string]string +} + +func requireWhiteboardUpdateReceipt(data, target map[string]any, mode string, expected *parsedUpdate) (*verifiedUpdateReceipt, error) { + receipt, err := requireWhiteboardSuccess(data, toolUpdate) + if err != nil { + return nil, err + } + for _, field := range []string{"nodeId", "partId"} { + value, ok := nonEmptyString(receipt[field]) + wanted, _ := target[field].(string) + if !ok || value != strings.TrimSpace(wanted) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_target_mismatch", "写回执的 "+field+" 与请求稳定目标不一致") + } + } + value, present := receipt["resultJson"] + if !present || value == nil { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_terminal_receipt", "成功写响应缺少非空 resultJson 终态回执;远端效果未知") + } + result, err := decodeResultJSONForTool(value, toolUpdate) + if err != nil { + return nil, err + } + receiptMode, ok := nonEmptyString(result["mode"]) + if !ok || receiptMode != mode { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_mode_mismatch", "写回执 mode 与请求不一致") + } + message, ok := nonEmptyString(result["message"]) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_terminal_receipt", "成功写响应缺少非空 resultJson.message 终态回执;远端效果未知") + } + result["message"] = message + created, err := nonEmptyStringArray(result["createdNodeIds"], "resultJson.createdNodeIds") + if err != nil { + return nil, err + } + if len(created) != len(expected.Nodes) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_count_mismatch", "createdNodeIds 数量与请求节点数不一致") + } + idMapValue, ok := result["idMap"].(map[string]any) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_id_map", "resultJson.idMap 必须是显式对象") + } + idMap := make(map[string]string, len(idMapValue)) + for key, raw := range idMapValue { + requestID := strings.TrimSpace(key) + realID, valid := nonEmptyString(raw) + if requestID == "" || !valid { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_id_map", "resultJson.idMap 含空请求或真实节点身份") + } + idMap[requestID] = realID + } + if len(idMap) != len(expected.Nodes) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_count_mismatch", "idMap 数量与请求节点数不一致") + } + for index, node := range expected.Nodes { + requestID, _ := nonEmptyString(node["id"]) + if idMap[requestID] != created[index] { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_identity_mismatch", "idMap 没有按请求顺序精确映射 createdNodeIds") + } + } + deleted, ok := nonNegativeInt(result["deletedNodeCount"]) + if !ok || (!expected.Overwrite && deleted != 0) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_deleted_count", "deletedNodeCount 必须是非负整数,append 时必须为 0") + } + receipt["resultJson"] = result + return &verifiedUpdateReceipt{Envelope: receipt, Result: result, IDMap: idMap}, nil +} + +func decodeResultJSON(value any) (map[string]any, error) { + return decodeResultJSONForTool(value, toolQuery) +} + +func decodeResultJSONForTool(value any, tool string) (map[string]any, error) { + switch typed := value.(type) { + case map[string]any: + if len(typed) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "empty_result_json", "resultJson 对象为空") + } + return typed, nil + case string: + if strings.TrimSpace(typed) == "" { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "empty_result_json", "resultJson 字符串为空") + } + decoder := json.NewDecoder(strings.NewReader(typed)) + decoder.UseNumber() + var result map[string]any + if err := decoder.Decode(&result); err != nil { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "invalid_result_json", fmt.Sprintf("resultJson 不是合法 JSON 对象: %v", err)) + } + if err := requireJSONEOF(decoder); err != nil || len(result) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "invalid_result_json", "resultJson 必须是单一非空 JSON 对象") + } + return result, nil + default: + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "malformed_result_json", fmt.Sprintf("resultJson 应为对象或 JSON 字符串,实际为 %T", value)) + } +} + +func validateWhiteboardPages(value any) ([]map[string]any, []map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_collection", fmt.Sprintf("resultJson.pages 必须是数组,实际为 %T", value)) + } + if len(raw) == 0 { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_page", "单页白板必须显式返回一个 page") + } + pages := make([]map[string]any, len(raw)) + nodes := make([]map[string]any, 0) + pageIDs := make(map[string]struct{}, len(raw)) + nodeIDs := make(map[string]struct{}) + for pageIndex, item := range raw { + page, ok := item.(map[string]any) + if !ok || len(page) == 0 { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_item", fmt.Sprintf("resultJson.pages[%d] 必须是非空对象", pageIndex)) + } + pageID, ok := nonEmptyString(page["id"]) + if !ok { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_page_identity", fmt.Sprintf("resultJson.pages[%d].id 必须是非空稳定身份", pageIndex)) + } + if _, duplicate := pageIDs[pageID]; duplicate { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "duplicate_page_identity", "resultJson.pages 含重复 page id") + } + pageIDs[pageID] = struct{}{} + nodesValue, present := page["nodes"] + if !present { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_nodes", fmt.Sprintf("resultJson.pages[%d] 缺少显式 nodes 数组", pageIndex)) + } + pageNodes, err := whiteboardNodeArray(nodesValue, fmt.Sprintf("resultJson.pages[%d].nodes", pageIndex), nodeIDs) + if err != nil { + return nil, nil, err + } + page["id"] = pageID + page["nodes"] = mapsToAny(pageNodes) + pages[pageIndex] = page + nodes = append(nodes, pageNodes...) + } + return pages, nodes, nil +} + +func whiteboardNodeArray(value any, field string, seen map[string]struct{}) ([]map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_collection", fmt.Sprintf("%s 必须是数组,实际为 %T", field, value)) + } + items := make([]map[string]any, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_item", fmt.Sprintf("%s[%d] 必须是非空对象", field, index)) + } + id, ok := nonEmptyString(object["id"]) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_node_identity", fmt.Sprintf("%s[%d].id 必须是非空稳定身份", field, index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "duplicate_node_identity", "resultJson.pages 跨页含重复节点 id") + } + seen[id] = struct{}{} + if _, ok := nonEmptyString(object["type"]); !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_node_type", fmt.Sprintf("%s[%d].type 必须是非空字符串", field, index)) + } + object["id"] = id + items[index] = object + } + return items, nil +} + +func nonEmptyStringArray(value any, field string) ([]string, error) { + raw, ok := value.([]any) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_collection", field+" 必须是显式数组") + } + result := make([]string, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for index, item := range raw { + id, ok := nonEmptyString(item) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_item", fmt.Sprintf("%s[%d] 必须是非空身份", field, index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "duplicate_node_identity", field+" 含重复节点身份") + } + seen[id] = struct{}{} + result[index] = id + } + return result, nil +} + +func validateSummary(summary map[string]any, nodeCount, pageCount int) error { + for key, expected := range map[string]int{"nodeCount": nodeCount, "pageCount": pageCount} { + value, ok := nonNegativeInt(summary[key]) + if !ok || value != expected { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "summary_count_mismatch", fmt.Sprintf("resultSummary.%s 必须等于显式数组长度 %d", key, expected)) + } + } + readOnly, ok := nonNegativeInt(summary["readOnlyNodeCount"]) + if !ok || readOnly > nodeCount { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_read_only_count", "resultSummary.readOnlyNodeCount 必须是不超过 nodeCount 的非负整数") + } + unknown, ok := nonNegativeInt(summary["unknownNodeCount"]) + if !ok || unknown > nodeCount { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_unknown_count", "resultSummary.unknownNodeCount 必须是不超过 nodeCount 的非负整数") + } + if _, ok := nonNegativeInt(summary["resultBytes"]); !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_result_bytes", "resultSummary.resultBytes 必须是非负整数") + } + if _, ok := nonEmptyString(summary["resultSha256"]); !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_digest", "resultSummary.resultSha256 必须是非空字符串") + } + return nil +} + +func verifyWhiteboardUpdate(expected *parsedUpdate, projected map[string]any, idMap map[string]string) error { + source, ok := projected["source"].(map[string]any) + if !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_readback_source", "更新后读回缺少 OpenNodes source") + } + _, readback, err := validateWhiteboardPages(source["pages"]) + if err != nil { + return err + } + byID := make(map[string]map[string]any, len(readback)) + for _, node := range readback { + id, _ := nonEmptyString(node["id"]) + byID[id] = node + } + if expected.Overwrite && whiteboardPageOwnedNodeCount(readback) != len(expected.Nodes) { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "overwrite_count_mismatch", "overwrite 后读回节点数量与请求不一致") + } + for _, requested := range expected.Nodes { + requestID, _ := nonEmptyString(requested["id"]) + realID := idMap[requestID] + actual := byID[realID] + if actual == nil { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_identity_mismatch", fmt.Sprintf("更新后未按回执真实身份读回请求节点 %q", requestID)) + } + requestedType, _ := nonEmptyString(requested["type"]) + actualType, _ := nonEmptyString(actual["type"]) + if requestedType != actualType { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_type_mismatch", fmt.Sprintf("节点 %q 的 type 读回不一致", requestID)) + } + critical := make(map[string]any, len(requested)-1) + for key, value := range requested { + if key != "id" { + critical[key] = normalizeRequestedReadback(value, idMap, key) + } + } + if err := requireRequestedValue(critical, actual, "node "+requestID); err != nil { + return err + } + } + return nil +} + +func normalizeRequestedReadback(value any, idMap map[string]string, field string) any { + switch typed := value.(type) { + case map[string]any: + normalized := make(map[string]any, len(typed)) + requestScope, isRequestRef := typed["scope"].(string) + requestID, hasRequestID := nonEmptyString(typed["id"]) + for key, child := range typed { + normalized[key] = normalizeRequestedReadback(child, idMap, key) + } + if isRequestRef && requestScope == "request" && hasRequestID { + if realID := idMap[requestID]; realID != "" { + normalized["scope"] = "document" + normalized["id"] = realID + } + } + return normalized + case []any: + normalized := make([]any, len(typed)) + for index, child := range typed { + normalized[index] = normalizeRequestedReadback(child, idMap, field) + } + return normalized + case string: + if field == "parentId" { + if realID := idMap[typed]; realID != "" { + return realID + } + } + } + return value +} + +func whiteboardPageOwnedNodeCount(nodes []map[string]any) int { + count := 0 + for _, node := range nodes { + if source, ok := nonEmptyString(node["source"]); ok && source == "master" { + continue + } + count++ + } + return count +} + +func requireRequestedValue(expected, actual any, path string) error { + switch wanted := expected.(type) { + case map[string]any: + got, ok := actual.(map[string]any) + if !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 读回类型不一致", path)) + } + for key, value := range wanted { + readback, present := got[key] + if !present { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_missing", fmt.Sprintf("%s.%s 未读回", path, key)) + } + if err := requireRequestedValue(value, readback, path+"."+key); err != nil { + return err + } + } + return nil + case []any: + got, ok := actual.([]any) + if !ok || len(got) != len(wanted) { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 数组读回不一致", path)) + } + for index := range wanted { + if err := requireRequestedValue(wanted[index], got[index], fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + return nil + } + if expectedNumber, expectedOK := numericValue(expected); expectedOK { + actualNumber, actualOK := numericValue(actual) + if !actualOK || expectedNumber.Cmp(actualNumber) != 0 { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 数值读回不一致", path)) + } + return nil + } + if expected != actual { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 读回值不一致", path)) + } + return nil +} + +func numericValue(value any) (*big.Rat, bool) { + switch typed := value.(type) { + case json.Number: + parsed, ok := new(big.Rat).SetString(typed.String()) + return parsed, ok + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) { + return nil, false + } + return new(big.Rat).SetFloat64(typed), true + case int: + return new(big.Rat).SetInt64(int64(typed)), true + default: + return nil, false + } +} + +func requireJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return fmt.Errorf("multiple JSON values are not allowed") + } else if !errorsIsEOF(err) { + return err + } + return nil +} + +func errorsIsEOF(err error) bool { return err == io.EOF } + +func nonEmptyString(value any) (string, bool) { + text, ok := value.(string) + text = strings.TrimSpace(text) + return text, ok && text != "" +} + +func nonNegativeInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, typed >= 0 + case float64: + if typed < 0 || typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) { + return 0, false + } + return int(typed), true + case json.Number: + parsed, err := strconv.ParseInt(typed.String(), 10, 64) + if err != nil || parsed < 0 || parsed > int64(math.MaxInt) { + return 0, false + } + return int(parsed), true + default: + return 0, false + } +} + +func mapsToAny(values []map[string]any) []any { + out := make([]any, len(values)) + for index := range values { + out[index] = values[index] + } + return out +} + +func init() { + shortcut.Register(Query, Update) +} diff --git a/internal/shortcut/whiteboard/whiteboard_test.go b/internal/shortcut/whiteboard/whiteboard_test.go new file mode 100644 index 00000000..1687956b --- /dev/null +++ b/internal/shortcut/whiteboard/whiteboard_test.go @@ -0,0 +1,709 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package whiteboard + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "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 whiteboardCoverageCall struct { + server string + tool string + args map[string]any +} + +type whiteboardCoverageCaller struct { + responses map[string][]string + calls []whiteboardCoverageCall + dry bool +} + +func (caller *whiteboardCoverageCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, whiteboardCoverageCall{server: server, tool: tool, args: args}) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*whiteboardCoverageCaller) Format() string { return "json" } +func (caller *whiteboardCoverageCaller) DryRun() bool { return caller.dry } +func (*whiteboardCoverageCaller) Fields() string { return "" } +func (*whiteboardCoverageCaller) JQ() string { return "" } + +func runWhiteboardCoverage(t *testing.T, declaration shortcut.Shortcut, caller *whiteboardCoverageCaller, stdin string, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetIn(strings.NewReader(stdin)) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "whiteboard"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetArgs(append([]string{"whiteboard", declaration.Command}, args...)) + return root.Execute() +} + +func directWhiteboardRuntime(t *testing.T, declaration shortcut.Shortcut, caller *whiteboardCoverageCaller, args ...string) *shortcut.RuntimeContext { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func validWhiteboardQueryResponse(nodes string) string { + var decoded []any + if err := json.Unmarshal([]byte(nodes), &decoded); err != nil { + panic(err) + } + result, err := json.Marshal(map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + "pages": []any{map[string]any{"id": "page-1", "nodes": decoded}}, + }) + if err != nil { + panic(err) + } + envelope, err := json.Marshal(map[string]any{ + "success": true, "resultJson": string(result), + "resultSummary": map[string]any{ + "nodeCount": len(decoded), "pageCount": 1, "readOnlyNodeCount": 0, + "unknownNodeCount": 0, "resultBytes": len(result), "resultSha256": "0123456789abcdef", + }, + }) + if err != nil { + panic(err) + } + return string(envelope) +} + +func validWhiteboardUpdateResponse(mode string, requestIDs, realIDs []string, deleted int) string { + idMap := make(map[string]any, len(requestIDs)) + created := make([]any, len(realIDs)) + for index := range requestIDs { + idMap[requestIDs[index]] = realIDs[index] + created[index] = realIDs[index] + } + result, err := json.Marshal(map[string]any{ + "mode": mode, "createdNodeIds": created, "idMap": idMap, + "deletedNodeCount": deleted, "message": "completed", + }) + if err != nil { + panic(err) + } + envelope, err := json.Marshal(map[string]any{ + "success": true, "nodeId": "doc", "partId": "part", "resultJson": string(result), + }) + if err != nil { + panic(err) + } + return string(envelope) +} + +func TestCrossPlatformCoverageWhiteboardQueryRejectsFalseSuccessAndMalformedNodes(t *testing.T) { + broken := map[string]map[string]any{ + "empty": {}, + "missing success": {"resultJson": map[string]any{}}, + "success false": {"success": false, "resultJson": map[string]any{}}, + "success wrong type": {"success": "true", "resultJson": map[string]any{}}, + "missing result": {"success": true}, + "result wrong type": {"success": true, "resultJson": []any{}}, + "result invalid string": {"success": true, "resultJson": "{"}, + "missing pages": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + }}, + "empty pages": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{}, + }}, + "missing page id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"nodes": []any{}}}, + }}, + "duplicate page id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{ + map[string]any{"id": "page", "nodes": []any{}}, map[string]any{"id": "page", "nodes": []any{}}, + }, + }}, + "missing nodes": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page"}}, + }}, + "nodes wrong type": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": map[string]any{}}}, + }}, + "bad node": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{"bad"}}}, + }}, + "missing node id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{map[string]any{"type": "text"}}}}, + }}, + "cross-page duplicate node id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{ + map[string]any{"id": "page-1", "nodes": []any{map[string]any{"id": "same", "type": "text"}}}, + map[string]any{"id": "page-2", "nodes": []any{map[string]any{"id": "same", "type": "shape"}}}, + }, + }}, + "missing summary": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}}, + }}, + "summary count conflict": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}}, + }, "resultSummary": map[string]any{ + "nodeCount": float64(1), "pageCount": float64(1), "readOnlyNodeCount": float64(0), + "unknownNodeCount": float64(0), "resultBytes": float64(2), "resultSha256": "hash", + }}, + } + for name, payload := range broken { + t.Run(name, func(t *testing.T) { + if _, err := projectWhiteboardQuery(payload, "doc", "part"); err == nil { + t.Fatalf("payload unexpectedly accepted: %#v", payload) + } + }) + } + + explicitEmpty := map[string]any{ + "success": true, + "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}}, + }, + "resultSummary": map[string]any{ + "nodeCount": float64(0), "pageCount": float64(1), "readOnlyNodeCount": float64(0), + "unknownNodeCount": float64(0), "resultBytes": float64(2), "resultSha256": "hash", + }, + } + got, err := projectWhiteboardQuery(explicitEmpty, "doc", "part") + if err != nil || got["nodeId"] != "doc" || got["partId"] != "part" { + t.Fatalf("explicit empty result = %#v, err=%v", got, err) + } + source := got["source"].(map[string]any) + pages := source["pages"].([]any) + if len(pages) != 1 || len(pages[0].(map[string]any)["nodes"].([]any)) != 0 { + t.Fatalf("explicit nested empty was not preserved: %#v", source) + } +} + +func TestCrossPlatformCoverageWhiteboardSourceValidationFailsClosed(t *testing.T) { + for name, source := range map[string]string{ + "empty": "", + "invalid json": "{", + "trailing json": `{} {}`, + "missing source": `{}`, + "unknown top field": `{"extra":true}`, + "wrong schema": `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`, + "wrong catalog": `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`, + "missing nodes": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`, + "nodes wrong type": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":{}}}`, + "append empty": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`, + "bad node": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`, + "missing node id": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"type":"text"}]}}`, + "duplicate node id": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"same","type":"text"},{"id":"same","type":"shape"}]}}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := parseWhiteboardSource(source); err == nil { + t.Fatalf("source unexpectedly accepted: %q", source) + } + }) + } + + for _, source := range []string{ + `{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`, + `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`, + } { + if _, err := parseWhiteboardSource(source); err != nil { + t.Fatalf("valid source rejected: %v", err) + } + } +} + +func TestCrossPlatformCoverageWhiteboardExactToolsConfirmationAndReadback(t *testing.T) { + queryCaller := &whiteboardCoverageCaller{responses: map[string][]string{ + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"n1","type":"text"}]`)}, + }} + if err := runWhiteboardCoverage(t, Query, queryCaller, "", "--node", "doc", "--part-id", "part"); err != nil { + t.Fatal(err) + } + if len(queryCaller.calls) != 1 || queryCaller.calls[0].server != "whiteboard" || queryCaller.calls[0].tool != "read_whiteboard_content" { + t.Fatalf("query calls = %#v", queryCaller.calls) + } + + source := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + declined := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, declined, "", "--node", "doc", "--part-id", "part", "--source", source); err == nil { + t.Fatal("update without confirmation unexpectedly succeeded") + } + if len(declined.calls) != 0 { + t.Fatalf("calls before confirmation = %#v", declined.calls) + } + + invalid := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, invalid, "", "--node", "doc", "--part-id", "part", "--source", `{}`, "--yes"); err == nil { + t.Fatal("invalid update source unexpectedly succeeded") + } + if len(invalid.calls) != 0 { + t.Fatalf("invalid source reached remote calls = %#v", invalid.calls) + } + + dryRun := &whiteboardCoverageCaller{dry: true, responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, dryRun, "", "--node", "doc", "--part-id", "part", "--source", source, "--dry-run"); err != nil { + t.Fatal(err) + } + if len(dryRun.calls) != 0 { + t.Fatalf("dry-run reached remote calls = %#v", dryRun.calls) + } + + missingReceipt := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {`{"success":true}`}, + }} + if err := runWhiteboardCoverage(t, Update, missingReceipt, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil { + t.Fatal("empty terminal receipt unexpectedly succeeded") + } + if len(missingReceipt.calls) != 1 { + t.Fatalf("missing receipt calls = %#v", missingReceipt.calls) + } + + verified := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"real-1","type":"text","source":"page"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, verified, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err != nil { + t.Fatal(err) + } + if len(verified.calls) != 2 || verified.calls[0].tool != "update_whiteboard" || verified.calls[1].tool != "read_whiteboard_content" { + t.Fatalf("verified calls = %#v", verified.calls) + } + if verified.calls[0].args["nodeId"] != "doc" || verified.calls[0].args["partId"] != "part" || verified.calls[1].args["nodeId"] != "doc" || verified.calls[1].args["partId"] != "part" { + t.Fatalf("stable target identity was not preserved: %#v", verified.calls) + } + + mismatch := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"other","type":"text"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, mismatch, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil { + t.Fatal("readback mismatch unexpectedly succeeded") + } + if len(mismatch.calls) != 2 { + t.Fatalf("readback mismatch calls = %#v", mismatch.calls) + } + + richSource := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text","text":"wanted","style":{"fontSize":16},"points":[1,2]}]}}` + fieldMismatch := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"real-1","type":"text","text":"different","style":{"fontSize":16},"points":[1,2]}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, fieldMismatch, "", "--node", "doc", "--part-id", "part", "--source", richSource, "--yes"); err == nil { + t.Fatal("request-critical field mismatch unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageWhiteboardReceiptRejectsFalseSuccessAndBadIdentityEvidence(t *testing.T) { + source := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + for name, response := range map[string]string{ + "missing target": `{"success":true,"resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`, + "target mismatch": `{"success":true,"nodeId":"other","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`, + "missing collections": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","deletedNodeCount":0,"message":"done"}}`, + "created wrong type": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":{},"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`, + "id map wrong type": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":[],"deletedNodeCount":0,"message":"done"}}`, + "identity disagreement": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-2"},"deletedNodeCount":0,"message":"done"}}`, + "missing message": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0}}`, + "append deleted nodes": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":1,"message":"done"}}`, + } { + t.Run(name, func(t *testing.T) { + caller := &whiteboardCoverageCaller{responses: map[string][]string{"update_whiteboard": {response}}} + if err := runWhiteboardCoverage(t, Update, caller, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil { + t.Fatal("malformed terminal receipt unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("malformed receipt calls = %#v", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageWhiteboardReadbackNormalizesRequestScopedReferences(t *testing.T) { + idMap := map[string]string{"frame": "real-frame", "left": "real-left"} + requested := map[string]any{ + "parentId": "frame", + "start": map[string]any{ + "type": "node", + "nodeRef": map[string]any{"scope": "request", "id": "left"}, + }, + } + actual := map[string]any{ + "parentId": "real-frame", + "start": map[string]any{ + "type": "node", + "nodeRef": map[string]any{"scope": "document", "id": "real-left"}, + "resolvedPoint": map[string]any{"x": float64(1), "y": float64(2)}, + }, + } + normalized := normalizeRequestedReadback(requested, idMap, "node") + if err := requireRequestedValue(normalized, actual, "node connector"); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageWhiteboardPublicContractsStayStrictAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{Query, Update} { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s missing Contract.Result", declaration.Command) + } + if declaration.Contract.Pagination != nil { + t.Errorf("%s publishes unsupported pagination", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" || strings.TrimSpace(declaration.Contract.Interface.Reason) == "" { + t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface) + } + } + if Query.Safety.Confirmation != "not_required" || Query.Safety.Effect != "read" { + t.Errorf("query safety=%+v", Query.Safety) + } + if Update.Safety.Confirmation != "user_required" || Update.Safety.Effect != "write" { + t.Errorf("update safety=%+v", Update.Safety) + } +} + +func TestCrossPlatformCoverageWhiteboardExecutorErrorsAndExactCallOrder(t *testing.T) { + queryCallError := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Query, queryCallError, "", "--node", "doc", "--part-id", "part"); err == nil || len(queryCallError.calls) != 1 { + t.Fatalf("query call error=%v calls=%#v", err, queryCallError.calls) + } + queryProjectionError := &whiteboardCoverageCaller{responses: map[string][]string{ + toolQuery: {`{"success":true}`}, + }} + if err := runWhiteboardCoverage(t, Query, queryProjectionError, "", "--node", "doc", "--part-id", "part"); err == nil || len(queryProjectionError.calls) != 1 { + t.Fatalf("query projection error=%v calls=%#v", err, queryProjectionError.calls) + } + + invalidDirect := directWhiteboardRuntime(t, Update, &whiteboardCoverageCaller{responses: map[string][]string{}}, + "--node", "doc", "--part-id", "part", "--source", `{}`) + if err := Update.Execute(invalidDirect); err == nil { + t.Fatal("direct update accepted invalid source") + } + + overwriteSource := `{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}` + overwriteDryRun := &whiteboardCoverageCaller{dry: true, responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, overwriteDryRun, "", "--node", "doc", "--part-id", "part", "--source", overwriteSource, "--dry-run"); err != nil { + t.Fatal(err) + } + if len(overwriteDryRun.calls) != 0 { + t.Fatalf("overwrite dry-run calls=%#v", overwriteDryRun.calls) + } + + appendSource := `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + writeCallError := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, writeCallError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(writeCallError.calls) != 1 || writeCallError.calls[0].tool != toolUpdate { + t.Fatalf("write call error=%v calls=%#v", err, writeCallError.calls) + } + + readCallError := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + }} + if err := runWhiteboardCoverage(t, Update, readCallError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(readCallError.calls) != 2 || readCallError.calls[1].tool != toolQuery { + t.Fatalf("read call error=%v calls=%#v", err, readCallError.calls) + } + + readProjectionError := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + toolQuery: {`{"success":true}`}, + }} + if err := runWhiteboardCoverage(t, Update, readProjectionError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(readProjectionError.calls) != 2 { + t.Fatalf("read projection error=%v calls=%#v", err, readProjectionError.calls) + } + + typeMismatch := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + toolQuery: {validWhiteboardQueryResponse(`[{"id":"real-1","type":"shape"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, typeMismatch, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(typeMismatch.calls) != 2 { + t.Fatalf("type mismatch error=%v calls=%#v", err, typeMismatch.calls) + } + + overwriteVerified := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("overwrite", nil, nil, 1)}, + toolQuery: {validWhiteboardQueryResponse(`[{"id":"master-1","type":"shape","source":"master"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, overwriteVerified, "", "--node", "doc", "--part-id", "part", "--source", overwriteSource, "--yes"); err != nil { + t.Fatal(err) + } + if len(overwriteVerified.calls) != 2 || overwriteVerified.calls[0].args["mode"] != "overwrite" || overwriteVerified.calls[1].tool != toolQuery { + t.Fatalf("overwrite call order=%#v", overwriteVerified.calls) + } +} + +func TestCrossPlatformCoverageWhiteboardQueryEnvelopeSummaryAndMessageMatrix(t *testing.T) { + baseResult := func() map[string]any { + return map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + "pages": []any{ + map[string]any{ + "id": "page", + "nodes": []any{ + map[string]any{"id": "node", "type": "text"}, + }, + }, + }, + } + } + baseSummary := func() map[string]any { + return map[string]any{ + "nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, + "unknownNodeCount": 0, "resultBytes": 1, "resultSha256": "hash", + } + } + fixtures := map[string]map[string]any{ + "wrong schema": {"success": true, "resultJson": map[string]any{"schemaVersion": "2.0", "catalogVersion": "dml-v1", "pages": []any{}}, "resultSummary": baseSummary()}, + "wrong catalog": {"success": true, "resultJson": map[string]any{"schemaVersion": "1.0", "catalogVersion": "v2", "pages": []any{}}, "resultSummary": baseSummary()}, + "bad page item": {"success": true, "resultJson": map[string]any{"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{"bad"}}, "resultSummary": baseSummary()}, + "missing node type": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + "pages": []any{ + map[string]any{ + "id": "page", + "nodes": []any{ + map[string]any{"id": "node"}, + }, + }, + }, + }, "resultSummary": baseSummary()}, + "empty summary": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{}}, + "wrong message": {"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "message": 1}, + "conflicting error": {"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "errorMsg": "failed"}, + "read only too high": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 2, "unknownNodeCount": 0, "resultBytes": 1, "resultSha256": "hash"}}, + "unknown too high": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 2, "resultBytes": 1, "resultSha256": "hash"}}, + "bad result bytes": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 0, "resultBytes": -1, "resultSha256": "hash"}}, + "missing digest": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 0, "resultBytes": 1}}, + } + for name, fixture := range fixtures { + if projected, err := projectWhiteboardQuery(fixture, "doc", "part"); err == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + valid := map[string]any{"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "message": "ok"} + projected, err := projectWhiteboardQuery(valid, " doc ", " part ") + if err != nil || projected["nodeId"] != "doc" || projected["partId"] != "part" || projected["message"] != "ok" { + t.Fatalf("valid message projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageWhiteboardReceiptAndResultJSONRemainingMatrix(t *testing.T) { + expected, err := parseWhiteboardSource(`{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`) + if err != nil { + t.Fatal(err) + } + target := map[string]any{"nodeId": "doc", "partId": "part"} + fixtures := map[string]map[string]any{ + "business failure": {"success": false}, + "missing terminal result": {"success": true, "nodeId": "doc", "partId": "part"}, + "empty result map": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{}}, + "empty result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": " "}, + "invalid result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": "{"}, + "trailing result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": `{"mode":"append"} {}`}, + "wrong result type": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": []any{}}, + "wrong mode": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "overwrite", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "created count mismatch": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "empty created identity": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{" "}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "duplicate created identity": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{"real", "real"}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "empty id map key": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{" ": "real"}, "deletedNodeCount": 0, + }}, + "id map count mismatch": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{}, "deletedNodeCount": 0, + }}, + } + for name, fixture := range fixtures { + if receipt, err := requireWhiteboardUpdateReceipt(fixture, target, "append", expected); err == nil { + t.Errorf("%s returned success: %#v", name, receipt) + } + } +} + +func TestCrossPlatformCoverageWhiteboardReadbackComparatorAndScalarMatrices(t *testing.T) { + if count := whiteboardPageOwnedNodeCount([]map[string]any{ + {"id": "master", "source": "master"}, {"id": "page", "source": "page"}, {"id": "plain"}, + }); count != 2 { + t.Fatalf("page-owned count=%d", count) + } + + comparisons := []struct { + name string + expected any + actual any + wantErr bool + }{ + {"map wrong type", map[string]any{"a": 1}, "bad", true}, + {"map missing key", map[string]any{"a": 1}, map[string]any{}, true}, + {"array wrong length", []any{1}, []any{}, true}, + {"array child mismatch", []any{1}, []any{2}, true}, + {"array equal", []any{1}, []any{float64(1)}, false}, + {"number wrong type", json.Number("1"), "1", true}, + {"number mismatch", json.Number("1"), float64(2), true}, + {"number equal cross type", json.Number("1"), int(1), false}, + {"large adjacent JSON integers mismatch", json.Number("9007199254740992"), json.Number("9007199254740993"), true}, + {"large JSON integer equal", json.Number("9007199254740993"), json.Number("9007199254740993"), false}, + {"scalar mismatch", "one", "two", true}, + {"scalar equal", "same", "same", false}, + } + for _, test := range comparisons { + err := requireRequestedValue(test.expected, test.actual, test.name) + if (err != nil) != test.wantErr { + t.Errorf("%s err=%v wantErr=%t", test.name, err, test.wantErr) + } + } + + for _, test := range []struct { + value any + ok bool + }{ + {json.Number("1.5"), true}, {json.Number("1e3"), true}, {json.Number("bad"), false}, + {float64(1), true}, {math.NaN(), false}, {math.Inf(1), false}, + {int(1), true}, {"1", false}, + } { + _, ok := numericValue(test.value) + if ok != test.ok { + t.Errorf("numericValue(%#v) ok=%t want=%t", test.value, ok, test.ok) + } + } + + for _, test := range []struct { + value any + want int + ok bool + }{ + {int(1), 1, true}, {int(-1), -1, false}, + {float64(2), 2, true}, {float64(-1), 0, false}, {1.5, 0, false}, {math.NaN(), 0, false}, {math.Inf(1), 0, false}, + {json.Number("3"), 3, true}, {json.Number("bad"), 0, false}, {json.Number("-1"), 0, false}, + {"4", 0, false}, + } { + got, ok := nonNegativeInt(test.value) + if got != test.want || ok != test.ok { + t.Errorf("nonNegativeInt(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok) + } + } + + decoder := json.NewDecoder(strings.NewReader(`{} ]`)) + var first map[string]any + if err := decoder.Decode(&first); err != nil { + t.Fatal(err) + } + if err := requireJSONEOF(decoder); err == nil { + t.Fatal("malformed trailing JSON returned EOF success") + } +} + +func TestCrossPlatformCoverageWhiteboardBlankTargetsStopBeforeRPC(t *testing.T) { + validSource := `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {"query blank node", Query, []string{"--node", " ", "--part-id", "part"}}, + {"query blank part", Query, []string{"--node", "doc", "--part-id", " "}}, + {"update blank node", Update, []string{"--node", " ", "--part-id", "part", "--source", validSource, "--yes"}}, + {"update blank part", Update, []string{"--node", "doc", "--part-id", " ", "--source", validSource, "--yes"}}, + } + for _, test := range tests { + caller := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, test.declaration, caller, "", test.args...); err == nil { + t.Errorf("%s returned success", test.name) + } + if len(caller.calls) != 0 { + t.Errorf("%s crossed RPC boundary: %#v", test.name, caller.calls) + } + } + directBlankNode := directWhiteboardRuntime(t, Query, &whiteboardCoverageCaller{responses: map[string][]string{}}, "--node", " ", "--part-id", "part") + if err := Query.Validate(directBlankNode); err == nil { + t.Fatal("direct query validation accepted blank node") + } + directBlankPart := directWhiteboardRuntime(t, Query, &whiteboardCoverageCaller{responses: map[string][]string{}}, "--node", "doc", "--part-id", " ") + if err := Query.Validate(directBlankPart); err == nil { + t.Fatal("direct query validation accepted blank part") + } + directUpdate := directWhiteboardRuntime(t, Update, &whiteboardCoverageCaller{responses: map[string][]string{}}, + "--node", " ", "--part-id", "part", "--source", validSource) + if err := Update.Validate(directUpdate); err == nil { + t.Fatal("direct update validation accepted blank node") + } +} + +func TestCrossPlatformCoverageWhiteboardSourceAndDirectVerificationRemainingMatrix(t *testing.T) { + for name, source := range map[string]string{ + "null nodes": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":null}}`, + "missing type": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"node"}]}}`, + } { + if parsed, err := parseWhiteboardSource(source); err == nil { + t.Errorf("%s returned success: %#v", name, parsed) + } + } + t.Run("marshal failure", func(t *testing.T) { + testseam.Swap(t, &whiteboardMarshalNodes, func(any) ([]byte, error) { + return nil, errors.New("injected marshal failure") + }) + if parsed, err := parseWhiteboardSource(`{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"node","type":"text"}]}}`); err == nil { + t.Fatalf("marshal failure returned success: %#v", parsed) + } + }) + + expected := &parsedUpdate{Overwrite: true, Nodes: []map[string]any{{"id": "request", "type": "text"}}} + if err := verifyWhiteboardUpdate(expected, map[string]any{}, map[string]string{"request": "real"}); err == nil { + t.Fatal("missing source returned success") + } + if err := verifyWhiteboardUpdate(expected, map[string]any{"source": map[string]any{"pages": "bad"}}, map[string]string{"request": "real"}); err == nil { + t.Fatal("bad pages returned success") + } + overwriteCountMismatch := map[string]any{ + "source": map[string]any{ + "pages": []any{ + map[string]any{ + "id": "page", + "nodes": []any{ + map[string]any{"id": "real", "type": "text"}, + map[string]any{"id": "extra", "type": "text"}, + }, + }, + }, + }, + } + if err := verifyWhiteboardUpdate(expected, overwriteCountMismatch, map[string]string{"request": "real"}); err == nil { + t.Fatal("overwrite count mismatch returned success") + } +} diff --git a/pkg/cmdutil/command_resolution.go b/pkg/cmdutil/command_resolution.go new file mode 100644 index 00000000..c8000dc9 --- /dev/null +++ b/pkg/cmdutil/command_resolution.go @@ -0,0 +1,222 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +// ResolutionReason is the stable machine-readable classification for a +// command-token resolution failure. +type ResolutionReason string + +const ( + // ResolutionUnknownSubcommand reports a token that does not resolve to a + // child command of the selected parent. + ResolutionUnknownSubcommand ResolutionReason = "unknown_subcommand" + // ResolutionUnknownShortcut reports an explicit +shortcut token that does + // not resolve under a top-level service. + ResolutionUnknownShortcut ResolutionReason = "unknown_shortcut" +) + +// CommandResolution is the immutable typed projection of a command-token +// resolution failure. Its constructors normalize suggestions once so the +// human hint and machine-readable details cannot diverge. +type CommandResolution struct { + reason ResolutionReason + input string + suggestions []string + message string + hint string + actions []string +} + +// ClassifyCommandResolution returns the stable reason for an unresolved token. +// A leading '+' is shortcut syntax only for a top-level service that actually +// exposes +shortcut children, and only when it does not already name a real +// child or alias. Keeping this classification beside CommandResolution +// prevents PreParse and direct Cobra execution from producing different +// machine contracts for the same input. +func ClassifyCommandResolution(parent *cobra.Command, input string) ResolutionReason { + input = strings.TrimSpace(input) + if strings.HasPrefix(input, "+") && isTopLevelShortcutService(parent) && !hasExactChildCommand(parent, input) { + return ResolutionUnknownShortcut + } + return ResolutionUnknownSubcommand +} + +// NewCommandResolution builds a bounded command-resolution result. Callers +// may pass sibling suggestions from SuggestSubcommands or reviewed deep-path +// candidates; both are normalized to the same three-item contract. +func NewCommandResolution(parent *cobra.Command, input string, reason ResolutionReason, suggestions []string, authoredHint string) CommandResolution { + input = strings.TrimSpace(input) + suggestions = normalizeCommandSuggestions(suggestions) + parentPath := commandResolutionParentPath(parent) + helpAction := fmt.Sprintf("Run '%s --help' for the full list", parentPath) + + resolution := CommandResolution{ + reason: reason, + input: input, + suggestions: suggestions, + actions: []string{helpAction}, + } + + switch reason { + case ResolutionUnknownShortcut: + resolution.message = fmt.Sprintf("unknown shortcut %q for %q", input, parentPath) + if parent != nil { + root := parent.Root() + if root != nil { + resolution.actions = append(resolution.actions, + fmt.Sprintf("Run '%s shortcut list --service %s --format json'", root.Name(), parent.Name())) + } + } + default: + resolution.reason = ResolutionUnknownSubcommand + resolution.message = fmt.Sprintf("unknown subcommand %q for %q", input, parentPath) + } + + authoredHint = strings.TrimSpace(authoredHint) + if authoredHint != "" { + resolution.hint = authoredHint + " (" + helpAction + ")" + } else { + resolution.hint = FormatSubcommandSuggestionHint(parent, suggestions, helpAction) + } + return resolution +} + +// NewInputCommandResolution classifies input and builds its one normalized +// human/machine projection. It is the shared entry point for PreParse and +// direct group execution. +func NewInputCommandResolution(parent *cobra.Command, input string, suggestions []string) CommandResolution { + return NewCommandResolution(parent, input, ClassifyCommandResolution(parent, input), suggestions, "") +} + +// Details returns a fresh machine-readable payload for the resolution. It +// always carries the exact bounded suggestions rendered in Hint. +func (r CommandResolution) Details() map[string]any { + suggestions := make([]string, len(r.suggestions)) + copy(suggestions, r.suggestions) + return map[string]any{ + "input": r.input, + "suggestions": suggestions, + } +} + +// Err projects the resolution through the repository's structured validation +// error contract. +func (r CommandResolution) Err() error { + return apperrors.NewValidation( + r.message, + apperrors.WithReason(string(r.reason)), + apperrors.WithHint(r.hint), + apperrors.WithActions(r.actions...), + apperrors.WithDetails(r.Details()), + ) +} + +// GroupRunE is the reusable handler for navigation-only parent commands. It +// shows help without arguments and otherwise returns the same structured +// resolution contract used by pre-parse validation. +func GroupRunE(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + input := strings.TrimSpace(args[0]) + return NewInputCommandResolution(cmd, input, SuggestSubcommands(cmd, input)).Err() +} + +// HintSubCmd creates a hidden compatibility command for a reviewed wrong path. +// It keeps hint-only identity while returning the unified typed resolution. +func HintSubCmd(use, authoredHint string) *cobra.Command { + command := &cobra.Command{ + Use: use, + Hidden: true, + Annotations: map[string]string{hintOnlyCommandAnnotation: "true"}, + } + command.RunE = func(cmd *cobra.Command, _ []string) error { + parent := cmd.Parent() + if parent == nil { + parent = cmd.Root() + } + return NewCommandResolution( + parent, + cmd.Name(), + ResolutionUnknownSubcommand, + []string{}, + authoredHint, + ).Err() + } + return command +} + +func normalizeCommandSuggestions(suggestions []string) []string { + out := make([]string, 0, min(len(suggestions), MaxCommandSuggestions)) + seen := make(map[string]struct{}, min(len(suggestions), MaxCommandSuggestions)) + for _, suggestion := range suggestions { + suggestion = strings.TrimSpace(suggestion) + if suggestion == "" { + continue + } + if _, duplicate := seen[suggestion]; duplicate { + continue + } + seen[suggestion] = struct{}{} + out = append(out, suggestion) + if len(out) == MaxCommandSuggestions { + break + } + } + return out +} + +func commandResolutionParentPath(parent *cobra.Command) string { + if parent == nil { + return "dws" + } + if path := strings.TrimSpace(parent.CommandPath()); path != "" { + return path + } + if name := strings.TrimSpace(parent.Name()); name != "" { + return name + } + return "dws" +} + +func hasExactChildCommand(parent *cobra.Command, candidate string) bool { + if parent == nil { + return false + } + for _, child := range parent.Commands() { + if child.Name() == candidate { + return true + } + for _, alias := range child.Aliases { + if alias == candidate { + return true + } + } + } + return false +} + +func isTopLevelShortcutService(parent *cobra.Command) bool { + if parent == nil { + return false + } + root := parent.Root() + if root == nil || parent == root || parent.Parent() != root { + return false + } + for _, child := range parent.Commands() { + if strings.HasPrefix(child.Name(), "+") { + return true + } + } + return false +} diff --git a/pkg/cmdutil/command_resolution_test.go b/pkg/cmdutil/command_resolution_test.go new file mode 100644 index 00000000..ff942d3e --- /dev/null +++ b/pkg/cmdutil/command_resolution_test.go @@ -0,0 +1,203 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "errors" + "slices" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageCommandResolutionProjectsExactShortcutContract(t *testing.T) { + root := &cobra.Command{Use: "dws"} + chat := &cobra.Command{Use: "chat"} + root.AddCommand(chat) + + resolution := NewCommandResolution( + chat, + " +chat-mesages ", + ResolutionUnknownShortcut, + []string{"+chat-messages"}, + "", + ) + structured := requireTypedCommandResolution(t, resolution.Err(), ResolutionUnknownShortcut) + + 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"? (Run 'dws chat --help' for the full list)` { + t.Fatalf("Hint = %q", structured.Hint) + } + wantActions := []string{ + "Run 'dws chat --help' for the full list", + "Run 'dws shortcut list --service chat --format json'", + } + if !slices.Equal(structured.Actions, wantActions) { + t.Fatalf("Actions = %#v, want %#v", structured.Actions, wantActions) + } + assertResolutionDetails(t, structured, "+chat-mesages", []string{"+chat-messages"}) +} + +func TestCrossPlatformCoverageGroupRunEClassifiesShortcutLikePreParse(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) {}}) + root.AddCommand(chat) + + structured := requireTypedCommandResolution(t, GroupRunE(chat, []string{"+chat-mesages"}), ResolutionUnknownShortcut) + 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"? (Run 'dws chat --help' for the full list)` { + t.Fatalf("Hint = %q", structured.Hint) + } + if !slices.Equal(structured.Actions, []string{ + "Run 'dws chat --help' for the full list", + "Run 'dws shortcut list --service chat --format json'", + }) { + t.Fatalf("Actions = %#v", structured.Actions) + } + assertResolutionDetails(t, structured, "+chat-mesages", []string{"+chat-messages"}) +} + +func TestCrossPlatformCoverageCommandResolutionBoundsOneSuggestionSetForHintAndDetails(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + root.AddCommand(group) + + resolution := NewCommandResolution( + group, + " alphx ", + ResolutionUnknownSubcommand, + []string{" alpha ", "alpha", "beta", "", "gamma", "delta"}, + "", + ) + if !slices.Equal(resolution.suggestions, []string{"alpha", "beta", "gamma"}) { + t.Fatalf("Suggestions = %#v", resolution.suggestions) + } + if got, ok := resolution.Details()["suggestions"].([]string); !ok || !slices.Equal(got, resolution.suggestions) { + t.Fatalf("Details.suggestions = %#v", resolution.Details()["suggestions"]) + } + structured := requireTypedCommandResolution(t, resolution.Err(), ResolutionUnknownSubcommand) + if structured.Message != `unknown subcommand "alphx" for "dws demo"` { + t.Fatalf("Message = %q", structured.Message) + } + if strings.Count(structured.Hint, `"dws demo `) != MaxCommandSuggestions || strings.Contains(structured.Hint, "delta") { + t.Fatalf("Hint = %q", structured.Hint) + } + assertResolutionDetails(t, structured, "alphx", []string{"alpha", "beta", "gamma"}) +} + +func TestCrossPlatformCoverageHintSubCmdUsesSameTypedResolutionFactory(t *testing.T) { + root := &cobra.Command{Use: "dws"} + parent := &cobra.Command{Use: "contact"} + hint := HintSubCmd("department", "use: dws contact dept") + root.AddCommand(parent) + parent.AddCommand(hint) + + structured := requireTypedCommandResolution(t, hint.RunE(hint, nil), ResolutionUnknownSubcommand) + if structured.Hint != "use: dws contact dept (Run 'dws contact --help' for the full list)" { + t.Fatalf("Hint = %q", structured.Hint) + } + if !IsHintOnlyCommand(hint) { + t.Fatal("hint command lost hint-only identity") + } + assertResolutionDetails(t, structured, "department", []string{}) +} + +func TestCrossPlatformCoverageCommandResolutionDefensiveFallbacks(t *testing.T) { + resolution := NewCommandResolution(nil, "missing", ResolutionReason("unsupported"), nil, "") + structured := requireTypedCommandResolution(t, resolution.Err(), ResolutionUnknownSubcommand) + if structured.Message != `unknown subcommand "missing" for "dws"` || structured.Hint != "Run 'dws --help' for the full list" { + t.Fatalf("fallback resolution = %#v", structured) + } + assertResolutionDetails(t, structured, "missing", []string{}) + if suggestions := structured.Details["suggestions"].([]string); suggestions == nil { + t.Fatal("empty suggestions must remain a machine-readable array, not null") + } +} + +func TestCrossPlatformCoverageCommandResolutionHelperBranches(t *testing.T) { + group := &cobra.Command{Use: "group"} + group.AddCommand(&cobra.Command{Use: "leaf", Run: func(*cobra.Command, []string) {}}) + var help strings.Builder + group.SetOut(&help) + group.SetErr(&help) + if err := GroupRunE(group, nil); err != nil { + t.Fatalf("GroupRunE help error = %v", err) + } + if output := help.String(); !strings.Contains(output, "Usage:") { + t.Fatalf("GroupRunE help output = %q", output) + } + + hint := HintSubCmd("mistake", "use canonical") + structured := requireTypedCommandResolution(t, hint.RunE(hint, nil), ResolutionUnknownSubcommand) + if structured.Message != `unknown subcommand "mistake" for "mistake"` { + t.Fatalf("detached hint Message = %q", structured.Message) + } + + nameFallback := &cobra.Command{ + Use: "fallback", + Annotations: map[string]string{cobra.CommandDisplayNameAnnotation: " "}, + } + if got := commandResolutionParentPath(nameFallback); got != "fallback" { + t.Fatalf("commandResolutionParentPath name fallback = %q", got) + } + emptyFallback := &cobra.Command{ + Annotations: map[string]string{cobra.CommandDisplayNameAnnotation: ""}, + } + if got := commandResolutionParentPath(emptyFallback); got != "dws" { + t.Fatalf("commandResolutionParentPath empty fallback = %q", got) + } + + parent := &cobra.Command{Use: "parent"} + child := &cobra.Command{Use: "canonical", Aliases: []string{"alias"}} + parent.AddCommand(child) + if hasExactChildCommand(nil, "canonical") { + t.Fatal("nil parent reported an exact child") + } + for _, candidate := range []string{"canonical", "alias"} { + if !hasExactChildCommand(parent, candidate) { + t.Fatalf("hasExactChildCommand(%q) = false", candidate) + } + } + if hasExactChildCommand(parent, "missing") { + t.Fatal("missing child reported as exact") + } + + root := &cobra.Command{Use: "dws"} + service := &cobra.Command{Use: "service"} + service.AddCommand(&cobra.Command{Use: "ordinary"}) + root.AddCommand(service) + if isTopLevelShortcutService(nil) || isTopLevelShortcutService(root) || isTopLevelShortcutService(service) { + t.Fatal("non-shortcut command classified as a top-level shortcut service") + } +} + +func requireTypedCommandResolution(t *testing.T, err error, reason ResolutionReason) *apperrors.Error { + t.Helper() + var structured *apperrors.Error + if !errors.As(err, &structured) { + t.Fatalf("error = %T %v, want *errors.Error", err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != string(reason) || structured.ExitCode() != 3 { + t.Fatalf("structured error = %#v", structured) + } + return structured +} + +func assertResolutionDetails(t *testing.T, structured *apperrors.Error, input string, suggestions []string) { + t.Helper() + if structured.Details["input"] != input { + t.Fatalf("Details.input = %#v, want %q", structured.Details["input"], input) + } + got, ok := structured.Details["suggestions"].([]string) + if !ok || !slices.Equal(got, suggestions) { + t.Fatalf("Details.suggestions = %#v, want %#v", structured.Details["suggestions"], suggestions) + } +} diff --git a/pkg/cmdutil/command_suggestions.go b/pkg/cmdutil/command_suggestions.go new file mode 100644 index 00000000..17f794f5 --- /dev/null +++ b/pkg/cmdutil/command_suggestions.go @@ -0,0 +1,186 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// MaxCommandSuggestions keeps typo recovery concise even for products with a +// large command surface. +const MaxCommandSuggestions = 3 + +type rankedCommandSuggestion struct { + name string + explicit bool + prefix bool + distance int + lengthDelta int +} + +// SuggestSubcommands returns at most MaxCommandSuggestions visible canonical +// child names, ranked by reviewed SuggestFor matches, prefix relevance, edit +// distance, length delta, and finally canonical name for deterministic ties. +// Aliases participate in scoring, but recovery always teaches the canonical +// command name. +func SuggestSubcommands(parent *cobra.Command, candidate string) []string { + if parent == nil { + return nil + } + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" { + return []string{} + } + minimumDistance := parent.SuggestionsMinimumDistance + if minimumDistance <= 0 { + minimumDistance = 2 + } + shortcutOnly := strings.HasPrefix(candidate, "+") + ranked := make([]rankedCommandSuggestion, 0) + for _, child := range parent.Commands() { + if !child.IsAvailableCommand() || (shortcutOnly && !strings.HasPrefix(child.Name(), "+")) { + continue + } + + explicit := false + for _, suggestion := range child.SuggestFor { + if strings.EqualFold(candidate, suggestion) { + explicit = true + break + } + } + + identities := append([]string{child.Name()}, child.Aliases...) + prefix := false + bestDistance := -1 + bestLengthDelta := 0 + for _, identity := range identities { + identity = strings.ToLower(strings.TrimSpace(identity)) + if identity == "" { + continue + } + if strings.HasPrefix(identity, candidate) { + prefix = true + } + distance := LevenshteinDist(candidate, identity) + lengthDelta := len(identity) - len(candidate) + if lengthDelta < 0 { + lengthDelta = -lengthDelta + } + if bestDistance < 0 || distance < bestDistance || (distance == bestDistance && lengthDelta < bestLengthDelta) { + bestDistance = distance + bestLengthDelta = lengthDelta + } + } + if !explicit && !prefix && (bestDistance < 0 || bestDistance > minimumDistance) { + continue + } + ranked = append(ranked, rankedCommandSuggestion{ + name: child.Name(), + explicit: explicit, + prefix: prefix, + distance: bestDistance, + lengthDelta: bestLengthDelta, + }) + } + + sort.Slice(ranked, func(i, j int) bool { + left, right := ranked[i], ranked[j] + if left.explicit != right.explicit { + return left.explicit + } + if left.prefix != right.prefix { + return left.prefix + } + if left.distance != right.distance { + return left.distance < right.distance + } + if left.lengthDelta != right.lengthDelta { + return left.lengthDelta < right.lengthDelta + } + return left.name < right.name + }) + if len(ranked) > MaxCommandSuggestions { + ranked = ranked[:MaxCommandSuggestions] + } + + suggestions := make([]string, 0, len(ranked)) + for _, suggestion := range ranked { + suggestions = append(suggestions, suggestion.name) + } + return suggestions +} + +// SuggestDescendantSubcommands returns bounded relative paths for available +// descendants whose canonical name or alias exactly matches candidate. It is +// the reviewed deep-recovery mode used by hierarchical products such as +// Sheet, where a model may omit an intermediate group (`sheet read` instead +// of `sheet range read`). Fuzzy ranking remains a sibling concern so a large +// subtree cannot drown the user in speculative paths. +func SuggestDescendantSubcommands(parent *cobra.Command, candidate string) []string { + if parent == nil { + return nil + } + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" { + return []string{} + } + + paths := make([]string, 0) + var walk func(*cobra.Command, []string) + walk = func(command *cobra.Command, prefix []string) { + for _, child := range command.Commands() { + if !child.IsAvailableCommand() { + continue + } + path := append(append([]string(nil), prefix...), child.Name()) + matched := strings.EqualFold(strings.TrimSpace(child.Name()), candidate) + if !matched { + for _, alias := range child.Aliases { + if strings.EqualFold(strings.TrimSpace(alias), candidate) { + matched = true + break + } + } + } + if matched { + paths = append(paths, strings.Join(path, " ")) + } + walk(child, path) + } + } + walk(parent, nil) + sort.Strings(paths) + return normalizeCommandSuggestions(paths) +} + +// FormatSubcommandSuggestionHint renders bounded suggestions and always keeps +// the full-list recovery action visible in the same hint. +func FormatSubcommandSuggestionHint(parent *cobra.Command, suggestions []string, fallback string) string { + if len(suggestions) > MaxCommandSuggestions { + suggestions = suggestions[:MaxCommandSuggestions] + } + paths := make([]string, 0, len(suggestions)) + parentPath := commandResolutionParentPath(parent) + for _, suggestion := range suggestions { + paths = append(paths, fmt.Sprintf("%q", parentPath+" "+suggestion)) + } + var hint string + switch len(paths) { + case 0: + return fallback + case 1: + hint = "Did you mean " + paths[0] + "?" + default: + hint = "Did you mean one of: " + strings.Join(paths, ", ") + "?" + } + if fallback != "" { + hint += " (" + fallback + ")" + } + return hint +} diff --git a/pkg/cmdutil/command_suggestions_test.go b/pkg/cmdutil/command_suggestions_test.go new file mode 100644 index 00000000..69dd2638 --- /dev/null +++ b/pkg/cmdutil/command_suggestions_test.go @@ -0,0 +1,179 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "errors" + "slices" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSuggestSubcommandsRanksNearestAndBoundsResults(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + root.AddCommand(group) + for _, name := range []string{"aaa", "alpha", "alphi", "alphx"} { + group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}}) + } + group.AddCommand(&cobra.Command{Use: "alpho-hidden", Hidden: true, Run: func(*cobra.Command, []string) {}}) + + want := []string{"alpha", "alphi", "alphx"} + if got := SuggestSubcommands(group, "alpho"); !slices.Equal(got, want) { + t.Fatalf("SuggestSubcommands() = %#v, want nearest %#v", got, want) + } +} + +func TestCrossPlatformCoverageSuggestSubcommandsUsesAliasesAndReviewedSuggestions(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 1} + root.AddCommand(group) + group.AddCommand( + &cobra.Command{Use: "canonical", Aliases: []string{"metas"}, Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "reviewed", SuggestFor: []string{"legacy-command"}, Run: func(*cobra.Command, []string) {}}, + ) + + if got := SuggestSubcommands(group, "meta"); !slices.Equal(got, []string{"canonical"}) { + t.Fatalf("alias suggestion = %#v, want canonical command", got) + } + if got := SuggestSubcommands(group, "legacy-command"); !slices.Equal(got, []string{"reviewed"}) { + t.Fatalf("reviewed SuggestFor = %#v", got) + } +} + +func TestCrossPlatformCoverageGroupRunERemainsConciseWithoutSuggestions(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + root.AddCommand(group) + for _, name := range []string{"alpha", "beta", "gamma", "delta"} { + group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}}) + } + + err := GroupRunE(group, []string{"zzzzz"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != string(ResolutionUnknownSubcommand) || + strings.Contains(structured.Hint, "available:") || structured.Hint != "Run 'dws demo --help' for the full list" { + t.Fatalf("GroupRunE() error = %v, want concise parent-help guidance", err) + } + assertResolutionDetails(t, structured, "zzzzz", []string{}) +} + +func TestCrossPlatformCoverageFormatSubcommandSuggestionHintDefensivelyBoundsInput(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + root.AddCommand(group) + hint := FormatSubcommandSuggestionHint(group, []string{"one", "two", "three", "four"}, "fallback") + if strings.Count(hint, `"dws demo `) != MaxCommandSuggestions || strings.Contains(hint, "four") { + t.Fatalf("unbounded formatted hint = %q", hint) + } + if hint := FormatSubcommandSuggestionHint(group, []string{"one"}, "fallback"); hint != `Did you mean "dws demo one"? (fallback)` { + t.Fatalf("single suggestion hint = %q", hint) + } +} + +func TestCrossPlatformCoverageSuggestSubcommandsDefensiveInputs(t *testing.T) { + if got := SuggestSubcommands(nil, "candidate"); got != nil { + t.Fatalf("SuggestSubcommands(nil) = %#v, want nil", got) + } + + group := &cobra.Command{Use: "demo"} + group.AddCommand(&cobra.Command{ + Use: "target", + Aliases: []string{""}, + Run: func(*cobra.Command, []string) {}, + }) + if got := SuggestSubcommands(group, " \t "); len(got) != 0 { + t.Fatalf("empty candidate suggestions = %#v", got) + } + if got := SuggestSubcommands(group, "target"); !slices.Equal(got, []string{"target"}) { + t.Fatalf("empty alias changed canonical suggestion = %#v", got) + } + + blankGroup := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + blankGroup.AddCommand(&cobra.Command{Use: "", Run: func(*cobra.Command, []string) {}}) + if got := SuggestSubcommands(blankGroup, "candidate"); len(got) != 0 { + t.Fatalf("blank command identity suggestions = %#v", got) + } +} + +func TestCrossPlatformCoverageSuggestSubcommandsRankingTieBreakers(t *testing.T) { + newGroup := func() *cobra.Command { + return &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + } + + explicit := newGroup() + explicit.AddCommand( + &cobra.Command{Use: "reviewed", SuggestFor: []string{"legacy"}, Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "ordinary", Run: func(*cobra.Command, []string) {}}, + ) + if got := SuggestSubcommands(explicit, "legacy"); len(got) < 2 || got[0] != "reviewed" { + t.Fatalf("explicit ranking = %#v", got) + } + + prefix := newGroup() + prefix.AddCommand( + &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "xlp", Run: func(*cobra.Command, []string) {}}, + ) + if got := SuggestSubcommands(prefix, "alp"); len(got) < 2 || got[0] != "alpha" { + t.Fatalf("prefix ranking = %#v", got) + } + + lengthDelta := newGroup() + lengthDelta.AddCommand( + &cobra.Command{Use: "ab", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "axc", Run: func(*cobra.Command, []string) {}}, + ) + if got := SuggestSubcommands(lengthDelta, "abc"); !slices.Equal(got, []string{"axc", "ab"}) { + t.Fatalf("length-delta ranking = %#v", got) + } +} + +func TestCrossPlatformCoverageSuggestDescendantSubcommandsExactCanonicalAliasSortedAndBounded(t *testing.T) { + root := &cobra.Command{Use: "sheet"} + addGroup := func(name string, hidden bool) *cobra.Command { + group := &cobra.Command{Use: name, Hidden: hidden, Run: func(*cobra.Command, []string) {}} + root.AddCommand(group) + return group + } + addLeaf := func(group *cobra.Command, use string, aliases []string, hidden, runnable bool) { + leaf := &cobra.Command{Use: use, Aliases: aliases, Hidden: hidden} + if runnable { + leaf.Run = func(*cobra.Command, []string) {} + } + group.AddCommand(leaf) + } + + // Insert matching paths deliberately out of order. The visible alias match + // must teach its canonical path, and the result must be sorted before it is + // capped at the shared three-suggestion limit. + addLeaf(addGroup("zeta", false), "read", nil, false, true) + addLeaf(addGroup("gamma", false), "read", nil, false, true) + addLeaf(addGroup("alpha", false), "fetch", []string{"read"}, false, true) + addLeaf(addGroup("beta", false), "read", nil, false, true) + + // These paths sort before the expected results if they leak through, so the + // assertion also proves hidden and unavailable descendants are excluded + // before bounding rather than merely falling beyond the cap. + addLeaf(addGroup("aaa-hidden-group", true), "read", nil, false, true) + addLeaf(addGroup("aab-unavailable", false), "read", nil, false, false) + addLeaf(addGroup("aac-hidden-leaf", false), "read", nil, true, true) + + want := []string{"alpha fetch", "beta read", "gamma read"} + if got := SuggestDescendantSubcommands(root, " READ "); !slices.Equal(got, want) { + t.Fatalf("SuggestDescendantSubcommands() = %#v, want %#v", got, want) + } + if got := SuggestDescendantSubcommands(root, "rea"); len(got) != 0 { + t.Fatalf("fuzzy descendant suggestions = %#v, want exact matching only", got) + } + if got := SuggestDescendantSubcommands(nil, "read"); got != nil { + t.Fatalf("nil parent suggestions = %#v", got) + } + if got := SuggestDescendantSubcommands(root, " \t "); len(got) != 0 { + t.Fatalf("blank candidate suggestions = %#v", got) + } +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index 07b9fbc5..000257d7 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -27,36 +27,6 @@ import ( const hintOnlyCommandAnnotation = "dws.command.hint_only" -// GroupRunE is a reusable RunE for parent (group) commands that have no -// business logic of their own. With args it returns an error listing available -// subcommands; without args it shows help. -func GroupRunE(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - var names []string - for _, sub := range cmd.Commands() { - if !sub.Hidden { - names = append(names, sub.Name()) - } - } - return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help", - args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath()) - } - return cmd.Help() -} - -// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint. -func HintSubCmd(use, hint string) *cobra.Command { - return &cobra.Command{ - Use: use, - Hidden: true, - Annotations: map[string]string{hintOnlyCommandAnnotation: "true"}, - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("ambiguous command %q for %q\n hint: %s", - use, cmd.Parent().CommandPath(), hint) - }, - } -} - // IsHintOnlyCommand reports whether cmd is a hidden compatibility prompt that // has no business execution of its own. Reviewed command-path fallbacks may // supersede these nodes, but must still reject collisions with real commands. diff --git a/pkg/cmdutil/flags_hint_test.go b/pkg/cmdutil/flags_hint_test.go index 50a1b20f..5a96dd2f 100644 --- a/pkg/cmdutil/flags_hint_test.go +++ b/pkg/cmdutil/flags_hint_test.go @@ -4,9 +4,10 @@ package cmdutil import ( - "strings" + "errors" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -21,7 +22,10 @@ func TestCrossPlatformCoverageHintSubCmdCarriesHintOnlyIdentity(t *testing.T) { if IsHintOnlyCommand(nil) || IsHintOnlyCommand(&cobra.Command{Use: "real"}) { t.Fatal("ordinary command was classified as hint-only") } - if err := hint.RunE(hint, nil); err == nil || !strings.Contains(err.Error(), "use parent find") { - t.Fatalf("hint error = %v", err) + var structured *apperrors.Error + if err := hint.RunE(hint, nil); !errors.As(err, &structured) || + structured.Hint != "use parent find (Run 'parent --help' for the full list)" { + t.Fatalf("hint error = %#v", structured) } + assertResolutionDetails(t, structured, "search", []string{}) } diff --git a/pkg/cmdutil/provenance.go b/pkg/cmdutil/provenance.go index b2dba584..9a02f184 100644 --- a/pkg/cmdutil/provenance.go +++ b/pkg/cmdutil/provenance.go @@ -57,25 +57,3 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool { func IsPluginSourced(cmd *cobra.Command) bool { return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin } - -// KindAnnotation is the annotation key for marking command kinds. -const KindAnnotation = "dws.kind" - -// KindGroup marks a command created as a group container. -const KindGroup = "group" - -// MarkGroup stamps cmd as a group container. -func MarkGroup(cmd *cobra.Command) { - if cmd == nil { - return - } - if cmd.Annotations == nil { - cmd.Annotations = map[string]string{} - } - cmd.Annotations[KindAnnotation] = KindGroup -} - -// IsGroup reports whether cmd was created as a group container. -func IsGroup(cmd *cobra.Command) bool { - return cmd != nil && cmd.Annotations[KindAnnotation] == KindGroup -} diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index 7418836e..a61bccc3 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -137,3 +137,24 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint") } } + +func TestCrossPlatformCoverageOpenSupplementServersExcludesRetiredEduEndpoints(t *testing.T) { + retiredProducts := map[string]bool{ + "edu-contact": true, + "edu-group": true, + "edu-app": true, + "edu-familygroup": true, + "college-contact": true, + } + + for _, server := range openSupplementServers() { + if retiredProducts[server.ID] { + t.Errorf("openSupplementServers() still exposes retired endpoint %q", server.ID) + } + for _, prefix := range server.Prefixes { + if retiredProducts[prefix] { + t.Errorf("openSupplementServers() endpoint %q still routes retired prefix %q", server.ID, prefix) + } + } + } +} diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh index 0183762c..81807b57 100755 --- a/scripts/ci/run-app-race-tests.sh +++ b/scripts/ci/run-app-race-tests.sh @@ -14,7 +14,7 @@ usage() { # Single source of truth for the partition set. CI runs one job per partition and # pins its shard names to this list, so a name that appears here without a # dispatch entry below fails closed rather than silently skipping tests. -APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz' +APP_PARTITIONS='schema a-b c-a-l c-m-o c-p-r c-s-z c-other d-r s-z-example-fuzz' mode="${1:-}" partition="" @@ -67,7 +67,8 @@ fi schema_count=0 ab_count=0 cal_count=0 -cmr_count=0 +cmo_count=0 +cpr_count=0 csz_count=0 cother_count=0 dr_count=0 @@ -79,7 +80,8 @@ while IFS= read -r test_name; do Test*Schema*) schema_count=$((schema_count + 1)) ;; Test[A-B]*) ab_count=$((ab_count + 1)) ;; TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;; - TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;; + TestCrossPlatformCoverage[M-O]*) cmo_count=$((cmo_count + 1)) ;; + TestCrossPlatformCoverage[P-R]*) cpr_count=$((cpr_count + 1)) ;; TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;; TestC*) cother_count=$((cother_count + 1)) ;; Test[D-R]*) dr_count=$((dr_count + 1)) ;; @@ -103,7 +105,8 @@ for spec in \ "schema:$schema_count" \ "a-b:$ab_count" \ "c-a-l:$cal_count" \ - "c-m-r:$cmr_count" \ + "c-m-o:$cmo_count" \ + "c-p-r:$cpr_count" \ "c-s-z:$csz_count" \ "c-other:$cother_count" \ "d-r:$dr_count" \ @@ -142,14 +145,14 @@ for name in $classified; do done total_count="$(wc -l < "$tests" | tr -d ' ')" -assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count)) +assigned_count=$((schema_count + ab_count + cal_count + cmo_count + cpr_count + csz_count + cother_count + dr_count + sz_count)) if [ "$assigned_count" -ne "$total_count" ]; then printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2 exit 1 fi -printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \ - "$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count" +printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-o=%s c-p-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \ + "$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmo_count" "$cpr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count" if [ "$mode" = "verify" ]; then exit 0 @@ -208,7 +211,8 @@ run_named_partition() { schema) run_partition schema no-race "$schema_pattern" ;; a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;; c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;; - c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;; + c-m-o) run_partition c-m-o race '^TestCrossPlatformCoverage[M-O]' "$schema_pattern" ;; + c-p-r) run_partition c-p-r race '^TestCrossPlatformCoverage[P-R]' "$schema_pattern" ;; c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;; c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;; d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;; diff --git a/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh b/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh new file mode 100755 index 00000000..d9bb9a97 --- /dev/null +++ b/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh @@ -0,0 +1,798 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Opt-in, real-data release evidence gate for the Dev -> DevApp -> Agoal +# Shortcut delivery group. The current worktree binary supplies both exact +# `+shortcut` leaves and their owning atomic command trees. Raw responses and +# stable IDs stay in a repository-external temporary directory; stdout contains +# only PII-free PASS labels and aggregate facts. + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +dws_bin=${DWS_SHORTCUT_GATE_BIN:-"$repo_root/dws"} +selected_product=${1:-all} + +case "$selected_product" in + all | dev | devapp | agoal) ;; + *) + echo "usage: $0 [all|dev|devapp|agoal]" >&2 + exit 2 + ;; +esac + +if [[ ${DWS_SHORTCUT_LIVE_GATE:-} != 1 ]]; then + echo "set DWS_SHORTCUT_LIVE_GATE=1 to authorize the real-data dual-layer gate" >&2 + exit 2 +fi +if [[ $selected_product == all || $selected_product == devapp ]]; then + if [[ ! -t 0 ]]; then + echo "the DevApp live gate requires an interactive terminal for explicit write and cleanup confirmation" >&2 + exit 2 + fi + read -r -p "This gate creates, changes, and deletes isolated DevApp fixtures. Type RUN to authorize the writes and cleanup: " live_confirmation + if [[ $live_confirmation != RUN ]]; then + echo "live gate authorization declined" >&2 + exit 2 + fi +fi +if [[ ! -x $dws_bin ]]; then + echo "current worktree binary is missing; run make build first" >&2 + exit 2 +fi +command -v jq >/dev/null 2>&1 || { + echo "jq is required" >&2 + exit 2 +} + +member_user_id="" +member_type=${DWS_DEVAPP_E2E_MEMBER_TYPE:-DEVELOPER} +if [[ $selected_product == all || $selected_product == devapp ]]; then + member_user_id_file=${DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE:-} + if [[ -z $member_user_id_file || ! -f $member_user_id_file ]]; then + echo "set DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE to a private file containing one safe test-account userId" >&2 + exit 2 + fi + IFS= read -r member_user_id <"$member_user_id_file" || true + [[ -n $member_user_id ]] || { + echo "the private DevApp member fixture is empty" >&2 + exit 2 + } + [[ -n $member_type ]] || { + echo "DWS_DEVAPP_E2E_MEMBER_TYPE must not be empty" >&2 + exit 2 + } +fi + +evidence_dir=$(mktemp -d "${TMPDIR:-/tmp}/dws-shortcut-dual-live.XXXXXX") +exact_app_id="" +exact_app_name="" +raw_app_id="" +raw_app_name="" + +fail() { + echo "FAIL $1 (secure response retained only until trap cleanup)" >&2 + exit 1 +} + +run_json() { + local label=$1 + local output=$2 + shift 2 + if ! "$@" --format json >"$output" 2>"$output.stderr"; then + fail "$label" + fi + jq -e . "$output" >/dev/null 2>&1 || fail "$label: non-JSON response" +} + +assert_jq() { + local label=$1 + local expression=$2 + shift 2 + jq -e "$expression" "$@" >/dev/null 2>&1 || fail "$label" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +assert_public_surface() { + local product=$1 + local expected=$2 + local actual + actual=$(jq -r --arg product "$product" '.results[] | select(.service == $product) | .command' "$repo_root/docs/shortcut-public-catalog.json" | LC_ALL=C sort) + expected=$(printf '%s\n' "$expected" | sed '/^$/d' | LC_ALL=C sort) + [[ $actual == "$expected" ]] || fail "$product public catalog differs from the exhaustive live matrix" +} + +cleanup() { + set +e + if [[ -n $exact_app_id ]]; then + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes --format json >/dev/null 2>&1 + fi + if [[ -n $raw_app_id ]]; then + "$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes --format json >/dev/null 2>&1 + fi + rm -rf "$evidence_dir" +} +trap cleanup EXIT INT TERM + +run_dev_gate() { + # Dev owns routing/documentation only and registers no Shortcut leaves. + assert_public_surface dev "" + pass "DEV-INVENTORY public=0 fully-unlocked=0 hardened-blocked=0 routed=4 platform-unavailable=1" +} + +expect_confirmation() { + local label=$1 + local output=$2 + shift 2 + local status=0 + # The outer gate intentionally requires a TTY for one explicit RUN + # authorization. The command under test must not inherit that TTY here: + # otherwise its own confirmation prompt blocks instead of returning the + # machine-readable confirmation_required result that proves zero writes. + "$@" --format json "$output" 2>"$output.stderr" || status=$? + [[ $status -ne 0 ]] || fail "$label: unconfirmed command unexpectedly succeeded" + jq -e '.ok == false and .error.type == "validation" and .error.subtype == "confirmation_required"' "$output" >/dev/null 2>&1 || fail "$label: missing confirmation_required" +} + +run_devapp_gate() { + assert_public_surface devapp "+create ++credentials-get ++delete ++disable ++enable ++event-list ++event-subscribe ++get ++list ++member-add ++member-list ++member-remove ++permission-list ++robot-config ++robot-disable ++robot-enable ++robot-get ++update ++version-check-approval ++version-create ++version-get ++version-list ++version-status ++webapp-config ++webapp-get" + local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A" + local suffix="$(date +%s)-$$" + exact_app_name="DWS-E2E-EXACT-$suffix" + raw_app_name="DWS-E2E-RAW-$suffix" + local exact_updated_name="DWS-E2E-UPDATED-$suffix" + local raw_updated_name="DWS-E2E-RAW-UPDATED-$suffix" + local bot_raw="DWS-E2E-BOT-RAW-$suffix" + local bot_exact="DWS-E2E-BOT-EXACT-$suffix" + local event_raw="bpms_task_change" + local event_exact="$event_raw" + + run_json "devapp create guard before exact query" "$evidence_dir/devapp-create-guard-before-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp create guard before raw query" "$evidence_dir/devapp-create-guard-before-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + expect_confirmation "devapp +create confirmation" "$evidence_dir/devapp-create-confirm.json" \ + "$dws_bin" devapp +create --name "$exact_app_name" + run_json "devapp create guard after exact query" "$evidence_dir/devapp-create-guard-after-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp create guard after raw query" "$evidence_dir/devapp-create-guard-after-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + assert_jq "devapp +create confirmation zero-call state" ' + (input) as $br | (input) as $ae | (input) as $ar | + .data.count == 0 and ($br.data.items | length) == 0 and + $ae.data.count == 0 and ($ar.data.items | length) == 0 + ' "$evidence_dir/devapp-create-guard-before-exact.json" "$evidence_dir/devapp-create-guard-before-raw.json" \ + "$evidence_dir/devapp-create-guard-after-exact.json" "$evidence_dir/devapp-create-guard-after-raw.json" + + run_json "devapp exact create" "$evidence_dir/devapp-create-exact.json" \ + "$dws_bin" devapp +create --name "$exact_app_name" --desc "dual-live-exact" --yes + exact_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-exact.json") || fail "devapp exact create stable ID" + assert_jq "devapp exact create terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "create"' "$evidence_dir/devapp-create-exact.json" + run_json "devapp exact create raw readback" "$evidence_dir/devapp-create-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$exact_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-exact-raw-readback.json" >/dev/null || fail "devapp exact create raw readback" + + run_json "devapp raw create" "$evidence_dir/devapp-create-raw.json" \ + "$dws_bin" dev app create --name "$raw_app_name" --desc "dual-live-raw" --yes + raw_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-raw.json") || fail "devapp raw create stable ID" + assert_jq "devapp raw create terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-create-raw.json" + run_json "devapp raw create exact readback" "$evidence_dir/devapp-create-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$raw_app_id" + jq -e --arg id "$raw_app_id" --arg name "$raw_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-raw-exact-readback.json" >/dev/null || fail "devapp raw create exact readback" + pass "DEVAPP +create confirm0/exact+raw terminal-receipt/stable-ID readback" + + run_json "devapp guard snapshot app" "$evidence_dir/devapp-guard-app-before.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot webapp" "$evidence_dir/devapp-guard-webapp-before.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot robot" "$evidence_dir/devapp-guard-robot-before.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot events" "$evidence_dir/devapp-guard-events-before.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp guard snapshot versions" "$evidence_dir/devapp-guard-versions-before.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + + expect_confirmation "devapp +update confirmation" "$evidence_dir/devapp-update-confirm.json" \ + "$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "guard-must-not-write" + expect_confirmation "devapp +delete confirmation" "$evidence_dir/devapp-delete-confirm.json" \ + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" + expect_confirmation "devapp +enable confirmation" "$evidence_dir/devapp-enable-confirm.json" \ + "$dws_bin" devapp +enable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +disable confirmation" "$evidence_dir/devapp-disable-confirm.json" \ + "$dws_bin" devapp +disable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +webapp-config confirmation" "$evidence_dir/devapp-webapp-confirm.json" \ + "$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/guard" + expect_confirmation "devapp +robot-config confirmation" "$evidence_dir/devapp-robot-config-confirm.json" \ + "$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "guard-must-not-write" --mode STREAM + expect_confirmation "devapp +robot-enable confirmation" "$evidence_dir/devapp-robot-enable-confirm.json" \ + "$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +robot-disable confirmation" "$evidence_dir/devapp-robot-disable-confirm.json" \ + "$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +event-subscribe confirmation" "$evidence_dir/devapp-event-confirm.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" + expect_confirmation "devapp +version-create confirmation" "$evidence_dir/devapp-version-create-confirm.json" \ + "$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "guard-must-not-write" + + run_json "devapp guard snapshot app after" "$evidence_dir/devapp-guard-app-after.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot webapp after" "$evidence_dir/devapp-guard-webapp-after.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot robot after" "$evidence_dir/devapp-guard-robot-after.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot events after" "$evidence_dir/devapp-guard-events-after.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp guard snapshot versions after" "$evidence_dir/devapp-guard-versions-after.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + assert_jq "devapp all public write confirmations preserve raw state" ' + (input) as $aa | (input) as $wb | (input) as $wa | (input) as $rb | (input) as $ra | + (input) as $eb | (input) as $ea | (input) as $vb | (input) as $va | + [.data.unifiedAppId,.data.name,.data.desc,.data.appStatus] == [$aa.data.unifiedAppId,$aa.data.name,$aa.data.desc,$aa.data.appStatus] and + $wb.data == $wa.data and $rb.data == $ra.data and + ([$eb.data.events[].eventCode] | sort) == ([$ea.data.events[].eventCode] | sort) and + ([$vb.data.items[].versionId] | sort) == ([$va.data.items[].versionId] | sort) + ' "$evidence_dir/devapp-guard-app-before.json" "$evidence_dir/devapp-guard-app-after.json" \ + "$evidence_dir/devapp-guard-webapp-before.json" "$evidence_dir/devapp-guard-webapp-after.json" \ + "$evidence_dir/devapp-guard-robot-before.json" "$evidence_dir/devapp-guard-robot-after.json" \ + "$evidence_dir/devapp-guard-events-before.json" "$evidence_dir/devapp-guard-events-after.json" \ + "$evidence_dir/devapp-guard-versions-before.json" "$evidence_dir/devapp-guard-versions-after.json" + + run_json "devapp exact list known" "$evidence_dir/devapp-list-known-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp raw list known" "$evidence_dir/devapp-list-known-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + run_json "devapp exact list zero" "$evidence_dir/devapp-list-zero-exact.json" \ + "$dws_bin" devapp +list --name "$nonce" --page-size 20 + run_json "devapp raw list zero" "$evidence_dir/devapp-list-zero-raw.json" \ + "$dws_bin" dev app list --name "$nonce" --page-size 20 + jq -e --arg id "$exact_app_id" ' + (input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .outcome == "success" and .data.count == 1 and .data.apps[0].unifiedAppId == $id and + $raw.ok == true and $raw.outcome == "success" and ($raw.data.items | length) == 1 and $raw.data.items[0].unifiedAppId == $id and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.apps | length) == 0 and + $zr.ok == true and ($zr.data.items | length) == 0 and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-list-known-exact.json" "$evidence_dir/devapp-list-known-raw.json" \ + "$evidence_dir/devapp-list-zero-exact.json" "$evidence_dir/devapp-list-zero-raw.json" >/dev/null || fail "devapp +list dual known/zero alignment" + pass "DEVAPP +list exact+raw known-nonempty/guaranteed-zero stable-ID/cursor termination" + + run_json "devapp exact get" "$evidence_dir/devapp-get-exact.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + run_json "devapp raw get" "$evidence_dir/devapp-get-raw.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .ok == true and .data.unifiedAppId == $id and $raw.ok == true and $raw.data.unifiedAppId == $id and .data.appKey == $raw.data.appKey' "$evidence_dir/devapp-get-exact.json" "$evidence_dir/devapp-get-raw.json" >/dev/null || fail "devapp +get dual object read" + pass "DEVAPP +get exact+raw stable-application-ID object alignment" + + run_json "devapp raw update" "$evidence_dir/devapp-update-raw.json" \ + "$dws_bin" dev app update --unified-app-id "$exact_app_id" --name "$raw_updated_name" --desc "raw-update" --yes + assert_jq "devapp raw update terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-update-raw.json" + run_json "devapp raw update exact readback" "$evidence_dir/devapp-update-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$raw_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "raw-update"' "$evidence_dir/devapp-update-raw-exact-readback.json" >/dev/null || fail "devapp raw update exact readback" + run_json "devapp exact update" "$evidence_dir/devapp-update-exact.json" \ + "$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "$exact_updated_name" --desc "exact-update" --yes + assert_jq "devapp exact update terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "update"' "$evidence_dir/devapp-update-exact.json" + run_json "devapp exact update raw readback" "$evidence_dir/devapp-update-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$exact_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "exact-update"' "$evidence_dir/devapp-update-exact-raw-readback.json" >/dev/null || fail "devapp exact update raw readback" + pass "DEVAPP +update confirm0/exact+raw terminal-receipt/key-field readback" + + run_json "devapp raw disable" "$evidence_dir/devapp-disable-raw.json" \ + "$dws_bin" dev app disable --unified-app-id "$exact_app_id" --yes + run_json "devapp raw disable exact readback" "$evidence_dir/devapp-disable-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + assert_jq "devapp raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-disable-raw.json" + assert_jq "devapp raw disable exact state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-raw-exact-readback.json" + run_json "devapp exact enable" "$evidence_dir/devapp-enable-exact.json" \ + "$dws_bin" devapp +enable --unified-app-id "$exact_app_id" --yes + run_json "devapp exact enable raw readback" "$evidence_dir/devapp-enable-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + assert_jq "devapp exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-enable-exact.json" + assert_jq "devapp exact enable raw state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-exact-raw-readback.json" + run_json "devapp exact disable" "$evidence_dir/devapp-disable-exact.json" \ + "$dws_bin" devapp +disable --unified-app-id "$exact_app_id" --yes + run_json "devapp exact disable raw readback" "$evidence_dir/devapp-disable-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + assert_jq "devapp exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-disable-exact.json" + assert_jq "devapp exact disable raw state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-exact-raw-readback.json" + run_json "devapp raw enable" "$evidence_dir/devapp-enable-raw.json" \ + "$dws_bin" dev app enable --unified-app-id "$exact_app_id" --yes + run_json "devapp raw enable exact readback" "$evidence_dir/devapp-enable-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + assert_jq "devapp raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-enable-raw.json" + assert_jq "devapp raw enable exact state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-raw-exact-readback.json" + pass "DEVAPP +disable confirm0/exact+raw terminal-receipt/disabled readback/restored" + pass "DEVAPP +enable confirm0/exact+raw terminal-receipt/normal readback" + + run_json "devapp exact credentials" "$evidence_dir/devapp-credentials-exact.json" \ + "$dws_bin" devapp +credentials-get --unified-app-id "$exact_app_id" + run_json "devapp raw credentials" "$evidence_dir/devapp-credentials-raw.json" \ + "$dws_bin" dev app credentials get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.unifiedAppId == $id and + (.data.appKey | type) == "string" and (.data.appKey | length) > 0 and + (.data.appSecret | type) == "string" and (.data.appSecret | length) > 0 and + $raw.ok == true and $raw.data.unifiedAppId == $id and + .data.appKey == $raw.data.appKey and .data.appSecret == $raw.data.appSecret + ' "$evidence_dir/devapp-credentials-exact.json" "$evidence_dir/devapp-credentials-raw.json" >/dev/null || fail "devapp +credentials-get dual object read" + pass "DEVAPP +credentials-get exact+raw stable-app/client/one-nonempty-secret alignment" + + run_json "devapp raw webapp config" "$evidence_dir/devapp-webapp-config-raw.json" \ + "$dws_bin" dev app webapp config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/raw" --yes + run_json "devapp raw webapp exact readback" "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" \ + "$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id" + assert_jq "devapp raw webapp terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-webapp-config-raw.json" + assert_jq "devapp raw webapp exact readback" '.data.homepageUrl == "https://example.invalid/raw"' "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" + run_json "devapp exact webapp config" "$evidence_dir/devapp-webapp-config-exact.json" \ + "$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/exact" --yes + run_json "devapp exact webapp raw readback" "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + assert_jq "devapp exact webapp terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-webapp-config-exact.json" + assert_jq "devapp exact webapp raw readback" '.data.homepageUrl == "https://example.invalid/exact"' "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" + pass "DEVAPP +webapp-config confirm0/exact+raw terminal-receipt/URL readback" + run_json "devapp exact webapp get final" "$evidence_dir/devapp-webapp-get-exact.json" \ + "$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id" + run_json "devapp raw webapp get final" "$evidence_dir/devapp-webapp-get-raw.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.homepageUrl == $raw.data.homepageUrl' "$evidence_dir/devapp-webapp-get-exact.json" "$evidence_dir/devapp-webapp-get-raw.json" >/dev/null || fail "devapp +webapp-get dual object read" + pass "DEVAPP +webapp-get exact+raw stable-app/key-field object alignment" + + run_json "devapp member raw known" "$evidence_dir/devapp-member-known-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + run_json "devapp member exact known" "$evidence_dir/devapp-member-known-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" + run_json "devapp member exact zero" "$evidence_dir/devapp-member-zero-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$nonce" + jq -e --arg nonce "$nonce" ' + (input) as $raw | (input) as $zero | + .ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.members | type) == "array" and + ([.data.members[].userId] | sort) == ([$raw.data.members[].userId] | sort) and + $zero.ok == true and $zero.data.count == 0 and ($zero.data.members | length) == 0 and + ([$raw.data.members[].userId] | index($nonce)) == null + ' "$evidence_dir/devapp-member-known-exact.json" "$evidence_dir/devapp-member-known-raw.json" "$evidence_dir/devapp-member-zero-exact.json" >/dev/null || fail "devapp +member-list adapter alignment" + pass "DEVAPP +member-list exact local-ID-filter+raw full-set known/guaranteed-zero stable-member alignment" + + run_json "devapp member fixture exact before" "$evidence_dir/devapp-member-fixture-before-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + run_json "devapp member fixture raw before" "$evidence_dir/devapp-member-fixture-before-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-fixture-before-exact.json" "$evidence_dir/devapp-member-fixture-before-raw.json" >/dev/null || fail "devapp member fixture initially absent" + + expect_confirmation "devapp +member-add confirmation" "$evidence_dir/devapp-member-add-confirm.json" \ + "$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" + expect_confirmation "devapp +member-remove confirmation" "$evidence_dir/devapp-member-remove-confirm.json" \ + "$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" + run_json "devapp member fixture exact after guards" "$evidence_dir/devapp-member-fixture-after-guards-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + run_json "devapp member fixture raw after guards" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-fixture-after-guards-exact.json" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" >/dev/null || fail "devapp member confirmations made no writes" + + run_json "devapp member exact add" "$evidence_dir/devapp-member-add-exact.json" \ + "$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member exact add raw readback" "$evidence_dir/devapp-member-add-exact-raw-readback.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg type "$member_type" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_add" and .data.resource.count == 1 and .data.resource.memberType == $type' \ + "$evidence_dir/devapp-member-add-exact.json" >/dev/null || fail "devapp member exact add terminal" + jq -e --arg user "$member_user_id" --arg type "$member_type" '([.data.members[] | select(.userId == $user and .memberType == $type)] | length) == 1' \ + "$evidence_dir/devapp-member-add-exact-raw-readback.json" >/dev/null || fail "devapp member exact add raw stable-ID role readback" + + run_json "devapp member raw remove" "$evidence_dir/devapp-member-remove-raw.json" \ + "$dws_bin" dev app member remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member raw remove exact readback" "$evidence_dir/devapp-member-remove-raw-exact-readback.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + assert_jq "devapp member raw remove terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-remove-raw.json" + assert_jq "devapp member raw remove exact absence" '.ok == true and .data.count == 0 and (.data.members | length) == 0' "$evidence_dir/devapp-member-remove-raw-exact-readback.json" + + run_json "devapp member raw add" "$evidence_dir/devapp-member-add-raw.json" \ + "$dws_bin" dev app member add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member raw add exact readback" "$evidence_dir/devapp-member-add-raw-exact-readback.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + assert_jq "devapp member raw add terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-add-raw.json" + jq -e --arg user "$member_user_id" --arg type "$member_type" \ + '.ok == true and .data.count == 1 and .data.members[0].userId == $user and .data.members[0].memberType == $type' \ + "$evidence_dir/devapp-member-add-raw-exact-readback.json" >/dev/null || fail "devapp member raw add exact stable-ID role readback" + + run_json "devapp member exact remove" "$evidence_dir/devapp-member-remove-exact.json" \ + "$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member exact remove raw readback" "$evidence_dir/devapp-member-remove-exact-raw-readback.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + assert_jq "devapp member exact remove terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_remove"' \ + "$evidence_dir/devapp-member-remove-exact.json" + jq -e --arg user "$member_user_id" '([.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-remove-exact-raw-readback.json" >/dev/null || fail "devapp member exact remove raw absence" + pass "DEVAPP +member-add/+member-remove confirm0/exact+raw terminal-receipt/stable-ID role/absence readback" + + run_json "devapp permission exact known" "$evidence_dir/devapp-permission-known-exact.json" \ + "$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --page-size 20 + run_json "devapp permission raw known" "$evidence_dir/devapp-permission-known-raw.json" \ + "$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --page-size 20 + run_json "devapp permission exact zero" "$evidence_dir/devapp-permission-zero-exact.json" \ + "$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20 + run_json "devapp permission raw zero" "$evidence_dir/devapp-permission-zero-raw.json" \ + "$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20 + assert_jq "devapp permission dual known/zero alignment" ' + (input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.items | length) > 0 and + ([.data.permissions[].scopeValue] | sort) == ([$raw.data.items[].scopeValue] | sort) and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.permissions | length) == 0 and + $zr.ok == true and ($zr.data.items | length) == 0 and + .meta.pagination.endpoint_exhausted == $raw.meta.pagination.endpoint_exhausted and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-permission-known-exact.json" "$evidence_dir/devapp-permission-known-raw.json" \ + "$evidence_dir/devapp-permission-zero-exact.json" "$evidence_dir/devapp-permission-zero-raw.json" + pass "DEVAPP +permission-list exact+raw known-nonempty/guaranteed-zero stable-scope/cursor alignment" + + run_json "devapp robot exact initial" "$evidence_dir/devapp-robot-get-exact.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + run_json "devapp robot raw initial" "$evidence_dir/devapp-robot-get-raw.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.robotStatus == $raw.data.robotStatus' "$evidence_dir/devapp-robot-get-exact.json" "$evidence_dir/devapp-robot-get-raw.json" >/dev/null || fail "devapp +robot-get initial dual object" + pass "DEVAPP +robot-get exact+raw stable-app/status object alignment" + + run_json "devapp robot raw config" "$evidence_dir/devapp-robot-config-raw.json" \ + "$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --desc "dual-live-raw" --mode STREAM --add-scope --yes + run_json "devapp robot raw config exact readback" "$evidence_dir/devapp-robot-config-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw config terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-config-raw.json" + jq -e --arg name "$bot_raw" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-raw-exact-readback.json" >/dev/null || fail "devapp robot raw config exact readback" + run_json "devapp robot exact config" "$evidence_dir/devapp-robot-config-exact.json" \ + "$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "$bot_exact" --brief "dual-live-exact" --desc "dual-live-exact" --mode STREAM --yes + run_json "devapp robot exact config raw readback" "$evidence_dir/devapp-robot-config-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact config terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-config-exact.json" + jq -e --arg name "$bot_exact" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-exact-raw-readback.json" >/dev/null || fail "devapp robot exact config raw readback" + pass "DEVAPP +robot-config confirm0/exact+raw terminal-receipt/config-field readback" + + run_json "devapp robot raw enable" "$evidence_dir/devapp-robot-enable-raw.json" \ + "$dws_bin" dev app robot enable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot raw enable exact readback" "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-enable-raw.json" + assert_jq "devapp robot raw enable exact state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" + run_json "devapp robot exact disable" "$evidence_dir/devapp-robot-disable-exact.json" \ + "$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot exact disable raw readback" "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-disable-exact.json" + assert_jq "devapp robot exact disable raw state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" + run_json "devapp robot raw reconfigure" "$evidence_dir/devapp-robot-reconfigure-raw.json" \ + "$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --mode STREAM --yes + run_json "devapp robot exact enable" "$evidence_dir/devapp-robot-enable-exact.json" \ + "$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot exact enable raw readback" "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-enable-exact.json" + assert_jq "devapp robot exact enable raw state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" + run_json "devapp robot raw disable" "$evidence_dir/devapp-robot-disable-raw.json" \ + "$dws_bin" dev app robot disable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot raw disable exact readback" "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-disable-raw.json" + assert_jq "devapp robot raw disable exact state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" + pass "DEVAPP +robot-enable confirm0/exact+raw terminal-receipt/ONLINE readback" + pass "DEVAPP +robot-disable confirm0/exact+raw terminal-receipt/UNCONFIGURED readback" + + run_json "devapp event raw subscribe" "$evidence_dir/devapp-event-subscribe-raw.json" \ + "$dws_bin" dev app event subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes + run_json "devapp event raw subscribe exact replay" "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes + run_json "devapp event raw subscribe raw readback" "$evidence_dir/devapp-event-subscribe-raw-readback.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_raw" --page-size 100 + assert_jq "devapp event raw subscribe terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-event-subscribe-raw.json" + assert_jq "devapp event raw subscribe exact replay" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" + jq -e --arg code "$event_raw" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-raw-readback.json" >/dev/null || fail "devapp event raw subscribe readback" + run_json "devapp event exact subscribe" "$evidence_dir/devapp-event-subscribe-exact.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" --yes + run_json "devapp event exact subscribe raw readback" "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + assert_jq "devapp event exact subscribe terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-exact.json" + jq -e --arg code "$event_exact" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" >/dev/null || fail "devapp event exact subscribe raw readback" + pass "DEVAPP +event-subscribe confirm0/exact+raw terminal-receipt/eventCode readback" + + run_json "devapp event exact known" "$evidence_dir/devapp-event-list-known-exact.json" \ + "$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + run_json "devapp event raw known" "$evidence_dir/devapp-event-list-known-raw.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + run_json "devapp event exact zero" "$evidence_dir/devapp-event-list-zero-exact.json" \ + "$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100 + run_json "devapp event raw zero" "$evidence_dir/devapp-event-list-zero-raw.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100 + jq -e --arg code "$event_exact" '(input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .data.count == 1 and .data.events[0].eventCode == $code and + $raw.ok == true and ($raw.data.events | length) == 1 and $raw.data.events[0].eventCode == $code and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.events | length) == 0 and + $zr.ok == true and ($zr.data.events | length) == 0 and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true' \ + "$evidence_dir/devapp-event-list-known-exact.json" "$evidence_dir/devapp-event-list-known-raw.json" \ + "$evidence_dir/devapp-event-list-zero-exact.json" "$evidence_dir/devapp-event-list-zero-raw.json" >/dev/null || fail "devapp +event-list dual known/zero alignment" + pass "DEVAPP +event-list exact+raw known-nonempty/guaranteed-zero stable-event/cursor alignment" + + run_json "devapp version exact zero before" "$evidence_dir/devapp-version-zero-exact.json" \ + "$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp version raw zero before" "$evidence_dir/devapp-version-zero-raw.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + assert_jq "devapp version guaranteed zero fixture" '(input) as $raw | .data.count == 0 and (.data.versions | length) == 0 and ($raw.data.items | length) == 0 and .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true' "$evidence_dir/devapp-version-zero-exact.json" "$evidence_dir/devapp-version-zero-raw.json" + run_json "devapp version raw create" "$evidence_dir/devapp-version-create-raw.json" \ + "$dws_bin" dev app version create --unified-app-id "$exact_app_id" --desc "dual-live-raw" --yes + local raw_version_id + raw_version_id=$(jq -er '.data.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-raw.json") || fail "devapp raw version create stable ID" + assert_jq "devapp raw version create terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-version-create-raw.json" + run_json "devapp raw version exact readback" "$evidence_dir/devapp-version-create-raw-exact-readback.json" \ + "$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$raw_version_id" + jq -e --arg app "$exact_app_id" --arg version "$raw_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-raw-exact-readback.json" >/dev/null || fail "devapp raw version exact readback" + run_json "devapp version exact create" "$evidence_dir/devapp-version-create-exact.json" \ + "$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "dual-live-exact" --yes + local exact_version_id + exact_version_id=$(jq -er '.data.resource.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-exact.json") || fail "devapp exact version create stable ID" + assert_jq "devapp exact version create terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-version-create-exact.json" + run_json "devapp exact version raw readback" "$evidence_dir/devapp-version-create-exact-raw-readback.json" \ + "$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-exact-raw-readback.json" >/dev/null || fail "devapp exact version raw readback" + pass "DEVAPP +version-create confirm0/exact+raw terminal-receipt/app+version-ID readback" + + local version_aligned=false + for _ in 1 2 3 4 5; do + run_json "devapp version exact known" "$evidence_dir/devapp-version-known-exact.json" \ + "$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp version raw known" "$evidence_dir/devapp-version-known-raw.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + if jq -e --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" ' + (input) as $raw | + .data.count >= 1 and + ([.data.versions[].versionId] | index($raw_version)) != null and + ([.data.versions[].versionId] | index($exact_version)) != null and + ([.data.versions[].versionId] | sort) == ([$raw.data.items[].versionId] | sort) and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-version-known-exact.json" "$evidence_dir/devapp-version-known-raw.json" >/dev/null 2>&1; then + version_aligned=true + break + fi + sleep 2 + done + if [[ $version_aligned != true ]]; then + jq -n -c --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" \ + --slurpfile exact "$evidence_dir/devapp-version-known-exact.json" \ + --slurpfile raw "$evidence_dir/devapp-version-known-raw.json" '{ + diagnostic:"DEVAPP-VERSION-LIST", + exact_count:$exact[0].data.count, + exact_items:($exact[0].data.versions|length), + raw_items:($raw[0].data.items|length), + ids_equal:(([$exact[0].data.versions[].versionId]|sort)==([$raw[0].data.items[].versionId]|sort)), + raw_version_visible:(([$exact[0].data.versions[].versionId]|index($raw_version))!=null), + exact_version_visible:(([$exact[0].data.versions[].versionId]|index($exact_version))!=null), + exact_pagination_type:($exact[0].meta.pagination|type), + raw_pagination_type:($raw[0].meta.pagination|type), + exact_exhausted:$exact[0].meta.pagination.endpoint_exhausted, + raw_exhausted:$raw[0].meta.pagination.endpoint_exhausted + }' >&2 + fail "devapp version known alignment" + fi + pass "DEVAPP +version-list exact+raw known-nonempty/empty-fixture-zero stable-version/cursor alignment" + + run_json "devapp version exact get" "$evidence_dir/devapp-version-get-exact.json" \ + "$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw get" "$evidence_dir/devapp-version-get-raw.json" \ + "$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | .data.unifiedAppId == $app and .data.versionId == $version and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-get-exact.json" "$evidence_dir/devapp-version-get-raw.json" >/dev/null || fail "devapp +version-get dual object" + pass "DEVAPP +version-get exact+raw stable-app+version-ID object alignment" + + run_json "devapp version exact approval" "$evidence_dir/devapp-version-approval-exact.json" \ + "$dws_bin" devapp +version-check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw approval" "$evidence_dir/devapp-version-approval-raw.json" \ + "$dws_bin" dev app version check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-approval-exact.json" "$evidence_dir/devapp-version-approval-raw.json" >/dev/null || fail "devapp +version-check-approval dual object" + pass "DEVAPP +version-check-approval exact+raw stable-app+version-ID pending/terminal alignment" + + run_json "devapp version exact status" "$evidence_dir/devapp-version-status-exact.json" \ + "$dws_bin" devapp +version-status --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw status" "$evidence_dir/devapp-version-status-raw.json" \ + "$dws_bin" dev app version status --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-status-exact.json" "$evidence_dir/devapp-version-status-raw.json" >/dev/null || fail "devapp +version-status dual object" + pass "DEVAPP +version-status exact+raw stable-app+version-ID status alignment" + + run_json "devapp raw delete" "$evidence_dir/devapp-delete-raw.json" \ + "$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes + assert_jq "devapp raw delete terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-delete-raw.json" + run_json "devapp raw delete exact absence" "$evidence_dir/devapp-delete-raw-exact-absence.json" \ + "$dws_bin" devapp +list --name "$raw_app_name" --page-size 20 + run_json "devapp raw delete raw absence" "$evidence_dir/devapp-delete-raw-raw-absence.json" \ + "$dws_bin" dev app list --name "$raw_app_name" --page-size 20 + assert_jq "devapp raw delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-raw-exact-absence.json" "$evidence_dir/devapp-delete-raw-raw-absence.json" + raw_app_id="" + + run_json "devapp exact delete" "$evidence_dir/devapp-delete-exact.json" \ + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes + assert_jq "devapp exact delete terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "delete"' "$evidence_dir/devapp-delete-exact.json" + run_json "devapp exact delete exact absence" "$evidence_dir/devapp-delete-exact-exact-absence.json" \ + "$dws_bin" devapp +list --name "$exact_updated_name" --page-size 20 + run_json "devapp exact delete raw absence" "$evidence_dir/devapp-delete-exact-raw-absence.json" \ + "$dws_bin" dev app list --name "$exact_updated_name" --page-size 20 + assert_jq "devapp exact delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-exact-exact-absence.json" "$evidence_dir/devapp-delete-exact-raw-absence.json" + exact_app_id="" + pass "DEVAPP +delete confirm0/exact+raw terminal-receipt/dual absence cleanup" + + pass "DEVAPP-INVENTORY public=25 dual-layer-pass=25 unavailable=5 cleanup=zero-residual" +} + +run_agoal_gate() { + assert_public_surface agoal "+contract-fields ++obj-template-list ++report-statistics-list ++report-submit-detail ++user-rules" + local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A" + local future_date="2099-12-31" + + run_json "agoal report statistics exact known" "$evidence_dir/agoal-report-known-exact.json" \ + "$dws_bin" agoal +report-statistics-list + run_json "agoal report statistics raw known" "$evidence_dir/agoal-report-known-raw.json" \ + "$dws_bin" agoal report list-statistics + assert_jq "agoal report statistics known alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count > 0 and + $raw.success == true and ($raw.content | type) == "array" and + (.data.count == ($raw.content | length)) and + ([.data.statistics[].templateId] | sort) == ([$raw.content[].templateId] | sort) + ' "$evidence_dir/agoal-report-known-exact.json" "$evidence_dir/agoal-report-known-raw.json" + run_json "agoal report statistics exact zero" "$evidence_dir/agoal-report-zero-exact.json" \ + "$dws_bin" agoal +report-statistics-list --keyword "$nonce" + run_json "agoal report statistics raw zero" "$evidence_dir/agoal-report-zero-raw.json" \ + "$dws_bin" agoal report list-statistics --keyword "$nonce" + assert_jq "agoal report statistics zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and + (.data.statistics | type) == "array" and (.data.statistics | length) == 0 and + $raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) == 0 + ' "$evidence_dir/agoal-report-zero-exact.json" "$evidence_dir/agoal-report-zero-raw.json" + pass "AGOAL +report-statistics-list exact+raw known-nonempty/guaranteed-zero stable-template alignment" + + run_json "agoal template exact known" "$evidence_dir/agoal-template-known-exact.json" \ + "$dws_bin" agoal +obj-template-list --page 1 --page-size 20 + run_json "agoal template raw known" "$evidence_dir/agoal-template-known-raw.json" \ + "$dws_bin" agoal obj-template list --page 1 --page-size 20 + assert_jq "agoal template known alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count > 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and + .data.totalCount == $raw.content.totalCount and + ([.data.templates[] | (.id // .templateId)] | sort) == + ([$raw.content.result[] | (.id // .templateId)] | sort) + ' "$evidence_dir/agoal-template-known-exact.json" "$evidence_dir/agoal-template-known-raw.json" + run_json "agoal template exact zero" "$evidence_dir/agoal-template-zero-exact.json" \ + "$dws_bin" agoal +obj-template-list --keyword "$nonce" --page 1 --page-size 20 + run_json "agoal template raw zero" "$evidence_dir/agoal-template-zero-raw.json" \ + "$dws_bin" agoal obj-template list --keyword "$nonce" --page 1 --page-size 20 + assert_jq "agoal template zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and + (.data.templates | type) == "array" and (.data.templates | length) == 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + ($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize + ' "$evidence_dir/agoal-template-zero-exact.json" "$evidence_dir/agoal-template-zero-raw.json" + pass "AGOAL +obj-template-list exact+raw known-nonempty/guaranteed-zero stable-ID/page alignment" + + run_json "agoal contract fields raw" "$evidence_dir/agoal-fields-raw.json" \ + "$dws_bin" agoal contract fields + local field_id + field_id=$(jq -er '.content[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-fields-raw.json") || fail "agoal contract fields raw fixture" + run_json "agoal contract fields exact known" "$evidence_dir/agoal-fields-known-exact.json" \ + "$dws_bin" agoal +contract-fields --keyword "$field_id" + run_json "agoal contract fields exact zero" "$evidence_dir/agoal-fields-zero-exact.json" \ + "$dws_bin" agoal +contract-fields --keyword "$nonce" + jq -e --arg field_id "$field_id" --arg nonce "$nonce" ' + (input) as $zero | (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 1 and + .data.fields[0].id == $field_id and + $zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and + ($zero.data.fields | length) == 0 and + $raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) > 0 and + ([$raw.content[].id] | index($field_id)) != null and + ([$raw.content[] | [.id,.code,.title,.category,.type] | map(select(type == "string")) | join(" ") | ascii_downcase] | + map(contains($nonce | ascii_downcase)) | any) == false + ' "$evidence_dir/agoal-fields-known-exact.json" "$evidence_dir/agoal-fields-zero-exact.json" "$evidence_dir/agoal-fields-raw.json" >/dev/null 2>&1 || fail "agoal contract fields adapter alignment" + pass "AGOAL +contract-fields exact local-filter+raw full-set known/guaranteed-zero stable-ID alignment" + + run_json "agoal user rules raw" "$evidence_dir/agoal-rules-raw.json" \ + "$dws_bin" agoal user rules + local rule_id + rule_id=$(jq -er '.content.rules[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-rules-raw.json") || fail "agoal user rules raw fixture" + run_json "agoal user rules exact known" "$evidence_dir/agoal-rules-known-exact.json" \ + "$dws_bin" agoal +user-rules --rule-id "$rule_id" + run_json "agoal user rules exact zero" "$evidence_dir/agoal-rules-zero-exact.json" \ + "$dws_bin" agoal +user-rules --rule-id "$nonce" + jq -e --arg rule_id "$rule_id" --arg nonce "$nonce" ' + (input) as $zero | (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 1 and .data.rules[0].id == $rule_id and + $zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and + ($zero.data.rules | length) == 0 and + $raw.success == true and ($raw.content.rules | type) == "array" and ($raw.content.rules | length) > 0 and + ([$raw.content.rules[].id] | index($rule_id)) != null and + ([$raw.content.rules[].id] | index($nonce)) == null + ' "$evidence_dir/agoal-rules-known-exact.json" "$evidence_dir/agoal-rules-zero-exact.json" "$evidence_dir/agoal-rules-raw.json" >/dev/null 2>&1 || fail "agoal user rules adapter alignment" + pass "AGOAL +user-rules exact local-filter+raw full-set known/guaranteed-zero stable-rule alignment" + + local template_id="" + local submit_state="" + while IFS= read -r candidate_template; do + for candidate_state in ON_TIME LATE NOT_SUBMITTED; do + run_json "agoal submission raw fixture scan" "$evidence_dir/agoal-submit-scan.json" \ + "$dws_bin" agoal report submit-detail --template-id "$candidate_template" --submit-state "$candidate_state" --page 1 --page-size 20 --timeout 60 + if jq -e '.success == true and (.content.result | type) == "array" and (.content.result | length) > 0' "$evidence_dir/agoal-submit-scan.json" >/dev/null; then + template_id=$candidate_template + submit_state=$candidate_state + cp "$evidence_dir/agoal-submit-scan.json" "$evidence_dir/agoal-submit-known-raw.json" + break 2 + fi + done + done < <(jq -r '.content[].templateId' "$evidence_dir/agoal-report-known-raw.json") + [[ -n $template_id && -n $submit_state ]] || fail "agoal submission known-nonempty fixture" + run_json "agoal submission exact known" "$evidence_dir/agoal-submit-known-exact.json" \ + "$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state "$submit_state" --page 1 --page-size 20 --timeout 60 + assert_jq "agoal submission known alignment" ' + (input) as $raw | + def stable_id: (.reportId // .user.dingUserId // .user.id); + .ok == true and .outcome == "success" and .data.count > 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and + .data.totalCount == $raw.content.totalCount and + ([.data.submissions[] | stable_id] | sort) == ([$raw.content.result[] | stable_id] | sort) + ' "$evidence_dir/agoal-submit-known-exact.json" "$evidence_dir/agoal-submit-known-raw.json" + run_json "agoal submission exact guaranteed zero" "$evidence_dir/agoal-submit-zero-exact.json" \ + "$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60 + run_json "agoal submission raw guaranteed zero" "$evidence_dir/agoal-submit-zero-raw.json" \ + "$dws_bin" agoal report submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60 + assert_jq "agoal submission guaranteed zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and + (.data.submissions | type) == "array" and (.data.submissions | length) == 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + ($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize + ' "$evidence_dir/agoal-submit-zero-exact.json" "$evidence_dir/agoal-submit-zero-raw.json" + pass "AGOAL +report-submit-detail exact+raw known-nonempty/future guaranteed-zero stable-user/page alignment" + + pass "AGOAL-INVENTORY public=5 dual-layer-pass=5 unavailable=11" +} + +if [[ $selected_product == all || $selected_product == dev ]]; then + run_dev_gate +fi +if [[ $selected_product == all || $selected_product == devapp ]]; then + # Added below after the Agoal proof block so product execution remains + # Dev -> DevApp -> Agoal when `all` is selected. + run_devapp_gate +fi +if [[ $selected_product == all || $selected_product == agoal ]]; then + run_agoal_gate +fi diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index de3a1f2d..8d8a416b 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -29,9 +29,19 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_aisearch.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_contact.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_live.json", ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", ROOT / "internal" / "shortcut" / "semantic_catalog_report.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_sheet.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_whiteboard.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_devapp.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_agoal.json", ] diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index 1df63b03..f5fe0fb0 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -41,11 +41,16 @@ SERVICE_TO_SKILL = { "mail": ROOT / "skills" / "multi" / "dingtalk-mail" / "SKILL.md", "minutes": ROOT / "skills" / "multi" / "dingtalk-minutes" / "SKILL.md", "oa": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "oa.md", + "pat": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "pat.md", "report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md", "sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md", "todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md", + "whiteboard": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "whiteboard.md", "wiki": ROOT / "skills" / "multi" / "dingtalk-wiki" / "SKILL.md", } +SERVICE_TO_SKILL_MIRRORS = { + "whiteboard": [ROOT / "skills" / "mono" / "references" / "products" / "whiteboard.md"], +} MONO_START = "" MONO_END = "" @@ -226,17 +231,19 @@ def update_product_skills(items: list[dict[str, Any]], check: bool) -> list[Path for item in items: by_service[item["service"]].append(item) changed = [] - for service, path in SERVICE_TO_SKILL.items(): + for service, primary_path in SERVICE_TO_SKILL.items(): if service not in by_service: continue - if not path.exists(): - raise RuntimeError(f"skill file not found for {service}: {path}") - text = path.read_text(encoding="utf-8") - block = product_section(service, by_service[service]) - anchor = "## 概念地图" if service == "devapp" else "## 意图表" - updated = replace_block(text, PRODUCT_START, PRODUCT_END, block, anchor) - if apply_update(path, text, updated, check): - changed.append(path) + paths = [primary_path, *SERVICE_TO_SKILL_MIRRORS.get(service, [])] + for path in paths: + if not path.exists(): + raise RuntimeError(f"skill file not found for {service}: {path}") + text = path.read_text(encoding="utf-8") + block = product_section(service, by_service[service]) + anchor = "## 概念地图" if service == "devapp" else "## 意图表" + updated = replace_block(text, PRODUCT_START, PRODUCT_END, block, anchor) + if apply_update(path, text, updated, check): + changed.append(path) return changed diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh index a617831e..27c20c9e 100755 --- a/scripts/policy/check-release-fragments.sh +++ b/scripts/policy/check-release-fragments.sh @@ -19,17 +19,172 @@ trap cleanup EXIT HUP INT TERM git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status" +normalize_notes() { + awk ' + /^[[:space:]]*$/ && !started { next } + { started = 1; lines[++count] = $0 } + END { + while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count-- + for (line_no = 1; line_no <= count; line_no++) print lines[line_no] + } + ' "$1" +} + +extract_release_notes() { + git -C "$ROOT" show "$1:CHANGELOG.md" | + awk -v heading="## [$2] - " ' + index($0, heading) == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' +} + +release_heading() { + git -C "$ROOT" show "$1:CHANGELOG.md" | + awk -v heading="## [$2] - " 'index($0, heading) == 1 { print }' +} + +changelog_without_release_notes() { + git -C "$ROOT" show "$1:CHANGELOG.md" | + awk -v heading="## [$2] - " ' + index($0, heading) == 1 { print; skipped = 1; next } + skipped && /^## / { skipped = 0 } + !skipped { print } + ' +} + +# A pre-tag beta amendment must preserve the original seal verbatim except for +# appending newly archived fragment bodies to their existing categories. These +# helpers canonicalize the renderer's category format before comparing it. +canonicalize_fragment_notes() { + awk ' + BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" } + function trim(value) { + sub(/^[\n]+/, "", value) + sub(/[\n]+$/, "", value) + return value + } + function flush() { + if (category == "") return + value = trim(body) + if (value == "" || seen[category]++) invalid = 1 + blocks[category] = value + category = "" + body = "" + } + /^### / { + flush() + candidate = substr($0, 5) + if (candidate !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) { + invalid = 1 + next + } + category = candidate + next + } + { + if (category == "") { + if ($0 !~ /^[[:space:]]*$/) invalid = 1 + next + } + body = body $0 "\n" + } + END { + flush() + if (invalid) exit 1 + for (i = 1; i <= 6; i++) { + category = order[i] + if (category in blocks) printf "### %s\n\n%s\n\n", category, blocks[category] + } + } + ' "$1" +} + +merge_canonical_fragment_notes() { + awk ' + BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" } + function trim(value) { + sub(/^[\n]+/, "", value) + sub(/[\n]+$/, "", value) + return value + } + function flush() { + if (category == "") return + value = trim(body) + if (value == "") invalid = 1 + if (part == 1) before[category] = value + else if (part == 2) addition[category] = value + else invalid = 1 + category = "" + body = "" + } + FNR == 1 { + if (started) flush() + started = 1 + part++ + } + /^### / { + flush() + category = substr($0, 5) + if (category !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) invalid = 1 + next + } + { body = body $0 "\n" } + END { + flush() + if (invalid || part != 2) exit 1 + for (i = 1; i <= 6; i++) { + category = order[i] + if (!(category in before) && !(category in addition)) continue + printf "### %s\n\n", category + if (category in before) printf "%s\n\n", before[category] + if (category in addition) printf "%s\n\n", addition[category] + } + } + ' "$1" "$2" +} + archive_changed=false if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then archive_changed=true fi if [ "$archive_changed" = true ]; then - release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')" - [ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { - printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2 - exit 1 - } + new_release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')" + new_release_version_count="$(printf '%s\n' "$new_release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" + archival_mode=seal + if [ "$new_release_version_count" -eq 1 ]; then + release_version="$new_release_version" + else + # An amendment keeps the existing heading. Derive the one allowed target + # from the literal archive directory, then verify every move below. + release_version="$(awk -F '\t' '$1 == "R100" && NF == 3 && $3 ~ /^\.changes\/released\// { path = $3; sub(/^\.changes\/released\//, "", path); sub(/\/.*/, "", path); print path }' "$tmp_root/status" | sort -u)" + [ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section or one untagged beta amendment target' >&2 + exit 1 + } + printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$' || { + printf '%s\n' 'error: only an existing beta CHANGELOG section may receive a pre-tag fragment amendment' >&2 + exit 1 + } + base_heading="$(release_heading "$merge_base" "$release_version")" + head_heading="$(release_heading "$head" "$release_version")" + [ "$(printf '%s\n' "$base_heading" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] && [ "$base_heading" = "$head_heading" ] || { + printf '%s\n' 'error: beta fragment amendment requires one unchanged pre-existing CHANGELOG heading' >&2 + exit 1 + } + changelog_without_release_notes "$merge_base" "$release_version" >"$tmp_root/base-changelog-without-amendment" + changelog_without_release_notes "$head" "$release_version" >"$tmp_root/head-changelog-without-amendment" + if ! cmp -s "$tmp_root/base-changelog-without-amendment" "$tmp_root/head-changelog-without-amendment"; then + printf '%s\n' 'error: beta fragment amendment may change only its existing beta CHANGELOG section' >&2 + exit 1 + fi + if git -C "$ROOT" rev-parse --verify --quiet "refs/tags/v$release_version^{commit}" >/dev/null; then + printf 'error: beta fragment amendment is forbidden after tag v%s exists\n' "$release_version" >&2 + exit 1 + fi + archival_mode=beta-amendment + fi # The archive directory is matched as a literal prefix, never as a regex: # interpolating the version into one would make `.` match any character, so # `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break @@ -47,7 +202,7 @@ if [ "$archive_changed" = true ]; then { invalid = 1 } END { exit !(changelog && moved > 0 && !invalid) } ' "$tmp_root/status"; then - printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/.md to .changes/released//.md in the matching release-seal PR' >&2 + printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/.md to .changes/released//.md in the matching release-seal or untagged beta-amendment PR' >&2 exit 1 fi source_changes="$tmp_root/source-changes" @@ -63,17 +218,25 @@ if [ "$archive_changed" = true ]; then esac done "$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes" - git -C "$ROOT" show "$head:CHANGELOG.md" | - awk -v heading="## [$release_version] - " ' - index($0, heading) == 1 { found = 1; next } - found && /^## / { exit } - found { print } - ' >"$tmp_root/actual-section" - case "$release_version" in - *-beta.*) - cp "$tmp_root/actual-section" "$tmp_root/actual-notes" - ;; - *) + if [ "$archival_mode" = beta-amendment ]; then + extract_release_notes "$merge_base" "$release_version" >"$tmp_root/base-notes" + extract_release_notes "$head" "$release_version" >"$tmp_root/head-notes" + if ! canonicalize_fragment_notes "$tmp_root/base-notes" >"$tmp_root/base-notes.canonical" || + ! canonicalize_fragment_notes "$tmp_root/expected-notes" >"$tmp_root/amendment-notes.canonical" || + ! merge_canonical_fragment_notes "$tmp_root/base-notes.canonical" "$tmp_root/amendment-notes.canonical" >"$tmp_root/expected-notes.merged" || + ! canonicalize_fragment_notes "$tmp_root/head-notes" >"$tmp_root/actual-notes.canonical"; then + printf '%s\n' 'error: beta fragment amendment requires canonical rendered fragment categories' >&2 + exit 1 + fi + normalize_notes "$tmp_root/expected-notes.merged" >"$tmp_root/expected-notes.normalized" + normalize_notes "$tmp_root/actual-notes.canonical" >"$tmp_root/actual-notes.normalized" + else + extract_release_notes "$head" "$release_version" >"$tmp_root/actual-section" + case "$release_version" in + *-beta.*) + cp "$tmp_root/actual-section" "$tmp_root/actual-notes" + ;; + *) stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")" [ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2 @@ -88,22 +251,13 @@ if [ "$archive_changed" = true ]; then found { print } /^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 } ' "$tmp_root/actual-section" >"$tmp_root/actual-notes" - ;; - esac - normalize_notes() { - awk ' - /^[[:space:]]*$/ && !started { next } - { started = 1; lines[++count] = $0 } - END { - while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count-- - for (line_no = 1; line_no <= count; line_no++) print lines[line_no] - } - ' "$1" - } - normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized" - normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized" + ;; + esac + normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized" + normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized" + fi if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then - printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2 + printf '%s\n' 'error: release CHANGELOG section does not exactly match the rendered active release fragments' >&2 diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true exit 1 fi diff --git a/scripts/policy/check-shortcut-public-e2e-proof.sh b/scripts/policy/check-shortcut-public-e2e-proof.sh new file mode 100755 index 00000000..807030b1 --- /dev/null +++ b/scripts/policy/check-shortcut-public-e2e-proof.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +MANIFEST="$ROOT/scripts/policy/shortcut-public-e2e-proof.json" +BINARY="${DWS_SHORTCUT_PROOF_BINARY:-$ROOT/dws}" +GO_BINARY="${GO:-go}" +PROOF_ROOT="" + +fail() { + printf 'shortcut public E2E proof failed: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [ -n "$PROOF_ROOT" ] && [ -d "$PROOF_ROOT" ]; then + find "$PROOF_ROOT" -type f -delete 2>/dev/null || true + find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \; 2>/dev/null || true + fi +} + +trap cleanup EXIT HUP INT TERM + +command -v jq >/dev/null 2>&1 || fail "jq is required" +command -v "$GO_BINARY" >/dev/null 2>&1 || fail "Go is required" +[ -x "$BINARY" ] || fail "current source binary is missing; run make build" +[ -f "$MANIFEST" ] || fail "reviewed proof manifest is missing" + +if [ "${DWS_SHORTCUT_PROOF_ALLOW_DIRTY:-0}" != "1" ] && + [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]; then + fail "worktree must be clean so evidence is bound to current HEAD" +fi + +HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)" +[ -n "$HEAD_SHA" ] || fail "cannot resolve current HEAD" + +jq -e ' + .version == 1 and + (.products | type == "array" and length > 0) and + (.public_shortcuts | type == "array") and + (.products | group_by(.service) | all(.[]; length == 1)) and + (.public_shortcuts | group_by([.service, .command]) | all(.[]; length == 1)) and + (.products | all(.[]; + (.service | type == "string" and length > 0) and + (.source_total | type == "number") and + (.public | type == "number") and + (.unavailable | type == "number") and + (.source_total == (.public + .unavailable)))) and + (.public_shortcuts | all(.[]; + (.service | type == "string" and length > 0) and + (.command | type == "string" and startswith("+")) and + (.owning_atomic | type == "string" and length > 0) and + (.proof_class | IN("list", "search", "read", "write")) and + (.proof_case | type == "string" and length > 0) and + (.stable_target | type == "string" and length > 0) and + (.pass_labels | type == "array" and length > 0 and all(.[]; type == "string" and endswith("-PASS"))))) +' "$MANIFEST" >/dev/null || fail "reviewed proof manifest is malformed" + +SOURCE_TOTAL=0 +PUBLIC_TOTAL=0 +UNAVAILABLE_TOTAL=0 +SEMANTIC_PUBLIC="" +RUNTIME_PUBLIC="" + +for service in $(jq -r '.products[].service' "$MANIFEST"); do + catalog="$ROOT/internal/shortcut/semantic_catalog_${service}.json" + [ -f "$catalog" ] || fail "semantic catalog is missing for $service" + + jq -e --arg service "$service" ' + . as $catalog | + .service == $service and + .default_availability == "unavailable" and + (.shortcuts | type == "object") and + (.shortcuts | to_entries | all(.[]; + (.key | type == "string" and startswith("+")) and + (.value | type == "object") and + (.value.reviewed == true) and + ((.value.semantic_delta | type) == "string" and (.value.semantic_delta | length) > 0) and + (if (.value.public // false) + then ((.value.availability // $catalog.default_availability) == "available") + else ((.value.availability // $catalog.default_availability) == "unavailable") + end))) + ' "$catalog" >/dev/null || fail "$service semantic catalog is not truthfully reviewed" + + source_count="$(jq '.shortcuts | length' "$catalog")" + public_count="$(jq '[. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available"))] | length' "$catalog")" + unavailable_count=$((source_count - public_count)) + expected_source="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .source_total' "$MANIFEST")" + expected_public="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .public' "$MANIFEST")" + expected_unavailable="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .unavailable' "$MANIFEST")" + [ "$source_count" -eq "$expected_source" ] || fail "$service source count drifted" + [ "$public_count" -eq "$expected_public" ] || fail "$service public count drifted" + [ "$unavailable_count" -eq "$expected_unavailable" ] || fail "$service unavailable count drifted" + + semantic_lines="$(jq -r --arg service "$service" '. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available")) | [$service, .key] | @tsv' "$catalog")" + if [ -n "$semantic_lines" ]; then + SEMANTIC_PUBLIC="${SEMANTIC_PUBLIC}${semantic_lines}"$'\n' + fi + + runtime_output="$($BINARY shortcut list --service "$service" --format json)" + printf '%s' "$runtime_output" | jq -e --arg service "$service" ' + type == "object" and + (.count | type == "number") and + (.shortcuts | type == "array") and + (.count == (.shortcuts | length)) and + (.shortcuts | all(.[]; + type == "object" and + .service == $service and + (.command | type == "string" and startswith("+")) and + .public == true and + .reviewed == true and + .availability == "available")) + ' >/dev/null || fail "$service runtime public inventory is malformed" + runtime_count="$(printf '%s' "$runtime_output" | jq '.shortcuts | length')" + [ "$runtime_count" -eq "$public_count" ] || fail "$service runtime public count does not match its semantic catalog" + runtime_lines="$(printf '%s' "$runtime_output" | jq -r '.shortcuts[] | [.service, .command] | @tsv')" + if [ -n "$runtime_lines" ]; then + RUNTIME_PUBLIC="${RUNTIME_PUBLIC}${runtime_lines}"$'\n' + fi + + SOURCE_TOTAL=$((SOURCE_TOTAL + source_count)) + PUBLIC_TOTAL=$((PUBLIC_TOTAL + public_count)) + UNAVAILABLE_TOTAL=$((UNAVAILABLE_TOTAL + unavailable_count)) +done + +SEMANTIC_PUBLIC="$(printf '%s' "$SEMANTIC_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)" +RUNTIME_PUBLIC="$(printf '%s' "$RUNTIME_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)" +EXPECTED_PUBLIC="$(jq -r '.public_shortcuts[] | [.service, .command] | @tsv' "$MANIFEST" | LC_ALL=C sort)" +[ "$SEMANTIC_PUBLIC" = "$RUNTIME_PUBLIC" ] || fail "semantic and runtime public surfaces differ" +[ "$RUNTIME_PUBLIC" = "$EXPECTED_PUBLIC" ] || fail "a public shortcut lacks a reviewed exact plus raw proof case" + +printf 'CURRENT-HEAD-PUBLIC-SURFACE-PASS head=%s source=%d public=%d unavailable=%d\n' "$HEAD_SHA" "$SOURCE_TOTAL" "$PUBLIC_TOTAL" "$UNAVAILABLE_TOTAL" + +(cd "$ROOT" && DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" "$GO_BINARY" test -count=1 ./internal/shortcut/pat -run '^TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup$' >/dev/null) || + fail "PAT exact/raw zero-call structural proof failed" + +run_pat_browser_policy_default() { + schema_output="$($BINARY schema --cli-path 'pat +browser-policy' --compact --format json)" + printf '%s' "$schema_output" | jq -e ' + type == "object" and + .cli_path == "pat +browser-policy" and + .effect == "write" and + .risk == "medium" and + .confirmation == "user_required" and + .idempotency == "idempotent" and + .availability == "available" and + .interface_mode == "composite" and + (.result | type == "object") and + (.result.outcomes | type == "array" and index("success") != null and index("failure") != null) and + (.result.data_schema.required | sort == ["executed", "openBrowser", "scope", "verified"]) + ' >/dev/null || fail "pat +browser-policy compact contract is incomplete" + + PROOF_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-pat-public-proof.XXXXXX")" + exact_dir="$PROOF_ROOT/exact" + raw_dir="$PROOF_ROOT/raw" + mkdir -p "$exact_dir" "$raw_dir" + exact_path="$exact_dir/pat_policy.json" + raw_path="$raw_dir/pat_policy.json" + + if env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat +browser-policy --enabled=false --format json /dev/null 2>&1; then + fail "pat +browser-policy wrote without confirmation" + fi + [ ! -e "$exact_path" ] || fail "pat +browser-policy left state before confirmation" + printf '%s\n' 'PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS' + + exact_dry_run="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat +browser-policy --enabled=false --dry-run --format json)" + printf '%s' "$exact_dry_run" | jq -e ' + type == "object" and .ok == true and .outcome == "success" and + (.data | type == "object") and .data.scope == "default" and + .data.openBrowser == false and .data.executed == false and .data.verified == false + ' >/dev/null || fail "pat +browser-policy dry-run result is malformed" + [ ! -e "$exact_path" ] || fail "pat +browser-policy dry-run wrote policy state" + printf '%s\n' 'PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS' + + exact_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat +browser-policy --enabled=false --yes --format json)" + printf '%s' "$exact_output" | jq -e ' + type == "object" and .ok == true and .outcome == "success" and + (.data | type == "object") and .data.scope == "default" and + .data.openBrowser == false and .data.executed == true and .data.verified == true + ' >/dev/null || fail "pat +browser-policy terminal receipt is malformed" + printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS' + + jq -e ' + type == "object" and (.default | type == "object") and + .default.openBrowser == false and ((has("agents") | not) or .agents == {}) + ' "$exact_path" >/dev/null || fail "pat +browser-policy exact target readback failed" + printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-READBACK-PASS' + + raw_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \ + DWS_CONFIG_DIR="$raw_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \ + "$BINARY" pat browser-policy --enabled=false --format json)" + printf '%s' "$raw_output" | jq -e ' + type == "object" and .scope == "default" and .source == "default" and + .openBrowser == false and (has("agentCode") | not) + ' >/dev/null || fail "pat browser-policy terminal receipt is malformed" + printf '%s\n' 'PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS' + + jq -e ' + type == "object" and (.default | type == "object") and + .default.openBrowser == false and ((has("agents") | not) or .agents == {}) + ' "$raw_path" >/dev/null || fail "pat browser-policy raw target readback failed" + printf '%s\n' 'PAT-BROWSER-POLICY-RAW-READBACK-PASS' + + unlink "$exact_path" + unlink "$raw_path" + [ ! -e "$exact_path" ] || fail "pat +browser-policy cleanup left exact residue" + [ ! -e "$raw_path" ] || fail "pat browser-policy cleanup left raw residue" + find "$exact_dir" "$raw_dir" -type f -delete + [ -z "$(find "$PROOF_ROOT" -type f -print -quit)" ] || fail "PAT browser policy proof left file residue" + find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \; + [ ! -e "$PROOF_ROOT" ] || fail "PAT browser policy proof left directory residue" + PROOF_ROOT="" + printf '%s\n' 'PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS' +} + +while IFS=$'\t' read -r service command proof_case; do + [ -n "$service" ] || continue + case "$service $command $proof_case" in + 'pat +browser-policy pat_browser_policy_default') + proof_output="$(run_pat_browser_policy_default)" + ;; + *) + fail "$service $command has no executable proof implementation" + ;; + esac + actual_labels="$(printf '%s\n' "$proof_output" | sed '/^$/d' | LC_ALL=C sort)" + expected_labels="$(jq -r --arg service "$service" --arg command "$command" '.public_shortcuts[] | select(.service == $service and .command == $command) | .pass_labels[]' "$MANIFEST" | LC_ALL=C sort)" + [ "$actual_labels" = "$expected_labels" ] || fail "$service $command did not emit every reviewed PASS label" + printf '%s\n' "$proof_output" +done < <(jq -r '.public_shortcuts[] | [.service, .command, .proof_case] | @tsv' "$MANIFEST") + +printf 'EVERY-PUBLIC-SHORTCUT-DOUBLE-LAYER-E2E-PASS total=%d\n' "$PUBLIC_TOTAL" diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json index 52a6bff1..98703267 100644 --- a/scripts/policy/interface-migrations/approved-command-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json @@ -86,6 +86,54 @@ }, "state": "pending", "reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command." + }, + { + "kind": "schema_availability_hardening", + "legacy": { + "command": "dws devapp +event-list", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "schema": { + "product_id": "devapp", + "source_tool_id": "devapp.shortcut_event_list", + "parameters": [], + "availability": {"before": "available", "after": "unavailable"} + }, + "state": "pending", + "reason": "Fail closed until event-list proves authoritative terminal pagination for both non-empty and zero-result live fixtures." + }, + { + "kind": "schema_availability_hardening", + "legacy": { + "command": "dws devapp +member-add", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "schema": { + "product_id": "devapp", + "source_tool_id": "devapp.shortcut_member_add", + "parameters": [], + "availability": {"before": "available", "after": "unavailable"} + }, + "state": "pending", + "reason": "Fail closed until a safe second-member fixture proves exact role readback and rollback for member addition." + }, + { + "kind": "schema_availability_hardening", + "legacy": { + "command": "dws devapp +member-remove", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "schema": { + "product_id": "devapp", + "source_tool_id": "devapp.shortcut_member_remove", + "parameters": [], + "availability": {"before": "available", "after": "unavailable"} + }, + "state": "pending", + "reason": "Fail closed until a safe second-member fixture proves exact removal readback and restoration." } ] } diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index 613d449d..ad270b14 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -13,6 +13,79 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" ) +func TestCrossPlatformCoverageSchemaAvailabilityMigrationNormalizesOnlyAvailability(t *testing.T) { + baseline := baselineContract() + current := cloneContract(baseline) + mutateTool(¤t, func(tool *toolSchema) { tool.Availability = "unavailable" }) + migration := interfacesnapshot.CommandMigration{ + Kind: interfacesnapshot.CommandMigrationAvailability, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws doc create", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "doc", + SourceToolID: "doc.create", + Parameters: []interfacesnapshot.CommandParameterMigration{}, + Availability: &interfacesnapshot.CommandAvailabilityChange{Before: "available", After: "unavailable"}, + }, + State: interfacesnapshot.CommandMigrationPending, + Reason: "Reviewed fail-closed availability hardening.", + } + + normalized, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{migration}) + if err != nil { + t.Fatal(err) + } + if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "unavailable" { + t.Fatalf("normalized availability = %q", got) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("availability hardening failures = %v", failures) + } + + unrelated := cloneContract(current) + mutateTool(&unrelated, func(tool *toolSchema) { tool.Risk = "high" }) + normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, []interfacesnapshot.CommandMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, "changed risk") { + t.Fatalf("unrelated risk drift was hidden: %q", failures) + } + + alreadyAfter := cloneContract(current) + if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, []interfacesnapshot.CommandMigration{migration}); err != nil { + t.Fatalf("consumed merge-base availability should remain inert: %v", err) + } + wrongPath := cloneContract(current) + mutateTool(&wrongPath, func(tool *toolSchema) { tool.PrimaryCLIPath = "doc other" }) + normalized, err = normalizeSchemaCommandMigrations(baseline, wrongPath, []interfacesnapshot.CommandMigration{migration}) + if err != nil { + t.Fatal(err) + } + if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "available" { + t.Fatalf("wrong-path migration changed availability to %q", got) + } + wrong := cloneContract(current) + mutateTool(&wrong, func(tool *toolSchema) { tool.Availability = "available" }) + if _, err := normalizeSchemaCommandMigrations(baseline, wrong, []interfacesnapshot.CommandMigration{migration}); err == nil || + !strings.Contains(err.Error(), "does not match Schema availability") { + t.Fatalf("wrong availability error = %v", err) + } + + compatibilityVisible := migration + compatibilityVisible.Legacy.After = compatibilityVisible.Legacy.Before + if _, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{compatibilityVisible}); err != nil { + t.Fatalf("compatibility-visible availability hardening must authorize the exact Schema transition: %v", err) + } + if _, err := normalizeSchemaCommandMigrations(baseline, baseline, []interfacesnapshot.CommandMigration{compatibilityVisible}); err == nil || + !strings.Contains(err.Error(), "does not match Schema availability") { + t.Fatalf("compatibility-visible receipt must not authorize unchanged Schema availability: %v", err) + } +} + func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) { baseline := schemaCommandMigrationContract(false) current := schemaCommandMigrationContract(true) diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 580eb7b6..c7d933ae 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -79,6 +79,38 @@ type parameterSchema struct { Enum []string `json:"enum,omitempty"` } +type reviewedCompatibilityException struct { + Field string + Old string + New string +} + +// reviewedCompatibilityExceptions is intentionally exact: safety fixes may +// need to tighten a historical contract, but that must not turn arbitrary +// confirmation drift into a compatible change. +var reviewedCompatibilityExceptions = map[string]reviewedCompatibilityException{ + // PR #1085: batch permission/member remove is destructive at container + // scope — one call can revoke access for up to 30 USER / DEPT / + // CONVERSATION / TAG members, and departments, chats, and role groups + // can indirectly affect many more users. The review therefore asked for + // the same user confirmation gate as other destructive removes. + "doc/doc.remove_permission": { + Field: "confirmation", + Old: "not_required", + New: "user_required", + }, + "drive/drive.permission_remove": { + Field: "confirmation", + Old: "not_required", + New: "user_required", + }, + "wiki/wiki.remove_member": { + Field: "confirmation", + Old: "not_required", + New: "user_required", + }, +} + func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } @@ -708,7 +740,7 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri {name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation}, {name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency}, } { - if field.old != field.new { + if field.old != field.new && !isReviewedCompatibilityException(toolPath, field.name, field.old, field.new) { failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name)) } } @@ -744,6 +776,11 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri return failures } +func isReviewedCompatibilityException(toolPath, field, oldValue, newValue string) bool { + exception, ok := reviewedCompatibilityExceptions[toolPath] + return ok && exception.Field == field && exception.Old == oldValue && exception.New == newValue +} + // reviewedInterfaceRefRedirect enumerates the exact, individually reviewed // backend RPC migrations this gate accepts. Schema shape alone cannot prove two // RPCs share business semantics, permissions, error behaviour, or side effects, @@ -778,6 +815,12 @@ var reviewedConstraintTransition = map[string]map[string]string{ "doc/doc.shortcut_import": { `{"require_one_of":[["folder","workspace"]]}`: "", }, + // PR #1105 adds local --file as an alternative to the historically required + // --src input. Every historical --src invocation remains valid; publishing + // both groups makes the final Schema express the runtime's exact-one rule. + "sheet/sheet.create_float_image": { + "": `{"mutually_exclusive":[["file","src"]],"require_one_of":[["file","src"]]}`, + }, } func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool { @@ -1832,6 +1875,24 @@ func normalizeSchemaCommandMigrations( normalizedProduct := normalized.Products[migration.Schema.ProductID] normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID] switch migration.Kind { + case interfacesnapshot.CommandMigrationAvailability: + change := migration.Schema.Availability + if change != nil && oldTool.Availability == change.After && newSource.Availability == change.After { + continue + } + if change == nil || oldTool.Availability != change.Before || newSource.Availability != change.After { + return schemaContract{}, fmt.Errorf( + "approved availability hardening %q does not match Schema availability %q -> %q", + migration.Legacy.Command, + oldTool.Availability, + newSource.Availability, + ) + } + if newSource.PrimaryCLIPath != legacyPath { + continue + } + normalizedTool.Availability = newSource.Availability + case interfacesnapshot.CommandMigrationMove: if newSource.PrimaryCLIPath != replacementPath { continue diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index 9e6c7794..bef2b6f6 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -415,6 +415,39 @@ func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) { } } +func TestSchemaCompatibilityAcceptsReviewedRemoveConfirmationHardening(t *testing.T) { + oldTool := baselineContract().Products["doc"].Tools["doc.create"] + newTool := oldTool + newTool.Confirmation = "user_required" + + for _, toolPath := range []string{ + "doc/doc.remove_permission", + "drive/drive.permission_remove", + "wiki/wiki.remove_member", + } { + if failures := checkToolCompatibility(toolPath, oldTool, newTool); len(failures) != 0 { + t.Fatalf("reviewed confirmation hardening for %s failures = %v", toolPath, failures) + } + } + if failures := checkToolCompatibility("doc/doc.other_remove", oldTool, newTool); len(failures) == 0 { + t.Fatal("unreviewed confirmation hardening unexpectedly passed") + } + + // A reviewed tool is only exempt for the exact reviewed transition: an + // unrelated field drift on the same tool must still be reported. + riskTool := oldTool + riskTool.Risk = "high" + if failures := checkToolCompatibility("doc/doc.remove_permission", oldTool, riskTool); len(failures) == 0 { + t.Fatal("reviewed tool risk drift unexpectedly passed") + } + + oldTool.Confirmation = "user_required" + newTool.Confirmation = "not_required" + if failures := checkToolCompatibility("doc/doc.remove_permission", oldTool, newTool); len(failures) == 0 { + t.Fatal("reviewed tool confirmation weakening unexpectedly passed") + } +} + func TestMergeContracts(t *testing.T) { historical := baselineContract() current := cloneContract(historical) @@ -959,6 +992,36 @@ func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testin } }) } + + const sheetToolPath = "sheet/sheet.create_float_image" + const sheetTarget = `{"mutually_exclusive":[["file","src"]],"require_one_of":[["file","src"]]}` + sheetOldTool := toolSchema{} + sheetNewTool := sheetOldTool + sheetNewTool.Constraints = sheetTarget + + if !compatibleReviewedConstraintTransition(sheetToolPath, sheetOldTool, sheetNewTool) { + t.Fatal("reviewed float-image local-file transition must be accepted") + } + if failures := checkToolCompatibility(sheetToolPath, sheetOldTool, sheetNewTool); len(failures) != 0 { + t.Fatalf("reviewed float-image local-file transition failed: %v", failures) + } + + for _, test := range []struct { + name string + path string + old string + new string + }{ + {name: "float image unlisted tool", path: "sheet/sheet.other", new: sheetTarget}, + {name: "float image unlisted source", path: sheetToolPath, old: `{"require_one_of":[["src"]]}`, new: sheetTarget}, + {name: "float image unlisted target", path: sheetToolPath, new: `{"require_one_of":[["file","src"]]}`}, + } { + t.Run(test.name, func(t *testing.T) { + if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) { + t.Fatal("unreviewed float-image constraint transition unexpectedly passed") + } + }) + } } // Clearing a property through the reviewed mapping exclusion table is the one diff --git a/scripts/policy/shortcut-public-e2e-proof.json b/scripts/policy/shortcut-public-e2e-proof.json new file mode 100644 index 00000000..9f142732 --- /dev/null +++ b/scripts/policy/shortcut-public-e2e-proof.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "products": [ + { + "service": "devdoc", + "source_total": 1, + "public": 0, + "unavailable": 1 + }, + { + "service": "hrbrain", + "source_total": 11, + "public": 0, + "unavailable": 11 + }, + { + "service": "pat", + "source_total": 2, + "public": 1, + "unavailable": 1 + } + ], + "public_shortcuts": [ + { + "service": "pat", + "command": "+browser-policy", + "owning_atomic": "pat browser-policy", + "proof_class": "write", + "proof_case": "pat_browser_policy_default", + "stable_target": "default-policy", + "pass_labels": [ + "PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS", + "PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS", + "PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS", + "PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS", + "PAT-BROWSER-POLICY-EXACT-READBACK-PASS", + "PAT-BROWSER-POLICY-RAW-READBACK-PASS", + "PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS" + ] + } + ] +} diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 3d226752..e31609b3 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -43,21 +43,25 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| -| `aitable` | 93 | `dingtalk-aitable` | +| `agoal` | 5 | `—` | +| `aisearch` | 1 | `—` | +| `aitable` | 100 | `dingtalk-aitable` | | `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | -| `contact` | 14 | `dingtalk-contact` | -| `devapp` | 19 | `dingtalk-misc` | +| `contact` | 13 | `dingtalk-contact` | +| `devapp` | 25 | `dingtalk-misc` | | `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | | `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | | `oa` | 1 | `dingtalk-misc` | +| `pat` | 1 | `dingtalk-misc` | | `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | +| `whiteboard` | 2 | `dingtalk-misc` | | `wiki` | 20 | `dingtalk-wiki` | diff --git a/skills/mono/references/products/agoal.md b/skills/mono/references/products/agoal.md index 527351ea..541cce9f 100644 --- a/skills/mono/references/products/agoal.md +++ b/skills/mono/references/products/agoal.md @@ -36,6 +36,7 @@ Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分 | `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` | | `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 | | `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY | +| `scorecard search-entities` | 搜索计分卡指标与关键事项 | `--keyword` | 可选 `--page`、`--page-size`;keyword 为标题模糊匹配关键词 | ### user (用户目标管理) @@ -75,6 +76,7 @@ Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分 - 查看详情 → `scorecard detail` - 实体详情 → `scorecard entity-detail` - 修改/更新 → `scorecard update` +- 搜索计分卡指标与关键事项 → `scorecard search-entities --keyword "关键词"` 用户说"目标/OKR/我的目标/个人目标": - 规则周期 → `user rules` @@ -121,6 +123,10 @@ dws agoal scorecard update --dept-id DEPT_ID --selected-time "2026-01-01T00:00:0 --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]' \ --format json +# 搜索计分卡指标与关键事项 +dws agoal scorecard search-entities --keyword "业绩" --format json +dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20 --format json + # 查询用户目标 dws agoal user rules --user-id USER_ID --format json dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json diff --git a/skills/mono/references/products/doc.md b/skills/mono/references/products/doc.md index 9f6cbf97..a2819e36 100644 --- a/skills/mono/references/products/doc.md +++ b/skills/mono/references/products/doc.md @@ -55,6 +55,8 @@ Example: dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=" dws doc read --node "https://alidocs.dingtalk.com/document/preview?dentryKey=" dws doc read --node --content-format jsonml --scope outline + dws doc read --node --password + dws doc read --node --version 3 Flags: --node string 文档 ID 或 URL (必填) --content-format string 输出格式: markdown / jsonml @@ -63,9 +65,11 @@ Flags: --max-depth int 筛选遍历最大深度,0 表示不限 --start-block-id string range / section 起始块 UUID --end-block-id string range 结束块 UUID + --password string 互联网公开文档密码保护时的访问密码 + --version int 读取指定历史版本(版本号从 doc version list 获取,0 表示初始版本);缺省读最新版 ``` -`--scope` 只适用于 `--content-format jsonml`,用于按大纲、块区间、单块子树或自定义 tag 局部读取。筛选结果是虚拟 JSONML fragment,只能剥壳后消费 children,不得把 fragment 容器整体写回。完整规则见 [`doc-read.md`](./doc/doc-read.md)。 +`--scope` 只适用于 `--content-format jsonml`,用于按大纲、块区间、单块子树或自定义 tag 局部读取。筛选结果是虚拟 JSONML fragment,只能剥壳后消费 children,不得把 fragment 容器整体写回。互联网公开文档(含密码保护)用 `--password`;读取历史版本内容用 `--version`(版本号来自 `dws doc version list`,0 表示初始版本,需要编辑权限)。完整规则见 [`doc-read.md`](./doc/doc-read.md)。 ### 创建文档 ``` @@ -517,17 +521,32 @@ Example: dws doc permission add --node --users uid1 --role READER dws doc permission add --node --users uid1,uid2 --role EDITOR dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/" --users uid1 --role MANAGER + dws doc permission add --node --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify + dws doc permission add --node --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]' Flags: --node string 目标文档/文件夹的 ID 或 URL (必填) - --users strings 被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个) - --role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读) + --users string 被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个) + --role string 授予的角色 (旧格式必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读) + --members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥 + --notify bool 是否通知被添加的成员 (仅 --members 新格式时生效,默认 false) --workspace string 所属知识库 ID (选填,仅用于辅助构造返回的 docUrl,业务实际依赖 nodeId) ``` +> **两种传参方式(互斥)**: +> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型) +> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个成员携带独立 `roleId` +> +> **成员类型说明**: +> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织) +> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织) +> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId` +> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型 +> > **重要约束**: -> - 仅支持 USER 类型授权。 -> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加)。 -> - 操作者需在该节点具备「可编辑(EDITOR)」及以上角色(OWNER / MANAGER / EDITOR)。 +> - 角色枚举(大小写不敏感):MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加)。 +> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。 +> - 单次请求最多 30 个成员,超出请分批调用。 +> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`。 > - 授权对象是文档节点本身,不需要也不应该用 `wiki member add`(那个是知识库容器级授权)。 ### 修改文档权限(节点级) @@ -537,13 +556,36 @@ Usage: Example: dws doc permission update --node --users uid1 --role EDITOR dws doc permission update --node --users uid1,uid2 --role READER + dws doc permission update --node --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false + dws doc permission update --node --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]' Flags: --node string 目标文档/文件夹的 ID 或 URL (必填) - --users strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个) - --role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER + --users string 被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个) + --role string 新角色 (旧格式必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER + --members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥 + --notify bool 是否通知被变更的成员 (仅 --members 新格式时生效,默认 false) --workspace string 所属知识库 ID (选填) ``` +> **两种传参方式(互斥)**: +> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型) +> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个成员携带独立 `roleId` +> +> **成员类型说明**: +> - `USER` — 用户,id 为用户 userId,需携带 `corpId` +> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId` +> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId` +> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId` +> +> **重要约束**: +> - OWNER 角色不可通过此接口变更。 +> - 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。 +> - 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。 +> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。 +> - 单次请求最多 30 个成员,超出请分批调用。 +> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。 +> - 仅可更新已存在协作关系的成员,新增协作者请使用 `dws doc permission add`。 + ### 列出文档权限(节点级) ``` Usage: @@ -552,14 +594,16 @@ Example: dws doc permission list --node dws doc permission list --node --limit 50 dws doc permission list --node --filter-role EDITOR + dws doc permission list --node --next-token <上次返回的 nextToken> Flags: --node string 目标文档/文件夹的 ID 或 URL (必填) --workspace string 所属知识库 ID (选填) - --limit int 返回数量上限,最大 200 (默认 50) + --limit int 返回数量上限,最大 50 (默认 30) --filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填) + --next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (选填) ``` -> 接口不支持游标分页,使用 `--limit` 一次性拉取。 +> 底层一次性返回全量成员后在内存中按 pageSize 分页;当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页。 ### 导出在线文档为 docx(一体化命令) ``` @@ -712,9 +756,17 @@ Flags: - 删除 → `block delete` 用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看": -- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`) +- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`,或 `--node` + `--members`) - 修改权限 → `permission update` - 查看谁有权限 → `permission list` +- 授权后要告知对方("并通知他/告知一下/提醒对方/让他知道")→ 走 `--members` 并追加 `--notify` + +> **通知意图 → `--notify`**(默认不通知,省略时 CLI 不向服务端发送该字段): +> - 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify` +> - 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → 追加 `--notify=false` +> - 用户没提通知需求 → **不传该 flag**,保持不通知;不要自行补上 `--notify` +> - `--notify` 仅在 `--members` 新格式下生效;旧格式 `--users` 下传了也不会生效,有通知需求必须改用 `--members` +> - 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声 > **关键区分**: > - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持) diff --git a/skills/mono/references/products/doc/doc-read.md b/skills/mono/references/products/doc/doc-read.md index 07132bef..2e1d6741 100644 --- a/skills/mono/references/products/doc/doc-read.md +++ b/skills/mono/references/products/doc/doc-read.md @@ -25,6 +25,8 @@ Flags: --max-depth int 筛选遍历最大深度,0 表示不限 --start-block-id string range / section 起始块 UUID --end-block-id string range 结束块 UUID;空或 "-1" 表示到文档末尾 + --password string 互联网公开文档开启密码保护时的访问密码;普通文档无需传入 + --version int 读取指定历史版本内容(版本号从 doc version list 获取,0 表示初始版本,需要文档编辑权限);缺省读最新版 ``` ## 关键说明 @@ -33,6 +35,13 @@ Flags: - 返回的 Markdown 中,附件以 OSS 临时下载链接形式给出(如 `https://alidocs2.oss-cn-zhangjiakou.aliyuncs.com/res/.../att/.ext?Expires=...`),**链接会过期**。链接过期后从 URL 路径中提取 ``(即 `/att/` 后、扩展名前的 UUID 部分),用 `media download --node --resource-id ` 重新获取下载链接。 - `--content-format jsonml` 返回完整 JSONML 结构(含 `revision`),用于无损读改写;可直接配合 [`doc-update.md`](./doc-update.md) 的 `--content-format jsonml --content-file` 写回。`revision` 仅在并发敏感场景下需要透传给 update 触发并发检查(详见下方)。 +## 互联网公开文档与历史版本 + +- **互联网公开文档**:公开链接直接传 `--node`;文档开启了密码保护时,通过 `--password ` 提供访问密码,普通文档无需传入。密码只进入读取请求,不会回显在读取结果里(`--dry-run` 预览会包含所传参数,注意输出环境)。 +- **历史版本**:`--version ` 读取指定历史版本内容(Markdown / JSONML / scope 均可组合);版本号从 `dws doc version list` 或 `dws doc +version-list` 获取,`0` 表示文档初始版本;需要文档编辑权限(EDITOR 及以上),缺省读最新版。 +- **跨组织文档**:非互联网公开的跨组织文档,`doc read` 整体被组织边界拦截(提示「不支持跨组织访问数据」),最新版与历史版本内容都读不到(`doc info` 仍可用);互联网公开(含密码)文档的 `doc read` 不受影响,但 `doc version list` / `+version-list` 与写操作仍会被拦截,版本号无法从列表获取。 +- 只要看历史内容、不打算恢复时用 `--version`;要把文档整体恢复到历史版本才用 `doc version revert`(危险操作,需确认)。 + ## content-format=jsonml 输出 输出 JSON 对象,包含 `revision`(版本号)和 `jsonml`(JSONML body 数组): @@ -92,6 +101,12 @@ dws doc read --node --format json # alidocs URL 直传 dws doc read --node "https://alidocs.dingtalk.com/i/nodes/" --format json +# 互联网公开文档(含密码保护) +dws doc read --node --password --format json + +# 读取历史版本(版本号来自 dws doc version list) +dws doc read --node --version 3 --format json + # JSONML 完整结构 → 文件(无损改写前置) dws doc read --node --content-format jsonml --output /tmp/doc.json # 之后修改 /tmp/doc.json 中的 jsonml 数组,再用: diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index f03372b6..2b63ccf4 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -594,6 +594,8 @@ Flags: 用户说"回收站/查看回收站/回收站列表/回收站里有什么" → `recycle list` 用户说"恢复文件/还原删除的文件/从回收站恢复/还原回收站文件" → `recycle restore` 用户说"给文档授权/分享权限" → `permission add` +用户说"授权并通知对方/加权限后告知他/通知一下被授权的人" → `permission add --members ... --notify`(未提通知需求时不传 `--notify`) +用户说"权限设置/权限模式/分享范围/水印等策略配置" → `permission get-setting` 用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set` 用户说"关闭公开/取消公开/取消互联网访问" → `publish unset` 用户说"查看公开状态/是否公开/发布状态" → `publish get` @@ -610,6 +612,13 @@ Flags: **drive permission vs wiki member**: "给某篇文档/文件授权" → `drive permission add`(节点级);"给某个知识库整体加成员" → `wiki member add`(空间级) +**通知意图 → `--notify`**(默认不通知,省略时 CLI 不向服务端发送该字段): +- 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify` +- 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → 追加 `--notify=false` +- 用户没提通知需求 → **不传该 flag**,保持不通知;不要自行补上 `--notify` +- `--notify` 仅在 `--members` 新格式下生效;旧格式 `--users` 下传了也不会生效,有通知需求必须改用 `--members` +- 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声 + **创建在线文档/表格/脑图**: drive 不支持创建文件,需走 `wiki node create --type `(创建空节点)或 `doc create`(创建并写入内容)。 **导出文档/导出为Word**: 导出是内容层操作,走 `doc export`,不属于 drive。 @@ -710,18 +719,54 @@ dws wiki node create --type folder --name "文件夹名" --workspace --users uid1,uid2 --role READER + dws drive permission add --node --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]' --notify dws drive permission update --node --users uid1 --role EDITOR + dws drive permission update --node --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false + dws drive permission update --node --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"}]' dws drive permission list --node + dws drive permission list --node --limit 50 --next-token <上次返回的 nextToken> + dws drive permission get-setting --node dws drive permission remove --node --users uid1 + dws drive permission remove --node --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]' Flags: - --node string 目标节点 ID 或 URL (必填) - --users string 用户 userId 列表,逗号分隔 (add/update/remove 必填) - --role string 角色: MANAGER / EDITOR / DOWNLOADER / READER (add/update 必填) - --workspace string 知识库 ID (选填) - --limit int 返回成员数上限 (仅 list,默认 30,最大 200) - --filter-role string 按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER (仅 list) + --node string 目标节点 ID 或 URL (必填) + --users string 用户 userId 列表,逗号分隔 (旧格式) + --role string 角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填) + --members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥 + --notify bool 是否通知被添加/变更的成员 (仅 --members 新格式时生效,add / update 均默认 false) + --limit int 返回成员数上限 (仅 list,默认 30,最大 50) + --filter-role string 按角色过滤 (仅 list) + --next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (仅 list) + --workspace string 知识库 ID (选填) ``` +> **add / update / remove 支持两种传参方式(互斥)**: +> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型) +> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个 member 携带独立 `roleId`(remove 只需 type 和 id,但 USER/DEPT/TAG 仍需 corpId) +> +> **成员类型说明**: +> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织) +> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织) +> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId` +> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型 +> +> **重要约束**: +> - `--notify` 仅在新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),add / update 均默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify` +> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied` +> - 单次请求最多 30 个成员,超出请分批调用 +> - list 命令底层一次性返回全量成员后在内存中按 pageSize 分页,当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页 + +`get-setting` 返回节点权限配置(不是成员清单):`permissionMode`(INHERITED 继承上级 / INDEPENDENT 独立管理)、`shareScope`(可见范围与链接分享设置)、`policies`(水印、组织外分享、添加成员门槛等策略列表)。查询协作者清单仍用 `permission list`。 + +get-setting 返回字段说明: +- `permissionMode` — INHERITED(继承上级)/ INDEPENDENT(独立管理),未知时为 null +- `shareScope` — `visibility`(PRIVATE/ORGANIZATION/PUBLIC);`partnerIncluded`、`defaultRole`、`canSearch`、`canRecommend` 仅 ORGANIZATION 有意义;`linkShare`(仅开启链接分享时返回):`requirePassword`(密码明文不返回)、`expireAt`/`expireDays`(未设置为 null)、`forCurrentNode` +- `policies[]` — 每项含 `code`(策略码)、`name`/`description`(中文名与值语义说明,随行必带)、`value`(当前值)、`disabledValues`(不可设置取值列表)、`allowedValues`(可设置值域,与 disabledValues 互斥);未下发或不支持的策略不返回;`node_spread_scope` 仅文件夹类节点返回 +- `disabledValues[]` — 每项含 `value`(被禁档位取值,与 value 同一值域)与 `reason`(服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null);恒返回,无被禁档位时为空数组;示例:`{"value": "READER_AND_ABOVE", "reason": "企业安全策略要求不可低于可下载角色"}` +- `value` 按策略分型:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;member_invite、comment 为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE(无 NOBODY);node_spread、online_content_copy 为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE 或 NOBODY(无 READER_AND_ABOVE);node_spread_scope 为 ALL_NODES(限制对所有文档生效)/ PREVIEWABLE_ONLY(仅对可预览的文档生效) +- `name`/`description` 示例(文案与产品权限设置页一致):external_share「添加企业外协作者」:是否允许添加企业外的人为协作者(ENABLED=允许,DISABLED=禁止);node_spread「谁可以下载、创建副本、打印」:允许哪些角色及以上的用户下载、创建副本、打印;NOBODY=所有人禁止下载、创建副本、打印;node_move_forbidden「禁止移动」:是否禁止移动到其他知识库或团队共享文件夹(ENABLED=禁止移动,DISABLED=允许移动) +- 方向语义:NOBODY=该操作对所有人禁止;XXX_AND_ABOVE=不低于该角色才允许 + ### 文件互联网公开发布 管理文件的互联网公开发布状态。公开后任何人通过链接即可访问,无需登录钉钉。操作者需要是文件的管理员或拥有者。 diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 6b45e267..e24b749f 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -51,7 +51,7 @@ | 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 | | 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 | | 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 | -| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | +| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | | 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | @@ -80,7 +80,7 @@ | [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` | | [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` | | [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` | -| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | +| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | @@ -218,8 +218,8 @@ Flags: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 | -| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 | +| `dws sheet +list-sheets` | read | 严格列出在线电子表格的工作表,并可按完整标题精确筛选 | +| `dws sheet +read` | read | 完整读取并严格校验在线电子表格范围;截断结果失败关闭 | ## 意图表 @@ -237,7 +237,7 @@ Flags: | "搜索公式文本" | `dws sheet find --node --sheet-id --query "<公式片段>" --match-formula` | | "正则搜索 / 不区分大小写" | `dws sheet find --node --sheet-id --query "" --use-regexp --match-case=false` | | "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1 --file <图片路径>` | -| "创建浮动图片" | 先 `dws sheet media-upload --node --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node --sheet-id --src "" --range A1 --width <宽> --height <高>` | +| "创建浮动图片" | `dws sheet create-float-image --node --sheet-id --file <图片路径> --range A1 --width <宽> --height <高>` | ## URL 与 ID 前置 diff --git a/skills/mono/references/products/sheet/sheet-media-image.md b/skills/mono/references/products/sheet/sheet-media-image.md index d3e236f0..aeee5e65 100644 --- a/skills/mono/references/products/sheet/sheet-media-image.md +++ b/skills/mono/references/products/sheet/sheet-media-image.md @@ -17,7 +17,7 @@ ### 浮动图片 用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图": -- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image` +- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src` - 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同 用户说"查看浮动图片/有哪些浮动图片/浮动图片列表": @@ -71,7 +71,11 @@ Flags: Usage: dws sheet create-float-image [flags] Example: - # 先上传图片获取 resourceUrl + # 直接上传本地图片并创建浮动图片 + dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 + + # 高级用法:先上传图片获取 resourceUrl dws sheet media-upload --node --file ./chart.png # 输出: resourceUrl: /core/api/resources/img/xxxx... @@ -85,7 +89,8 @@ Example: Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) - --src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填) + --file string 本地图片文件路径,与 --src 二选一 + --src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一 --range string 锚点单元格,A1 表示法,如 A1、B3 (必填) --width int 图片宽度,像素,正整数 (必填) --height int 图片高度,像素,正整数 (必填) @@ -94,7 +99,8 @@ Flags: ``` 浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL +- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建 +- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程 - `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `--width` / `--height` 为必填,单位像素,必须为正整数 - `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数 @@ -138,13 +144,18 @@ Example: # 调整尺寸 dws sheet update-float-image --node --sheet-id --float-image-id --width 600 --height 400 - # 替换图片(需先 media-upload 新图片获取 resourceUrl) + # 直接用本地图片替换 + dws sheet update-float-image --node --sheet-id --float-image-id \ + --file ./replacement.png + + # 高级用法:通过已上传的 resourceUrl 替换 dws sheet update-float-image --node --sheet-id --float-image-id \ --src "/core/api/resources/img/xxxx..." Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) --float-image-id string 浮动图片 ID (必填) + --file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用 --src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl --range string 新的锚点单元格,A1 表示法 --width int 新的图片宽度,像素 @@ -153,7 +164,7 @@ Flags: --offset-y int 新的垂直偏移量,像素 ``` -更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。 +更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。 `--float-image-id` 可通过 `list-float-images` 获取。 ### 删除浮动图片 @@ -210,6 +221,16 @@ dws sheet range update --node --sheet-id --range "A1:B1" \ dws sheet range update --node --sheet-id --range "A2:A2" \ --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json dws sheet write-image --node --sheet-id --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json + +# ── 工作流 11: 创建或替换浮动图片 ── + +# 从本地图片直接创建 +dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 -f json + +# 从本地图片直接替换已有浮动图 +dws sheet update-float-image --node --sheet-id \ + --float-image-id --file ./replacement.png -f json ``` ## 上下文传递 @@ -232,11 +253,11 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil - `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 -- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL +- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src` - `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数 - `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小 -- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl` -- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个 +- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file` +- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用 - `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete) - `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除 diff --git a/skills/mono/references/products/whiteboard.md b/skills/mono/references/products/whiteboard.md index 306d3ea5..c8f46176 100644 --- a/skills/mono/references/products/whiteboard.md +++ b/skills/mono/references/products/whiteboard.md @@ -8,14 +8,27 @@ OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需 `geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整 工作流见 [常用 Recipes](./whiteboard/recipes.md)。 + +## Shortcuts(无专用脚本/recipe 时优先) + +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "whiteboard +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws whiteboard --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service whiteboard --format json` 批量发现。 + +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws whiteboard +query` | read | 严格读取已有文档白板的 OpenNodes 快照 | +| `dws whiteboard +update` | high-risk-write | 确认后更新白板并按同一稳定目标精确读回 | + + ## 标准流程 1. 从用户输入或真实文档 JSONML 取得 `nodeId` 和 card `metadata.id`(partId)。 -2. `dws whiteboard query --node --part-id --format json` 保存当前内容。 +2. `dws whiteboard +query --node --part-id --format json` 保存并严格校验当前内容。 3. 生成 OpenNodes V1 文件;不能把 query 响应直接回写。 4. 向用户展示写入范围并取得确认。 -5. `dws whiteboard update --node --part-id --source --yes --format json`。 -6. 再次 query 验证节点、层级和连接关系。 +5. `dws whiteboard +update --node --part-id --source --format json`;该 shortcut 会验证终态 receipt,并按同一白板执行独立读回。 +6. 仅在需要人工审阅完整节点细节时,再执行一次 `+query`。 + +执行层只能在已取得本次写入确认后添加 `--yes`;可复制的存储示例不得预置该参数。 更新文件: diff --git a/skills/mono/references/products/wiki.md b/skills/mono/references/products/wiki.md index c06da8f6..5586f393 100644 --- a/skills/mono/references/products/wiki.md +++ b/skills/mono/references/products/wiki.md @@ -120,16 +120,31 @@ Example: dws wiki member add --workspace --users uid1 --role READER dws wiki member add --workspace --users uid1,uid2 --role EDITOR dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces//overview" --users uid1 --role MANAGER + dws wiki member add --workspace --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify + dws wiki member add --workspace --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]' Flags: --workspace string 目标知识库 ID 或 URL (必填) - --users string 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个) - --role string 授予的角色 (必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读) + --users string 被加入的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个) + --role string 授予的角色 (旧格式必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读) + --members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥 + --notify bool 是否通知被添加的成员 (仅 --members 新格式时生效,默认 false) ``` +> **两种传参方式(互斥)**: +> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型) +> - 新格式:`--members` 传入 JSON 数组,每个成员包含 `type`(USER/DEPT/CONVERSATION/TAG)、`id`、`roleId`(建议全大写),支持混合类型 +> +> **成员类型说明**: +> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织) +> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织) +> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId` +> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型 +> > **❗ 重要约束**: -> - 仅支持 USER 类型。 > - 角色枚举:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。 -> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。 +> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。 +> - 单次请求最多 30 个成员,超出请分批调用。 +> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`。旧格式始终通知。 > - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws drive permission add` 在节点级别授权。 ### 移除知识库成员 @@ -139,15 +154,28 @@ Usage: Example: dws wiki member remove --workspace --users uid1 dws wiki member remove --workspace --users uid1,uid2 + dws wiki member remove --workspace --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]' Flags: --workspace string 目标知识库 ID 或 URL (必填) - --users string 被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个) + --users string 被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个) + --members string 成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥 ``` +> **两种传参方式(互斥)**: +> - 旧格式:`--users` 传入逗号分隔的 userId 列表(仅 USER 类型) +> - 新格式:`--members` 传入 JSON 数组,只需 `type` 和 `id`(USER/DEPT/TAG 还需 `corpId`),支持 USER/DEPT/CONVERSATION/TAG +> +> **成员类型说明**: +> - `USER` — 用户,id 为用户 userId,需携带 `corpId` +> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId` +> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId` +> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId` +> > **重要约束**: > - OWNER 角色不可通过此接口移除。 -> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。 -> - 移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。 +> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。 +> - 单次请求最多 30 个成员,超出请分批调用。 +> - 移除后相关成员将无法访问该知识库下的内容(除非通过节点级权限另行授权)。 > - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**。 ### 修改知识库成员角色 @@ -157,27 +185,50 @@ Usage: Example: dws wiki member update --workspace --users uid1 --role EDITOR dws wiki member update --workspace --users uid1,uid2 --role READER + dws wiki member update --workspace --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false + dws wiki member update --workspace --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]' Flags: --workspace string 目标知识库 ID 或 URL (必填) - --users string 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个) - --role string 新角色 (必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER + --users string 目标用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个) + --role string 新角色 (旧格式必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER + --members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥 + --notify bool 是否通知被变更的成员 (仅 --members 新格式时生效,默认 false) ``` +> **两种传参方式(互斥)**: +> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型) +> - 新格式:`--members` 传入 JSON 数组,每个成员携带独立 `roleId` +> +> **成员类型说明**: +> - `USER` — 用户,id 为用户 userId,需携带 `corpId` +> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId` +> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId` +> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId` +> +> **重要约束**: +> - OWNER 角色不可通过此接口变更。 +> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。 +> - 单次请求最多 30 个成员,超出请分批调用。 +> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。 + ### 列出知识库成员 ``` Usage: dws wiki member list [flags] Example: dws wiki member list --workspace - dws wiki member list --workspace --limit 100 + dws wiki member list --workspace --limit 50 dws wiki member list --workspace --filter-role EDITOR + dws wiki member list --workspace --next-token <上次返回的 nextToken> Flags: --workspace string 目标知识库 ID 或 URL (必填) - --limit int 返回成员数上限,默认 30,最大 200 + --limit int 返回成员数上限,默认 30,最大 50 --filter-role string 按角色过滤,逗号分隔: OWNER / MANAGER / EDITOR / DOWNLOADER / READER (选填) + --next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (选填) ``` -> 接口不支持游标分页,使用 `--limit` 一次性拉取。 +> 底层一次性返回全量成员后在内存中按 pageSize 分页;当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页。 +> 操作者须满足该知识库配置的权限管理最低角色要求,权限不足返回 `forbidden.accessDenied`。 > ⚠️ **返回字段限制**:`member list` 每条只返回 `name` / `role` / `type` 三个字段,**不含 userId**(服务端不返回)。因此**无法**从 `member list` 拿到 userId 再去串联 `member update` / `member remove`。要对某人改角色 / 移除,需另行拿到其 userId(例如用 `dws contact user search --query "<姓名>"` 按姓名反查)。 @@ -338,7 +389,8 @@ Flags: - 用户说"复制知识库里的文档" → `node copy`(需 `--workspace` + `--node`) - 用户说"移动知识库里的文档" → `node move`(需 `--workspace` + `--node`) - 用户说"删除知识库里的文档/节点" → `node delete`(需 `--workspace` + `--node`) -- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`) +- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`,或 `--workspace` + `--members`) +- 用户说"加成员并通知他/邀请后告知对方" → `member add --members ... --notify`;用户说"加进去但不要通知/悄悄加" → `member add --members ... --notify=false` - 用户说"修改某人在知识库的权限/调整成员角色" → `member update` - 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`) - 用户说"知识库有哪些成员/查看知识库成员" → `member list` @@ -350,6 +402,12 @@ Flags: > **重要 — `--exclude-file` 使用规则**: > 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。 +> **通知意图 → `--notify`**(`--members` 新格式默认不通知,省略时 CLI 不向服务端发送该字段): +> - 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify` +> - 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → **必须改用 `--members` 并传 `--notify=false`**;旧格式 `--users` 始终通知且无法关闭,此时继续用旧格式会违背用户意图 +> - 用户没提通知需求 → 走 `--members` 时**不传该 flag**,保持不通知;不要自行补上 `--notify` +> - 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声 + > **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担: >- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node `** >- 用户说"搜文件"(不指定空间) → 走 **`dws drive search`**(全局聚合搜索) diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md index 3e38f5ce..50fc24c4 100644 --- a/skills/multi/dingtalk-aitable/SKILL.md +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-aitable -description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。 +description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表/数据源/创建数据源/更新数据源配置/触发数据源同步/按任务 ID 查询同步状态/获取数据源配置/列出数据源可用来源/获取数据源可同步字段/审批数据同步。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。 metadata: cli_version: ">=0.2.14" category: product @@ -28,7 +28,7 @@ metadata: ## Shortcut 发现(按需) -`aitable` 当前有 93 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 +`aitable` 当前有 100 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 @@ -60,10 +60,11 @@ metadata: | 调整视图列顺序 | `dws aitable view update visible-fields --base-id --table-id --view-id --field-ids <完整有序IDs>` | 先读取字段和当前完整列数组,固定主字段在首位,写后回读精确校验 | | 创建/修改图表前取配置 | `dws aitable +chart-widgets-example` | 命令返回所有图表类型示例;已有合法 config 时直接 create/update | | Base 内 Section/节点移动 | `dws aitable +section-*` | Table/Dashboard/Section 是 Base 内 nsheet 节点,不是独立 Drive 节点 | +| 接入外部数据源(审批等) | `dws aitable +datasource-list-sources --base-id --datasource-type OA` → 解析 result 构造 sourceConfig → `dws aitable +datasource-create --base-id --datasource-type OA --source-config ''` | 当前仅支持 OA 审批;sourceConfig 中 processCode/name/iconUrl/url 须从 list-sources 原样透传;创建后用 `+datasource-sync-status` 查同步结果 | ### 常用 leaf 直达 -参数已知时直接执行,不探测 Help/Catalog:Base 查看/改名用 `+base-get` / `+base-update`;模板搜索用 `+template-search`,再把真实 templateId 交给 `base create --template-id`;Table 查看/更新用 `+table-get` / `+table-update`;视图创建/复制用 `view create` / `+view-duplicate`;仪表盘创建/更新/读回用 `dashboard create` / `+dashboard-update` / `+dashboard-get`;表单分享用 `+form-share-update` / `+form-share-get`;查看自动化用 `+workflow-list`。 +参数已知时直接执行,不探测 Help/Catalog:Base 查看/改名用 `+base-get` / `+base-update`;模板搜索用 `+template-search`,再把真实 templateId 交给 `base create --template-id`;Table 查看/更新用 `+table-get` / `+table-update`;视图创建/复制用 `view create` / `+view-duplicate`;仪表盘创建/更新/读回用 `dashboard create` / `+dashboard-update` / `+dashboard-get`;表单分享用 `+form-share-update` / `+form-share-get`;查看自动化用 `+workflow-list`;数据源查看来源用 `+datasource-list-sources`,获取字段用 `+datasource-get-fields`,创建/更新/同步/查状态/查配置用 `+datasource-create` / `+datasource-update` / `+datasource-sync` / `+datasource-sync-status` / `+datasource-get-config`。 ### 低频入口 @@ -77,6 +78,7 @@ metadata: - 单产品线性任务直接执行,不创建 TodoWrite;只有跨产品或多个独立分支的长任务才建计划,并且只在阶段切换时更新,不在每条 CLI 后刷新状态。 - 用户要求资源名带当前时间戳时只取一次并在 Base、Table、Dashboard 等名称中复用同一值;不要为每个资源分别取时间。 - JSON 已返回所需字段时立即复用;不得为寻找同一字段改用 `--verbose`、`raw`、`pretty` 重复请求。 +- 数据源创建前必须先 `+datasource-list-sources` 获取 processCode 等透传字段,不要凭记忆或猜测构造 sourceConfig。 ## 记录输入与结果 @@ -91,6 +93,7 @@ metadata: ## 安全边界 - 删除不可逆,按 Runtime confirmation 核对真实目标;`base list` 只是最近访问。字段零/多候选、类型不明时停止;多批写保留已完成批次和续跑位置。 +- 数据源 `+datasource-create` / `+datasource-update` 会触发真实数据同步(全量),执行前确认目标 Base 和 sourceConfig 无误。`+datasource-sync` 同理,单次最多 5 张表。 ## 按需加载 @@ -107,6 +110,7 @@ metadata: | Base 内 Section/节点移动或清理 | [section](references/aitable-section.md) | | 图表配置 | [dashboard-chart](references/aitable/aitable-dashboard-chart.md) | | 附件、表单、工作流 | 读取 `references/aitable/` 下对应的一个精确文件 | +| 数据源接入、同步管理、sourceConfig 构造、同步审批数据到 AI 表格 | [datasource](references/aitable/aitable-datasource.md) | | 产品边界不明确 | [intent-guide](references/intent-guide.md) | 通用 `references/aitable.md` 仅保留为兼容索引,不是默认入口;正常 Case 不预读。低频能力按意图选择一个最精确的 Reference,禁止连读。 @@ -116,6 +120,7 @@ metadata: 1. 零/多候选、字段歧义或分页不完整:停止并返回证据;需要后续页时只透传真实 `nextCursor`。 2. 类型错误只复核目标字段,不删字段或丢输入;`partial_success` 从 checkpoint 续跑,未知写入先回读。 3. 错误包含 `actions` / `available_flags` 时只执行其中的 `next_command`;同一操作最多做一次有证据的参数修正。`retryable=false` 或目标 ID 类型不符时停止,不把 Drive/Wiki/Space/子节点 ID 轮流代入试错。 +4. 数据源同步 `errorCode=4014` 为幂等冲突(同步运行中重复触发),标记 FAILED 但可稍后重试;非数据源表(sync=false)触发 sync 会返回参数错误,先用 `+base-get` 确认 sync=true。 ## 跨产品边界 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-datasource.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-datasource.md new file mode 100644 index 00000000..08570e75 --- /dev/null +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-datasource.md @@ -0,0 +1,272 @@ +# datasource — 数据源同步管理 + +将外部数据源(当前仅支持 OA 审批)同步到 AI 表格。完整链路:list-sources → (OA) 选择模板 → get-fields → create → sync-status → sync / update / get-config。 + +## 命令一览 + +| 命令 | 用途 | 读/写 | +|------|------|-------| +| `+datasource-list-sources` | 列出可用数据源条目,获取 processCode/name/iconUrl/url | 读 | +| `+datasource-get-fields` | 获取可同步字段列表,用于决定 field-ids | 读 | +| `+datasource-create` | 创建数据源表并触发首次全量同步 | 写 | +| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | +| `+datasource-sync` | 手动触发一次同步(最多 5 张表) | 写 | +| `+datasource-sync-status` | 查询同步任务状态(RUNNING/FINISHED/FAILED) | 读 | +| `+datasource-get-config` | 获取数据源表当前同步配置 | 读 | + +## 典型工作流 + +``` +Step 0 列出可用来源 +datasource-list-sources --base-id --datasource-type OA + → 返回 approvals 数组(通常含多个审批模板) + +Step 0.5 (OA 审批) 选择模板 list-sources 返回的 approvals 数组通常含多个审批模板,需确定目标: + - 用户已指定名称(如"采购申请")→ 按 name 精确/模糊匹配 + · 唯一命中 → 提取该条的 processCode/name/iconUrl/url,继续 + · 多候选 → 列出匹配项让用户消歧 + · 零命中 → 停止,提示用户确认名称或从完整列表选择 + - 用户未指定 → 列出候选模板清单(name + processCode),等用户选择 + - 禁止:未匹配直接选第一项、或凭记忆猜 processCode + 注:此步骤针对 OA 审批数据源(多模板场景);其他数据源类型的 + list-sources 返回结构可能不同,按实际 result 解析即可, + 不一定需要选择步骤。 + +Step 1 (可选) 获取可同步字段 +datasource-get-fields --base-id --datasource-type OA --source-config '' + → 决定需要同步哪些字段,得到 field-ids + +Step 2 创建数据源 +datasource-create --base-id --datasource-type OA --source-config '' + → sourceConfig 中的 processCode/name/iconUrl/url 来自 Step 0.5 选中的模板 + → 返回 tableId + taskId + +Step 3 查询同步结果 +datasource-sync-status --base-id --table-id --task-ids + → FINISHED=完成,FAILED=看 errorCode 排查,RUNNING=轮询 + +Step 4 (后续) 手动触发同步 +datasource-sync --base-id --table-ids , + → 返回新 taskId,再用 sync-status 查结果 + +Step 5 (后续) 更新配置 +datasource-update --base-id --table-id --source-config '' --auto + → 更新后自动触发一次同步 + +查看当前配置 +datasource-get-config --base-id --table-id +``` + +## sourceConfig 字段协议 + +以下字段协议仅适用于 OA 审批数据源(datasourceType=OA),其他数据源类型待后续开放。 + +### 须从 list-sources 原样透传(必填) + +| 字段 | 类型 | 说明 | +|------|------|------| +| processCode | String | OA 审批流程编码 | +| name | String | 展示名称 | +| iconUrl | String | 图标 URL | +| url | String | 跳转链接 | + +### 调用方自行设置 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| dataType | String | 是 | 数据范围类型:`time_range` / `start_time` / `recent_time` | +| recentDays | String | 条件 | dataType=recent_time 时有效,取值 `7d`/`30d`/`1y`,默认 `30d` | +| startDate | String | 条件 | dataType=time_range 或 start_time 时有效,`yyyy-MM-dd`,默认 30 天前 | +| endDate | String | 条件 | dataType=time_range 时有效,`yyyy-MM-dd`,默认当天 | +| keepRemovedFields | Boolean | 否 | 是否保留已删除字段,默认 false | +| splitParentTableField | Boolean | 否 | 是否拆分父表字段 | + +> list-sources 返回的 keepRemovedFields / splitParentTableField / enableDataSyncOaDetailList 不要透传,由调用方按需设置。 + +### 三种 dataType 最小示例 + +```json +// recent_time — 最近一段时间 +{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."} + +// time_range — 指定起止日期 +{"processCode":"PROC-xxxx","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"...","url":"..."} + +// start_time — 从某日期至今 +{"processCode":"PROC-xxxx","name":"采购申请","dataType":"start_time","startDate":"2025-06-01","iconUrl":"...","url":"..."} +``` + +## autoSyncSetting 频率配置 + +仅在 `--auto=true` 时生效。不传时使用下游默认自动同步策略。 + +| 字段 | 必填 | 说明 | +|------|------|------| +| syncType | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) | +| hourlyInterval | hourly 时 | 正整数,小时间隔 | +| scheduleType | scheduled 时 | `daily` / `weekly` / `monthly` | +| timeValue | scheduled 时 | `HH:mm` 触发时间 | +| selectedMonthDays | monthly 时必填 | 每月几号触发,1-31 | +| selectedWeekdays | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 | +| skipNonWorkingDay | 否 | 是否跳过非工作日,默认 false | + +示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` + +## 命令详情 + +### +datasource-list-sources — 列出可用数据源条目 + +```bash +dws aitable +datasource-list-sources --base-id BASE_ID --datasource-type OA --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` | + +返回每条条目包含 `result`(下游原始 JSON 字符串)和 `sourceType`(OA 审批对应 2)。OA 审批场景下 result 为 approvals 数组: + +```json +{ + "approvals": [ + { + "processCode": "PROC-xxxx", + "name": "采购申请", + "iconUrl": "https://...", + "url": "https://...", + "keepRemovedFields": false, + "splitParentTableField": false, + "enableDataSyncOaDetailList": false + } + ] +} +``` + +调用方应自行解析 result,提取目标模板字段后构造 sourceConfig。 + +### +datasource-get-fields — 获取可同步字段列表 + +```bash +dws aitable +datasource-get-fields --base-id BASE_ID --datasource-type OA \ + --source-config '{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \ + --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` | +| `--source-config` | 是 | 源配置 JSON 字符串,结构同 create 的 --source-config | + +返回字段列表(字段 ID、名称、类型等),用于在 create/update 中指定 `--field-ids`。 + +### +datasource-create — 创建数据源表 + +```bash +dws aitable +datasource-create --base-id BASE_ID --datasource-type OA \ + --source-config '{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \ + --format json + +# 开启自动同步 + 自定义频率 +dws aitable +datasource-create --base-id BASE_ID --datasource-type OA \ + --source-config '...' --auto \ + --auto-sync-setting '{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}' \ + --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` | +| `--source-config` | 是 | 源配置 JSON 字符串(见上方字段协议) | +| `--auto` | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 | +| `--auto-sync-setting` | 否 | 自动同步频率配置 JSON 字符串,仅 --auto=true 时生效 | +| `--field-ids` | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 | + +返回新建数据源表 tableId 和同步任务 taskId。创建后自动触发一次全量同步,需用 `+datasource-sync-status` 查最终结果。 + +### +datasource-update — 更新数据源配置 + +```bash +# 仅开启自动同步 +dws aitable +datasource-update --base-id BASE_ID --table-id TABLE_ID --auto --format json + +# 更新源配置 +dws aitable +datasource-update --base-id BASE_ID --table-id TABLE_ID \ + --source-config '{"processCode":"PROC-yyyy","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \ + --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--table-id` | 是 | 已有数据源表 ID(sync=true) | +| `--source-config` | 否 | 新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖 | +| `--auto` | 否 | 是否开启自动同步,不传时保持原设置 | +| `--auto-sync-setting` | 否 | 自动同步频率配置 JSON 字符串,仅 --auto=true 时生效;不传时保持原频率配置 | +| `--field-ids` | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 | + +更新后自动触发一次全量同步,返回新 taskId。 + +### +datasource-sync — 手动触发同步 + +```bash +dws aitable +datasource-sync --base-id BASE_ID --table-ids TBL1,TBL2 --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--table-ids` | 是 | 待同步的数据源表 ID 列表(sync=true),1-5 个 | + +返回结果包含文档链接,可打开查看同步进度。每张表独立提交,部分失败不影响其他表。 + +### +datasource-sync-status — 按任务 ID 查询同步状态 + +```bash +dws aitable +datasource-sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2 --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--table-id` | 是 | 数据源表 ID(sync=true) | +| `--task-ids` | 是 | 同步任务 ID 列表(由 create/update/sync 返回),1-5 个 | + +任务状态:`RUNNING`(进行中)、`FINISHED`(完成)、`FAILED`(失败,含 errorCode + errorMessage)。 + +### +datasource-get-config — 获取数据源配置 + +```bash +dws aitable +datasource-get-config --base-id BASE_ID --table-id TABLE_ID --format json +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 目标 Base ID | +| `--table-id` | 是 | 数据源表 ID(sync=true) | + +返回当前同步配置详情(sourceConfig、是否自动同步、同步状态等)。仅适用于数据源表,普通表会报错。 + +## 错误码与排查 + +| 场景 | 表现 | 排查 | +|------|------|------| +| 同步运行中重复触发 | errorCode=4014,status=FAILED | 幂等冲突,稍后重试即可 | +| 非数据源表触发 sync | 参数错误返回 | 确认 table 的 sync=true,用 `+base-get` / `+table-list` 检查 | +| sourceConfig 缺必填字段 | 创建/更新失败 | 检查 processCode/name/iconUrl/url 是否从 list-sources 原样透传 | +| dataType 与时间字段不匹配 | 创建失败 | recent_time 需 recentDays;time_range 需 startDate+endDate;start_time 需 startDate | + +## 能力边界 + +| 能力 | 状态 | +|------|------| +| OA 审批数据源 | 已支持 | +| 其他数据源类型 | 待后续开放 | +| 全量同步 | 已支持 | +| 增量同步 | 待后续开放 | +| 自动同步 | 已支持(--auto + autoSyncSetting) | +| 删除数据源表 | 走普通表删除,不走 datasource 命令 | + +## 注意事项 + +- sourceConfig 是 **JSON 字符串**(不是 JSON 对象),CLI flag 传入时需要用单引号包裹 +- list-sources 返回的 keepRemovedFields / splitParentTableField / enableDataSyncOaDetailList 不要透传,由调用方按需设置 +- create/update 后自动触发一次同步,返回 taskId;用 sync-status 查最终结果 +- sync 单次最多 5 张表,超出拆分多次调用 +- sync-status 单次最多 5 个 taskId,超出拆分多次调用 +- get-config 仅适用于数据源表(sync=true),普通表会报错 diff --git a/skills/multi/dingtalk-contact/SKILL.md b/skills/multi/dingtalk-contact/SKILL.md index 76c52aac..d4a063d5 100644 --- a/skills/multi/dingtalk-contact/SKILL.md +++ b/skills/multi/dingtalk-contact/SKILL.md @@ -29,7 +29,6 @@ metadata: | `dws contact +list-dept-members` | read | 查看部门成员(仅本部门,不含下级) | | `dws contact +list-followings` | read | 获取当前用户的特别关注列表 | | `dws contact +list-role-members` | read | 查询角色下的成员列表 | -| `dws contact +list-roles` | read | 获取企业所有角色(标签)列表 | | `dws contact +list-sub-depts` | read | 查看指定部门的子部门 | | `dws contact +lookup` | read | 按姓名查询某人的完整资料(自动解析 userId 后取详情) | | `dws contact +me` | read | 查看我自己的通讯录资料(姓名/userId/手机/部门/组织,干净投影) | diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md index 9ecdfc67..8ae49dc6 100644 --- a/skills/multi/dingtalk-doc/references/doc.md +++ b/skills/multi/dingtalk-doc/references/doc.md @@ -52,6 +52,8 @@ dws doc +version-revert --node --version --format json `+version-save/list/revert` 分别用于快照、浏览和恢复,命中后直接执行,不预读 Help。`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。重要内容更新优先使用 `+checkpoint-update`,不要手工编排保存、写入和回读。回滚必须确认,以 leaf Schema 与 Runtime gate 为准。 +只读某个历史版本的内容时,用 `dws doc +fetch --node --version `(版本号同样来自 `+version-list`,`0` 表示初始版本,需要文档编辑权限);整体恢复到历史版本才用 `+version-revert`(危险操作,需确认)。互联网公开文档(含密码保护)的读取见 [doc-read.md](doc/doc-read.md) 的 `--password`。 + ## 权限与分享 - 查询或聚合权限:`+inspect --include-permissions`。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-read.md b/skills/multi/dingtalk-doc/references/doc/doc-read.md index ed7857fc..8a05aef0 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-read.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-read.md @@ -5,6 +5,8 @@ ```bash dws doc +fetch --node --format json dws doc +fetch --query "项目周报" --scope keyword --keyword "结论" --format json +dws doc +fetch --node --password --format json +dws doc +fetch --node --version --format json ``` - 已知 ID 或 URL:传 `--node`。 @@ -12,6 +14,13 @@ dws doc +fetch --query "项目周报" --scope keyword --keyword "结论" --forma - `--node` 与 `--query` 必须且只能提供一个。 - 默认 `--detail simple --scope full`,适合普通阅读,避免加载不必要的 JSONML。 +## 互联网公开文档与历史版本 + +- **互联网公开文档**:公开链接直接传 `--node`;文档开启了密码保护时,通过 `--password ` 提供访问密码,普通文档无需传入。密码只进入读取请求,不会回显在读取结果里(`--dry-run` 预览会包含所传参数,注意输出环境)。 +- **历史版本**:`--version ` 读取指定历史版本内容;版本号从 `dws doc +version-list` 获取,`0` 表示文档初始版本,需要文档编辑权限(EDITOR 及以上);缺省读最新版。注意区分:`revision` 是文档编辑版本号(JSONML 读取响应返回、供 `+update --expected-revision` 条件写使用),不是历史版本号,`+fetch` 不支持 `--revision`。 +- **跨组织文档**:非互联网公开的跨组织文档,`+fetch` 整体被组织边界拦截(提示「不支持跨组织访问数据」),最新版与历史版本内容都读不到;互联网公开(含密码)文档的 `+fetch` 不受影响,但 `+version-list` 与写操作仍会被拦截,版本号无法从列表获取。 +- 只要看历史内容、不打算恢复时用 `--version`;要把文档整体恢复到历史版本才用 `+version-revert`(危险操作,需确认)。 + ## 局部读取 ```bash diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 113beb05..515285c0 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -72,6 +72,7 @@ dws drive +publish-unset --node | 意图 | managed leaf | |---|---| | 查看成员权限 | `drive permission list` | +| 查询节点权限设置(权限模式/分享范围/策略) | `permission get-setting` | | 添加、修改、移除成员 | `permission add` / `update` / `remove` | | 转移所有者 | `permission transfer-owner` | | 查看可申请权限和审批人 | `permission apply-info` | @@ -79,6 +80,8 @@ dws drive +publish-unset --node 只在意图命中时读取一个精确 leaf Schema。成员变更、转移所有者、发起申请和公开状态变更必须明确节点、用户、角色与影响范围。转移所有者时,在构造最终命令前必须让用户分别明确决定 `--reserve-role ` 和 `--recursive=`;Agent 不得根据默认值、对象类型或便捷性自行选择任一项。两项决策与目标、新所有者均明确后,才按 Runtime confirmation 构造首次正式调用。 +`permission get-setting` 返回 `permissionMode`(INHERITED/INDEPENDENT,未知时为 null)、`shareScope`(可见范围与链接分享,密码明文不返回;`partnerIncluded`、`defaultRole` 等仅 ORGANIZATION 有意义,`linkShare` 仅开启链接分享时返回)和 `policies[]`(code/name/description/value/disabledValues/allowedValues;name/description 为中文名与值语义说明,随行必带;未下发的策略不返回,`node_spread_scope` 仅文件夹)。`disabledValues` 为不可设置取值列表(恒返回,无被禁档位时为空数组),每项含 `value`(被禁档位取值,与 value 同一值域)与 `reason`(服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null),与 allowedValues 互斥;示例:`{"value": "READER_AND_ABOVE", "reason": "企业安全策略要求不可低于可下载角色"}`。`value` 按策略分型:开关型为 ENABLED/DISABLED;member_invite、comment 为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE;node_spread、online_content_copy 为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE 或 NOBODY;node_spread_scope 为 ALL_NODES(限制对所有文档生效)/ PREVIEWABLE_ONLY(仅对可预览的文档生效)。NOBODY=该操作对所有人禁止;XXX_AND_ABOVE=不低于该角色才允许。name/description 示例(文案与产品权限设置页一致):external_share「添加企业外协作者」:是否允许添加企业外的人为协作者(ENABLED=允许,DISABLED=禁止);node_spread「谁可以下载、创建副本、打印」:允许哪些角色及以上的用户下载、创建副本、打印;NOBODY=所有人禁止下载、创建副本、打印;node_move_forbidden「禁止移动」:是否禁止移动到其他知识库或团队共享文件夹(ENABLED=禁止移动,DISABLED=允许移动)。 + 发起权限申请先只读执行 `permission apply-info`。正式 `permission apply` 会通知审批人;调用前必须向用户逐项回显并确认资源、申请角色、审批人和理由。Agent 不得默认选择第一位审批人、最高/最低角色或代写申请理由;用户未明确同意完整申请内容时停在确认环节。 ## 快捷方式节点 diff --git a/skills/multi/dingtalk-misc/references/agoal.md b/skills/multi/dingtalk-misc/references/agoal.md index f76d6bee..00cede10 100644 --- a/skills/multi/dingtalk-misc/references/agoal.md +++ b/skills/multi/dingtalk-misc/references/agoal.md @@ -70,6 +70,7 @@ Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分 | `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` | | `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 | | `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY | +| `scorecard search-entities` | 搜索计分卡指标与关键事项 | `--keyword` | 可选 `--page`、`--page-size`;keyword 为标题模糊匹配关键词 | #### selectedTime 时间说明 @@ -163,6 +164,7 @@ items 每项包含: - 查看详情 → `scorecard detail` - 实体详情 → `scorecard entity-detail` - 修改/更新 → `scorecard update` +- 搜索计分卡指标与关键事项 → `scorecard search-entities --keyword "关键词"` 用户说"目标/OKR/我的目标/个人目标": - 规则周期 → `user rules` @@ -218,6 +220,10 @@ dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID # 10. 更新计分卡 dws agoal scorecard update --dept-id DEPT_ID --selected-time "2025-01-01T00:00:00+08:00" --id SC_ID --tracking-period-type MONTHLY --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]' +# 10.1 搜索计分卡指标与关键事项 +dws agoal scorecard search-entities --keyword "业绩" +dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20 + # 11. 查看用户规则 → 提取 ruleId 和 periodId dws agoal user rules --user-id USER_ID @@ -286,6 +292,7 @@ dws agoal obj-template create-or-update --template-id TPL_ID --title "业绩模 | "查经营合约" | `dws agoal contract list` / `contract detail` | | "更新经营合约" | 先 `contract detail` 获取完整内容,再 `contract update` 覆盖更新 | | "查计分卡" | `dws agoal scorecard detail` / `scorecard entity-detail` | +| "搜索计分卡指标与关键事项" | `dws agoal scorecard search-entities --keyword ` | | "更新计分卡" | 先查详情,再按 [agoal.md](./agoal.md) 覆盖更新 | | "查周月报统计/提交情况/跟催" | `dws agoal report list-statistics` / `report submit-detail` | diff --git a/skills/multi/dingtalk-misc/references/devapp.md b/skills/multi/dingtalk-misc/references/devapp.md index 1f39d076..3d9d5032 100644 --- a/skills/multi/dingtalk-misc/references/devapp.md +++ b/skills/multi/dingtalk-misc/references/devapp.md @@ -25,19 +25,25 @@ | Shortcut | 风险 | 适用场景 | |---|---|---| | `dws devapp +create` | write | 创建开放平台企业内部应用 | +| `dws devapp +credentials-get` | read | 读取开放平台应用凭证 | | `dws devapp +delete` | high-risk-write | 删除开放平台企业内部应用(不可逆) | | `dws devapp +disable` | high-risk-write | 停用开放平台企业内部应用 | | `dws devapp +enable` | write | 启用开放平台企业内部应用 | -| `dws devapp +event-list` | read | 查询应用已订阅的事件列表 | +| `dws devapp +event-list` | read | 查询应用可用事件目录与订阅状态 | +| `dws devapp +event-subscribe` | write | 订阅应用事件回调 | | `dws devapp +get` | read | 查询开放平台企业内部应用详情 | | `dws devapp +list` | read | 查询开放平台企业内部应用列表 | | `dws devapp +member-add` | write | 添加开放平台应用成员 | | `dws devapp +member-list` | read | 查询开放平台应用成员 | | `dws devapp +member-remove` | high-risk-write | 移除开放平台应用成员 | | `dws devapp +permission-list` | read | 查询开放平台应用权限列表 | +| `dws devapp +robot-config` | write | 创建或更新现有应用的机器人配置(upsert) | +| `dws devapp +robot-disable` | high-risk-write | 停用现有应用的机器人能力 | +| `dws devapp +robot-enable` | write | 启用现有应用机器人能力(纯启用,无需配置字段) | | `dws devapp +robot-get` | read | 查询现有应用的机器人配置 | | `dws devapp +update` | write | 修改开放平台企业内部应用基础信息 | | `dws devapp +version-check-approval` | read | 预检版本发布是否需要审批(不实际发布) | +| `dws devapp +version-create` | write | 基于当前配置创建应用新版本 | | `dws devapp +version-get` | read | 查询指定版本详情 | | `dws devapp +version-list` | read | 分页查询应用版本列表 | | `dws devapp +version-status` | read | 查询版本发布/审批状态 | diff --git a/skills/multi/dingtalk-misc/references/pat.md b/skills/multi/dingtalk-misc/references/pat.md index 41de9718..a4d4ec23 100644 --- a/skills/multi/dingtalk-misc/references/pat.md +++ b/skills/multi/dingtalk-misc/references/pat.md @@ -4,6 +4,16 @@ `dws pat` 管理 Agent 的行为授权。它不管理开放平台应用权限;应用权限使用 `dws dev app permission`(见 [devapp.md](./devapp.md))。 + +## Shortcuts(无专用脚本/recipe 时优先) + +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "pat +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws pat --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service pat --format json` 批量发现。 + +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws pat +browser-policy` | write | 安全配置 PAT 授权时是否允许打开本地浏览器 | + + ## 命令总览 ### 配置浏览器策略 diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index 1fb2d5d1..56789b02 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -51,7 +51,7 @@ | 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 | | 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 | | 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 | -| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | +| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | | 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | @@ -80,7 +80,7 @@ | [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` | | [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` | | [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` | -| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | +| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | @@ -218,8 +218,8 @@ Flags: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 | -| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 | +| `dws sheet +list-sheets` | read | 严格列出在线电子表格的工作表,并可按完整标题精确筛选 | +| `dws sheet +read` | read | 完整读取并严格校验在线电子表格范围;截断结果失败关闭 | ## 意图表 @@ -237,7 +237,7 @@ Flags: | "搜索公式文本" | `dws sheet find --node --sheet-id --query "<公式片段>" --match-formula` | | "正则搜索 / 不区分大小写" | `dws sheet find --node --sheet-id --query "" --use-regexp --match-case=false` | | "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1 --file <图片路径>` | -| "创建浮动图片" | 先 `dws sheet media-upload --node --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node --sheet-id --src "" --range A1 --width <宽> --height <高>` | +| "创建浮动图片" | `dws sheet create-float-image --node --sheet-id --file <图片路径> --range A1 --width <宽> --height <高>` | ## URL 与 ID 前置 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md index d3e236f0..aeee5e65 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md @@ -17,7 +17,7 @@ ### 浮动图片 用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图": -- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image` +- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src` - 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同 用户说"查看浮动图片/有哪些浮动图片/浮动图片列表": @@ -71,7 +71,11 @@ Flags: Usage: dws sheet create-float-image [flags] Example: - # 先上传图片获取 resourceUrl + # 直接上传本地图片并创建浮动图片 + dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 + + # 高级用法:先上传图片获取 resourceUrl dws sheet media-upload --node --file ./chart.png # 输出: resourceUrl: /core/api/resources/img/xxxx... @@ -85,7 +89,8 @@ Example: Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) - --src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填) + --file string 本地图片文件路径,与 --src 二选一 + --src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一 --range string 锚点单元格,A1 表示法,如 A1、B3 (必填) --width int 图片宽度,像素,正整数 (必填) --height int 图片高度,像素,正整数 (必填) @@ -94,7 +99,8 @@ Flags: ``` 浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL +- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建 +- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程 - `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `--width` / `--height` 为必填,单位像素,必须为正整数 - `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数 @@ -138,13 +144,18 @@ Example: # 调整尺寸 dws sheet update-float-image --node --sheet-id --float-image-id --width 600 --height 400 - # 替换图片(需先 media-upload 新图片获取 resourceUrl) + # 直接用本地图片替换 + dws sheet update-float-image --node --sheet-id --float-image-id \ + --file ./replacement.png + + # 高级用法:通过已上传的 resourceUrl 替换 dws sheet update-float-image --node --sheet-id --float-image-id \ --src "/core/api/resources/img/xxxx..." Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) --float-image-id string 浮动图片 ID (必填) + --file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用 --src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl --range string 新的锚点单元格,A1 表示法 --width int 新的图片宽度,像素 @@ -153,7 +164,7 @@ Flags: --offset-y int 新的垂直偏移量,像素 ``` -更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。 +更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。 `--float-image-id` 可通过 `list-float-images` 获取。 ### 删除浮动图片 @@ -210,6 +221,16 @@ dws sheet range update --node --sheet-id --range "A1:B1" \ dws sheet range update --node --sheet-id --range "A2:A2" \ --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json dws sheet write-image --node --sheet-id --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json + +# ── 工作流 11: 创建或替换浮动图片 ── + +# 从本地图片直接创建 +dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 -f json + +# 从本地图片直接替换已有浮动图 +dws sheet update-float-image --node --sheet-id \ + --float-image-id --file ./replacement.png -f json ``` ## 上下文传递 @@ -232,11 +253,11 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil - `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 -- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL +- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src` - `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数 - `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小 -- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl` -- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个 +- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file` +- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用 - `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete) - `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除 diff --git a/skills/multi/dingtalk-misc/references/whiteboard.md b/skills/multi/dingtalk-misc/references/whiteboard.md index 125c88fc..057b6995 100644 --- a/skills/multi/dingtalk-misc/references/whiteboard.md +++ b/skills/multi/dingtalk-misc/references/whiteboard.md @@ -8,6 +8,17 @@ OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需 `geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整 工作流见 [常用 Recipes](./whiteboard/recipes.md)。 + +## Shortcuts(无专用脚本/recipe 时优先) + +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "whiteboard +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws whiteboard --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service whiteboard --format json` 批量发现。 + +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws whiteboard +query` | read | 严格读取已有文档白板的 OpenNodes 快照 | +| `dws whiteboard +update` | high-risk-write | 确认后更新白板并按同一稳定目标精确读回 | + + ## 定位白板 每次操作都需要真实的文档 `nodeId` 和白板 `partId`。缺少 `partId` 时先读取文档 @@ -21,7 +32,7 @@ dws doc read --node --content-format jsonml --scope tags --tags card -- ## 读取 ```bash -dws whiteboard query --node --part-id --format json +dws whiteboard +query --node --part-id --format json ``` CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令不支持全局 @@ -61,14 +72,16 @@ CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令 - `overwrite=false`:追加,`nodes` 至少一个对象。 - `overwrite=true`:整页重建,允许空数组;执行前必须先 query 保存当前内容。 -- 所有更新都是远端写入,必须先获得用户确认,再加 `--yes`。 +- 所有更新都是远端写入,必须先获得用户对本次写入的确认;存储示例不携带 `--yes`,执行层只能在确认后添加。 - Query 返回不能直接作为 update 输入;真实节点 ID 不能用于局部修改。 ```bash -dws whiteboard update --node --part-id \ - --source ./whiteboard.json --yes --format json +dws whiteboard +update --node --part-id \ + --source @whiteboard.json --format json ``` +`+update` 会严格验证终态 receipt、请求节点到真实节点的映射,并对同一 `nodeId` / `partId` 执行独立读回;不需要再以手写原子命令拼装验证链。 + 常用节点类型包括 `text`、`shape`、`frame`、`group`、`connector`、`vector`。 节点可用请求内临时 `id` 建立 `parentId` 或 connector 引用;服务端负责完整字段、 层级和枚举校验,未知字段会使整次更新失败。 diff --git a/skills/multi/dingtalk-wiki/references/wiki-members.md b/skills/multi/dingtalk-wiki/references/wiki-members.md index ee571769..b5433cc0 100644 --- a/skills/multi/dingtalk-wiki/references/wiki-members.md +++ b/skills/multi/dingtalk-wiki/references/wiki-members.md @@ -18,7 +18,12 @@ dws wiki +member-remove --workspace --users --format json ## 列表完整性 -`+member-list` 的服务端单次上限为 50,且没有可续游标。结果能证明“本次返回的成员”,不能据此断言大型知识库的完整成员全集。不要伪造 `--page-all` 或不断提高 limit。 +成员列表服务端单次上限为 50(`--limit` / pageSize 最大 50)。快捷命令 `+member-list` 适合单页列表,但**不支持游标翻页**;超过 50 人必须改用原生 `dws wiki member list` 的 `--next-token` 翻页:首次调用不传,后续把上一次响应中的 `nextToken` 传入 `--next-token`,直到 `hasMore` 为 false。出参 `totalCount` 为全量成员总数,可用于核对是否拉全。不要伪造 `--page-all` 或不断提高 limit 超过 50。 + +```bash +dws wiki member list --workspace --limit 50 --format json +dws wiki member list --workspace --next-token <上次返回的 nextToken> --format json +``` ## 写入验证 diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index 9611e9b7..c06df396 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -71,7 +71,7 @@ [agoal.scorecard] runnable: true hidden: false - commands: detail, entity-detail, update + commands: detail, entity-detail, search-entities, update flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited [agoal.scorecard.detail] @@ -89,6 +89,11 @@ hidden: false flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --content:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dept-id:string|required=false|hidden=false|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --request-id:string|required=false|hidden=false|no-opt=""|scope=local, --selected-time:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --tracking-period-type:string|required=false|hidden=false|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited +[agoal.scorecard.search-entities] + runnable: true + hidden: false + flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --request-id:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited + [agoal.strategy] runnable: true hidden: false diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index a71385b5..1585d6ae 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -351,6 +351,79 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable. } } +func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) { + newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) { + t.Helper() + repo := newChangelogGateRepo(t) + sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1") + if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output) + } + changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644) + repo.commit(t, "merge post-seal beta fragment") + return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + } + + stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) { + t.Helper() + changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog + +## [Unreleased] + +## [1.0.1-beta.1] - 2026-07-17 + +### Added + +- Chat reply mentions. + +- Sheet accepts local float image files. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, 0o644) + archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1") + if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil { + t.Fatalf("Rename beta amendment fragment: %v", err) + } + repo.commit(t, "amend untagged beta release notes") + } + + t.Run("accepts exact pre-tag merge", func(t *testing.T) { + repo, amendmentBase := newAmendmentRepo(t) + stageCanonicalAmendment(t, repo) + + if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output) + } + }) + + t.Run("rejects tagged beta", func(t *testing.T) { + repo, amendmentBase := newAmendmentRepo(t) + stageCanonicalAmendment(t, repo) + changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1") + + output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD") + if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") { + t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output) + } + }) + + t.Run("rejects rewritten sealed prose", func(t *testing.T) { + repo, amendmentBase := newAmendmentRepo(t) + stageCanonicalAmendment(t, repo) + changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644) + repo.commit(t, "rewrite sealed beta prose") + + output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD") + if err == nil || !strings.Contains(output, "does not exactly match") { + t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output) + } + }) +} + func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) { t.Run("invalid active fragment", func(t *testing.T) { repo := newChangelogGateRepo(t) @@ -1106,6 +1179,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Fatal("Code Admission workflow missing Policy job boundaries") } policyJob := admission[policyStart:policyEnd] + if !strings.Contains(policyJob, "timeout-minutes: 15") { + t.Error("Policy job must retain enough headroom for full Schema policy validation") + } requirePolicyEnv := func(step, nextStep string) { t.Helper() start := strings.Index(policyJob, " - name: "+step+"\n") diff --git a/test/scripts/coverage_baseline_repair_identity_test.go b/test/scripts/coverage_baseline_repair_identity_test.go new file mode 100644 index 00000000..698fed25 --- /dev/null +++ b/test/scripts/coverage_baseline_repair_identity_test.go @@ -0,0 +1,145 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const stableMergedPRIdentityHelper = `function isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, +) { + return ( + currentPull?.number === pullNumber && + currentPull.state === 'closed' && + currentPull.merged === true && + typeof currentPull.merged_at === 'string' && + currentPull.merged_at.length > 0 && + currentPull.base?.ref === 'main' && + currentPull.head?.sha === headSha && + currentPull.merge_commit_sha === mergeCommitSha + ); +}` + +func TestCoverageBaselineRepairUsesStableMergedPRIdentity(t *testing.T) { + t.Parallel() + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to verify merged-PR repair identity semantics") + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + workflowPath := filepath.Join( + root, + ".github", + "workflows", + "coverage-baseline-repair.yml", + ) + data, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read %s: %v", workflowPath, err) + } + workflow := string(data) + + var parsedWorkflow any + if err := yaml.Unmarshal(data, &parsedWorkflow); err != nil { + t.Fatalf("parse %s: %v", workflowPath, err) + } + var scripts []string + collectGitHubScripts(parsedWorkflow, &scripts) + helperCount := 0 + checkCount := 0 + for _, script := range scripts { + helperCount += strings.Count(script, stableMergedPRIdentityHelper) + checkCount += strings.Count(script, "!isStableMergedPRIdentity(") + } + if helperCount != 2 { + t.Fatalf("stable merged-PR identity helper occurrences = %d, want dispatcher and producer", helperCount) + } + if checkCount != 2 { + t.Fatalf("stable merged-PR identity checks = %d, want dispatcher and producer", checkCount) + } + for _, forbidden := range []string{ + "currentPull.base.sha", + "const baseSha =", + "base_sha", + } { + if strings.Contains(workflow, forbidden) { + t.Errorf("merged-PR repair must not bind mutable Ref SHA via %q", forbidden) + } + } + for _, want := range []string{ + "const baseRef = eventPull?.base?.ref;", + "baseRef !== 'main'", + "const headSha = eventPull?.head?.sha;", + "const headSha = payload.head_sha;", + "head_sha: headSha", + "currentPull.head?.sha === headSha", + "currentPull.merge_commit_sha === mergeCommitSha", + "await requireMainContainment(mergeCommitSha)", + "await requireMainContainment(targetSha)", + } { + if !strings.Contains(workflow, want) { + t.Errorf("merged-PR repair missing stable identity guard %q", want) + } + } + + verification := stableMergedPRIdentityHelper + ` +const pullNumber = 1077; +const headSha = '1'.repeat(40); +const mergeCommitSha = '2'.repeat(40); +const validPull = { + number: pullNumber, + state: 'closed', + merged: true, + merged_at: '2026-08-25T04:55:30Z', + base: {ref: 'main', sha: '3'.repeat(40)}, + head: {sha: headSha}, + merge_commit_sha: mergeCommitSha, +}; +const cases = [ + ['base ref advanced after event', {...validPull, base: {...validPull.base, sha: '4'.repeat(40)}}, true], + ['base sha omitted by projection', {...validPull, base: {ref: 'main'}}, true], + ['wrong pull number', {...validPull, number: pullNumber + 1}, false], + ['open pull request', {...validPull, state: 'open'}, false], + ['not merged', {...validPull, merged: false}, false], + ['missing merged at', {...validPull, merged_at: null}, false], + ['empty merged at', {...validPull, merged_at: ''}, false], + ['non-string merged at', {...validPull, merged_at: 1}, false], + ['wrong base ref', {...validPull, base: {...validPull.base, ref: 'release'}}, false], + ['missing base ref', {...validPull, base: {}}, false], + ['missing head', {...validPull, head: null}, false], + ['wrong head sha', {...validPull, head: {sha: '5'.repeat(40)}}, false], + ['wrong merge sha', {...validPull, merge_commit_sha: '6'.repeat(40)}, false], + ['null pull', null, false], +]; +for (const [name, currentPull, want] of cases) { + const got = isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + ); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} +` + command := exec.Command(node, "-e", verification) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("stable merged-PR identity verification failed: %v\n%s", runErr, output) + } +} diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index 5ffb1298..facc8b8d 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -135,15 +135,32 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { t.Fatalf("ReadFile(ci.yml) error = %v", err) } admission := string(data) + for _, want := range []string{ + "group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }}", + "cancel-in-progress: true", + } { + if !strings.Contains(admission, want) { + t.Errorf("CI workflow missing exact-revision producer contract %q", want) + } + } + for _, forbidden := range []string{ + "github.event.pull_request.number || github.ref", + "github.event.pull_request.number || github.sha", + } { + if strings.Contains(admission, forbidden) { + t.Errorf("CI producers must not share the coarse concurrency identity %q", forbidden) + } + } currentStart := strings.Index(admission, "\n coverage-current:\n") fullStart := strings.Index(admission, "\n coverage-current-full:\n") supportingStart := strings.Index(admission, "\n coverage-supporting:\n") baselineStart := strings.Index(admission, "\n coverage-baseline:\n") + metadataStart := strings.Index(admission, "\n coverage-main-metadata:\n") gateStart := strings.Index(admission, "\n coverage:\n") policyStart := strings.Index(admission, "\n policy:\n") if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart || - baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart { + baselineStart <= supportingStart || metadataStart <= baselineStart || gateStart <= metadataStart || policyStart <= gateStart { t.Fatal("CI workflow missing ordered coverage job boundaries") } @@ -175,7 +192,10 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { } } - baselineJob := admission[baselineStart:gateStart] + baselineJob := admission[baselineStart:metadataStart] + if !strings.Contains(baselineJob, "timeout-minutes: 30") { + t.Error("coverage-baseline must retain enough headroom for an authoritative cold-cache fallback") + } cachePath := "coverage-cache.txt" baselineKey := "dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}" for _, want := range []string{ @@ -201,8 +221,50 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { t.Error("coverage baseline cache must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline") } + metadataJob := admission[metadataStart:gateStart] + for _, want := range []string{ + "github.event_name == 'push'", + "needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true'", + "timeout-minutes: 30", + "fetch-depth: 0", + "PUSH_BEFORE_SHA: ${{ github.event.before }}", + "PUSH_AFTER_SHA: ${{ github.event.after }}", + "git merge-base --is-ancestor \"$PUSH_BEFORE_SHA\" \"$PUSH_AFTER_SHA\"", + "git diff --name-only --no-renames -z", + `^\.changes/[a-z0-9][a-z0-9._-]*\.md$`, + `^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$`, + "Refusing coverage-cache promotion for unreviewed change-fragment path", + "Refusing coverage-cache promotion for executable path", + "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA", + "id: metadata-current-cache", + "id: metadata-source-cache", + "key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}", + "go test -count=1 -p 1", + "./ ./cmd/... ./internal/... ./skills/...", + "key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}", + "id: metadata-target-cache-verification", + "lookup-only: true", + "fail-on-cache-miss: true", + "EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, + } { + if !strings.Contains(metadataJob, want) { + t.Errorf("metadata-only main cache producer missing contract %q", want) + } + } + if strings.Contains(metadataJob, "restore-keys") { + t.Error("metadata-only main cache promotion must use exact source and target keys") + } + if strings.Count(metadataJob, "path: "+cachePath) != 4 { + t.Error("metadata-only producer must restore target/source, save, and verify through the shared cache version path") + } + gateJob := admission[gateStart:policyStart] for _, want := range []string{ + "- coverage-main-metadata", + "MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}", + "main_metadata_expected=success", + `"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"`, "pattern: coverage-current-*", "merge-multiple: true", "for shard in app cli generators helpers remaining; do", @@ -212,6 +274,12 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { "cp coverage.txt coverage-cache.txt", "path: " + cachePath, "key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}", + "name: Verify push coverage cache exists", + "id: push-cache-verification", + "lookup-only: true", + "fail-on-cache-miss: true", + "EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, `"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`, } { if !strings.Contains(gateJob, want) { @@ -219,7 +287,456 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { } } - if strings.Count(gateJob, "path: "+cachePath) != 1 { - t.Error("green main push must save the candidate profile through the same cache path/version as baseline restore") + if strings.Count(gateJob, "path: "+cachePath) != 2 { + t.Error("green main push must save and verify the candidate profile through the same cache path/version as baseline restore") + } +} + +func TestFormulaCoverageBaselinePromotionContract(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + promotionData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-promotion.yml")) + if err != nil { + t.Fatalf("ReadFile(coverage-baseline-promotion.yml) error = %v", err) + } + promotion := string(promotionData) + for _, want := range []string{ + "repository_dispatch:", + "types: [coverage-baseline-promote]", + "checks: write", + "contents: read", + "group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}", + "cancel-in-progress: false", + "queue: max", + "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'", + "timeout-minutes: 30", + "context.payload.client_payload?.target_sha", + "context.payload.client_payload?.source_run_id", + "context.payload.client_payload?.check_run_id", + "targetCommit.parents.length !== 1", + "targetCommit.author?.login !== 'github-actions[bot]'", + "targetCommit.committer?.login !== 'github-actions[bot]'", + "files.length !== 1", + "Formula/dingtalk-workspace-cli.rb", + "Formula/dingtalk-workspace-cli-beta.rb", + "[skip ci", + "await requireSuccessfulAdmission(parentSha, 'Formula parent')", + "await requireSuccessfulAdmission(targetSha, 'Formula target')", + "for (let attempt = 1; attempt <= 6; attempt += 1)", + "setTimeout(resolve, 5000)", + "run.app?.slug !== 'github-actions'", + "run.conclusion !== 'success'", + "basehead: `${targetSha}...${branch.commit.sha}`", + "['ahead', 'identical'].includes(containment.status)", + "promotionCheck.name !== 'Coverage Baseline Cache'", + "promotionCheck.external_id !== promotionExternalId", + "promotionCheck.status !== 'queued'", + "core.setOutput('check_run_id', String(checkRunId))", + "name: Mark Formula cache promotion in progress", + "status: 'in_progress'", + "persist-credentials: false", + "ref: ${{ steps.validate-target.outputs.target_sha }}", + "test \"$(git rev-parse HEAD^)\" = \"$PARENT_SHA\"", + "id: target-cache", + "id: parent-cache", + "key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}", + "go test -count=1 -p 1", + "./ ./cmd/... ./internal/... ./skills/...", + "key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}", + "lookup-only: true", + "fail-on-cache-miss: true", + "id: formula-target-cache-verification", + "EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, + "name: Complete Formula cache promotion acknowledgement", + "PROMOTION_JOB_STATUS: ${{ job.status }}", + "conclusion: succeeded ? 'success' : 'failure'", + } { + if !strings.Contains(promotion, want) { + t.Errorf("Formula baseline promotion missing contract %q", want) + } + } + for _, want := range []string{ + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff", + "actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830", + "actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830", + } { + if !strings.Contains(promotion, want) { + t.Errorf("Formula baseline promotion must pin trusted action %q", want) + } + } + for _, forbidden := range []string{ + "pull_request:", + "pull_request_target:", + "push:", + "workflow_dispatch:", + "restore-keys", + "HOMEBREW_PR_TOKEN", + "RELEASE_GOVERNANCE_TOKEN", + "contents: write", + } { + if strings.Contains(promotion, forbidden) { + t.Errorf("Formula baseline promotion must not contain %q", forbidden) + } + } + validateIndex := strings.Index(promotion, "name: Validate Formula-only main target") + checkoutIndex := strings.Index(promotion, "name: Check out validated Formula-only target") + if validateIndex < 0 || checkoutIndex <= validateIndex { + t.Error("Formula target must be fully validated before it is checked out or executed") + } + ackReadIndex := strings.Index(promotion, "const {data: promotionCheck} = await github.rest.checks.get") + ackOutputIndex := strings.Index(promotion, "core.setOutput('check_run_id', String(checkRunId))") + targetReadIndex := strings.Index(promotion, "const {data: targetCommit} = await github.rest.repos.getCommit") + if ackReadIndex < 0 || ackOutputIndex <= ackReadIndex || targetReadIndex <= ackOutputIndex { + t.Error("Formula acknowledgement must be identity-checked and bound to the finalizer before target validation") + } + if strings.Count(promotion, "path: coverage-cache.txt") != 4 { + t.Error("Formula promotion must restore target/source, save, and verify through the shared cache version path") + } + + releaseData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("ReadFile(release.yml) error = %v", err) + } + release := string(releaseData) + publishStart := strings.Index(release, " publish-release:\n") + sealStart := strings.Index(release, " - name: Seal Formula-only Code Admission contexts\n") + if publishStart < 0 || sealStart <= publishStart { + t.Fatal("release workflow missing Formula-only seal start") + } + if !strings.Contains(release[publishStart:sealStart], "timeout-minutes: 30") { + t.Error("publish-release must not be extended by cache acknowledgement polling") + } + sealEnd := strings.Index(release[sealStart:], "\n - name: Reverify exact immutable npm package\n") + if sealEnd < 0 { + t.Fatal("release workflow missing Formula-only seal boundaries") + } + seal := release[sealStart : sealStart+sealEnd] + for _, want := range []string{ + `core.setOutput("coverage_baseline_required", "true")`, + `core.setOutput("coverage_baseline_commit", commit)`, + } { + if !strings.Contains(seal, want) { + t.Errorf("Formula seal missing cache-promotion dispatch contract %q", want) + } + } + for _, forbidden := range []string{ + "for (let attempt = 1; attempt <= 180; attempt += 1)", + "github.rest.checks.get", + "promotionComplete", + "Coverage Baseline Cache", + "github.rest.repos.createDispatchEvent", + } { + if strings.Contains(seal, forbidden) { + t.Errorf("irreversible publish-release must not wait on cache through %q", forbidden) + } + } + confirmationStart := strings.Index(release, " coverage-baseline-confirmation:\n") + deliveryGateStart := strings.Index(release, " release-delivery-gate:\n") + if confirmationStart < 0 || deliveryGateStart <= confirmationStart { + t.Fatal("release workflow missing independent Formula baseline confirmation job") + } + confirmation := release[confirmationStart:deliveryGateStart] + for _, want := range []string{ + "if (!['true', 'false'].includes(rawRequired))", + "github.rest.checks.create", + "name: 'Coverage Baseline Cache'", + "status: 'queued'", + "external_id: expectedExternalId", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-promote'", + "check_run_id: String(promotionCheck.id)", + "conclusion: 'failure'", + "for (let attempt = 1; attempt <= 180; attempt += 1)", + "github.rest.checks.get", + "currentCheck.name !== 'Coverage Baseline Cache'", + "currentCheck.conclusion !== 'success'", + "setTimeout(resolve, 10000)", + "Formula baseline promotion timed out", + } { + if !strings.Contains(confirmation, want) { + t.Errorf("independent Formula baseline confirmation missing %q", want) + } + } +} + +func TestCoverageBaselineRepairContract(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-repair.yml")) + if err != nil { + t.Fatalf("ReadFile(coverage-baseline-repair.yml) error = %v", err) + } + workflow := string(data) + + for _, want := range []string{ + "pull_request_target:", + "branches: [main]", + "types: [closed]", + "workflow_run:", + "workflows: [CI]", + "types: [completed]", + "repository_dispatch:", + "types: [coverage-baseline-repair]", + "schedule:", + `cron: "23 * * * *"`, + "workflow_dispatch:", + "group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}", + "cancel-in-progress: false", + "queue: max", + } { + if !strings.Contains(workflow, want) { + t.Errorf("coverage baseline repair missing workflow contract %q", want) + } + } + + dispatchStart := strings.Index(workflow, "\n dispatch-merged-pr:\n") + failedDispatchStart := strings.Index(workflow, "\n dispatch-failed-ci:\n") + repairStart := strings.Index(workflow, "\n repair:\n") + if dispatchStart < 0 || failedDispatchStart <= dispatchStart || repairStart <= failedDispatchStart { + t.Fatal("coverage baseline repair is missing ordered merged-PR, failed-CI, and producer jobs") + } + dispatcher := workflow[dispatchStart:failedDispatchStart] + for _, want := range []string{ + "github.event_name == 'pull_request_target'", + "github.event.pull_request.merged == true", + "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'", + "actions: read", + "contents: write", + "pull-requests: read", + "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b", + "context.payload.repository?.default_branch !== 'main'", + "currentPull.state === 'closed'", + "currentPull.merged === true", + "typeof currentPull.merged_at === 'string'", + "const baseRef = eventPull?.base?.ref;", + "baseRef !== 'main'", + "currentPull.base?.ref === 'main'", + "isStableMergedPRIdentity(", + "currentPull.head?.sha === headSha", + "currentPull.merge_commit_sha === mergeCommitSha", + "for (let attempt = 1; attempt <= 6; attempt += 1)", + "setTimeout(resolve, 5000)", + "basehead: `${targetSha}...${branch.commit.sha}`", + "['ahead', 'identical'].includes(comparison.status)", + "for (let attempt = 1; attempt <= 12; attempt += 1)", + "github.rest.actions.getWorkflow", + "workflow_id: '.github/workflows/ci.yml'", + "ciWorkflow.name !== 'CI'", + "ciWorkflow.path !== '.github/workflows/ci.yml'", + "ciWorkflow.state !== 'active'", + "github.rest.actions.listWorkflowRunsForRepo", + "branch: 'main'", + "event: 'push'", + "run.name === 'CI'", + "run.workflow_id === ciWorkflow.id", + "run.path === ciWorkflow.path", + "run.event === 'push'", + "run.head_sha === mergeCommitSha", + "run.head_branch === 'main'", + "['queued', 'in_progress', 'completed'].includes(run.status)", + "setTimeout(resolve, 5000)", + "repair dispatch is unnecessary", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-repair'", + "source: 'merged_pr'", + "pull_number: String(pullNumber)", + "head_sha: headSha", + "merge_commit_sha: mergeCommitSha", + } { + if !strings.Contains(dispatcher, want) { + t.Errorf("merged-PR repair dispatcher missing %q", want) + } + } + ciWorkflowLookup := strings.Index(dispatcher, "github.rest.actions.getWorkflow") + pushRunLookup := strings.Index(dispatcher, "github.rest.actions.listWorkflowRunsForRepo") + repairDispatch := strings.Index(dispatcher, "github.rest.repos.createDispatchEvent") + if ciWorkflowLookup < 0 || pushRunLookup <= ciWorkflowLookup || repairDispatch <= pushRunLookup { + t.Error("merged-PR dispatcher must bind the fixed CI workflow before exhausting exact push-run lookup and dispatch") + } + stableIdentityCheck := strings.Index(dispatcher, "if (!isStableMergedPRIdentity(") + mainContainmentCheck := strings.Index(dispatcher, "await requireMainContainment(mergeCommitSha)") + if stableIdentityCheck < 0 || mainContainmentCheck <= stableIdentityCheck || repairDispatch <= mainContainmentCheck { + t.Error("merged-PR dispatcher must prove stable PR identity and main containment before dispatch") + } + for _, forbidden := range []string{ + "actions/checkout@", + "actions/cache/", + "actions/setup-go@", + "go test ", + "github.event.pull_request.head.ref", + "currentPull.base.sha", + "base_sha", + "secrets.", + } { + if strings.Contains(dispatcher, forbidden) { + t.Errorf("privileged pull_request_target dispatcher must not contain %q", forbidden) + } + } + + failedDispatcher := workflow[failedDispatchStart:repairStart] + for _, want := range []string{ + "github.event_name == 'workflow_run'", + "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'", + "github.event.workflow_run.name == 'CI'", + "github.event.workflow_run.event == 'push'", + "github.event.workflow_run.head_branch == 'main'", + "github.event.workflow_run.status == 'completed'", + "github.event.workflow_run.conclusion != 'success'", + "actions: read", + "contents: write", + "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b", + "context.payload.repository?.default_branch !== 'main'", + "eventRun?.name !== 'CI'", + "eventRun?.event !== 'push'", + "eventRun?.head_branch !== 'main'", + "eventRun?.status !== 'completed'", + "conclusion === 'success'", + "github.rest.actions.getWorkflow", + "workflow_id: '.github/workflows/ci.yml'", + "github.rest.actions.getWorkflowRun", + "eventRun.workflow_id !== ciWorkflow.id", + "currentRun.workflow_id !== ciWorkflow.id", + "currentRun.name !== 'CI'", + "currentRun.event !== 'push'", + "currentRun.head_branch !== 'main'", + "currentRun.head_sha !== headSha", + "currentRun.run_attempt !== runAttempt", + "currentRun.status !== 'completed'", + "currentRun.conclusion !== conclusion", + "currentRun.repository?.full_name !== upstream", + "currentRun.head_repository?.full_name !== upstream", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-repair'", + "source: 'failed_ci'", + "workflow_run_id: String(runID)", + "workflow_run_attempt: String(runAttempt)", + "workflow_conclusion: conclusion", + "merge_commit_sha: headSha", + } { + if !strings.Contains(failedDispatcher, want) { + t.Errorf("failed-CI repair dispatcher missing %q", want) + } + } + failedRunRead := strings.Index(failedDispatcher, "github.rest.actions.getWorkflowRun") + failedRepairDispatch := strings.Index(failedDispatcher, "github.rest.repos.createDispatchEvent") + if failedRunRead < 0 || failedRepairDispatch <= failedRunRead { + t.Error("workflow_run dispatcher must API-bind the exact failed CI run before repository dispatch") + } + for _, forbidden := range []string{ + "actions/checkout@", + "actions/cache/", + "actions/setup-go@", + "go test ", + "secrets.", + } { + if strings.Contains(failedDispatcher, forbidden) { + t.Errorf("privileged workflow_run dispatcher must not contain %q", forbidden) + } + } + + producer := workflow[repairStart:] + for _, want := range []string{ + "github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'", + "timeout-minutes: 35", + "actions: read", + "contents: read", + "pull-requests: read", + "id: resolve-target", + "context.eventName === 'repository_dispatch'", + "payload.source === 'merged_pr'", + "payload.pull_number", + "payload.head_sha", + "payload.merge_commit_sha", + "currentPull.state === 'closed'", + "currentPull.merged === true", + "typeof currentPull.merged_at === 'string'", + "currentPull.base?.ref === 'main'", + "isStableMergedPRIdentity(", + "currentPull.head?.sha === headSha", + "currentPull.merge_commit_sha === mergeCommitSha", + "payload.source === 'failed_ci'", + "payload.workflow_run_id", + "payload.workflow_run_attempt", + "payload.workflow_conclusion", + "github.rest.actions.getWorkflow", + "workflow_id: '.github/workflows/ci.yml'", + "github.rest.actions.getWorkflowRun", + "ciWorkflow.name !== 'CI'", + "ciWorkflow.path !== '.github/workflows/ci.yml'", + "currentRun.id !== workflowRunID", + "currentRun.workflow_id !== ciWorkflow.id", + "currentRun.name !== 'CI'", + "currentRun.event !== 'push'", + "currentRun.head_branch !== 'main'", + "currentRun.head_sha !== targetSha", + "currentRun.run_attempt !== workflowRunAttempt", + "currentRun.status !== 'completed'", + "currentRun.conclusion !== workflowConclusion", + "coverage-baseline-repair payload has an unknown source", + "context.ref !== 'refs/heads/main'", + "targetSha = context.sha", + "for (let attempt = 1; attempt <= 6; attempt += 1)", + "basehead: `${targetSha}...${branch.commit.sha}`", + "core.setOutput('target_sha', targetSha)", + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + "fetch-depth: 0", + "persist-credentials: false", + "ref: ${{ steps.resolve-target.outputs.target_sha }}", + `run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"`, + "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff", + "id: target-cache", + "actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830", + "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}", + "go test -count=1 -p 1", + "./ ./cmd/... ./internal/... ./skills/...", + "actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830", + "id: target-cache-verification", + "lookup-only: true", + "fail-on-cache-miss: true", + "EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, + } { + if !strings.Contains(producer, want) { + t.Errorf("trusted coverage repair producer missing %q", want) + } + } + for _, forbidden := range []string{ + "restore-keys", + "github.event.pull_request.head.ref", + "currentPull.base.sha", + "base_sha", + "HOMEBREW_PR_TOKEN", + "RELEASE_GOVERNANCE_TOKEN", + "REVIEWER_ROUTER_APP_PRIVATE_KEY", + "contents: write", + } { + if strings.Contains(producer, forbidden) { + t.Errorf("trusted coverage repair producer must not contain %q", forbidden) + } + } + if strings.Count(producer, "path: coverage-cache.txt") != 3 { + t.Error("coverage repair must restore, save, and independently verify one exact cache path/version") + } + if strings.Count(producer, "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}") != 3 { + t.Error("coverage repair restore, save, and verification must share one exact target key") + } + stablePayloadIdentityCheck := strings.Index(producer, "if (!isStableMergedPRIdentity(") + mainPayloadContainmentCheck := strings.Index(producer, "await requireMainContainment(targetSha)") + resolvedTargetOutput := strings.Index(producer, "core.setOutput('target_sha', targetSha)") + if stablePayloadIdentityCheck < 0 || + mainPayloadContainmentCheck <= stablePayloadIdentityCheck || + resolvedTargetOutput <= mainPayloadContainmentCheck { + t.Error("merged-PR producer must prove stable payload identity and main containment before resolving checkout target") + } + validateIndex := strings.Index(producer, "name: Resolve trusted main repair target") + checkoutIndex := strings.Index(producer, "name: Check out exact protected-main target") + if validateIndex < 0 || checkoutIndex <= validateIndex { + t.Error("repository_dispatch input must be fully bound to its protected-main source before checkout") } } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 73798dcd..ffb514ad 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -3,17 +3,22 @@ package scripts_test import ( "archive/tar" "archive/zip" + "bytes" "compress/gzip" "crypto/sha256" "fmt" + "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "sort" + "strconv" "strings" + "syscall" "testing" + "time" ) var expectedPackagedSkillTargets = []string{ @@ -93,6 +98,350 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { } } +func TestNPMWrapperForwardsSIGTERMToVendor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX signal forwarding contract") + } + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to exercise the npm wrapper") + } + + root := t.TempDir() + binDir := filepath.Join(root, "bin") + vendorDir := filepath.Join(root, "vendor") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(vendorDir, 0o755); err != nil { + t.Fatal(err) + } + + wrapperSource := filepath.Join("..", "..", "build", "npm", "bin", "dws.js") + wrapperData, err := os.ReadFile(wrapperSource) + if err != nil { + t.Fatal(err) + } + wrapperPath := filepath.Join(binDir, "dws.js") + if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil { + t.Fatal(err) + } + + pidPath := filepath.Join(root, "child.pid") + signalPath := filepath.Join(root, "child.signal") + vendorPath := filepath.Join(vendorDir, "dws") + vendorScript := "#!/bin/sh\n" + + "printf '%s' \"$$\" > \"$DWS_TEST_CHILD_PID_FILE\"\n" + + "trap 'printf TERM > \"$DWS_TEST_SIGNAL_FILE\"; exit 0' TERM\n" + + "while :; do sleep 0.1; done\n" + if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(nodePath, wrapperPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_CHILD_PID_FILE="+pidPath, + "DWS_TEST_SIGNAL_FILE="+signalPath, + ) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + childPID := waitForPIDFile(t, pidPath) + t.Cleanup(func() { + _ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run() + }) + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("signal npm wrapper: %v", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err == nil { + t.Fatal("signal-terminated npm wrapper returned success") + } + case <-time.After(5 * time.Second): + t.Fatal("npm wrapper did not exit after SIGTERM") + } + + signalData, err := os.ReadFile(signalPath) + if err != nil { + t.Fatalf("vendor did not record SIGTERM: %v", err) + } + if got := strings.TrimSpace(string(signalData)); got != "TERM" { + t.Fatalf("vendor signal = %q, want TERM", got) + } + if err := exec.Command("kill", "-0", strconv.Itoa(childPID)).Run(); err == nil { + t.Fatalf("vendor process %d is still running after npm wrapper exited", childPID) + } +} + +func TestNPMWrapperForwardsForegroundGroupSIGINTToVendorGroupOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX foreground process-group signal contract") + } + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to exercise the npm wrapper") + } + + root := t.TempDir() + binDir := filepath.Join(root, "bin") + vendorDir := filepath.Join(root, "vendor") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(vendorDir, 0o755); err != nil { + t.Fatal(err) + } + + wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js")) + if err != nil { + t.Fatal(err) + } + wrapperPath := filepath.Join(binDir, "dws.js") + if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil { + t.Fatal(err) + } + + pidPath := filepath.Join(root, "child.pid") + signalPath := filepath.Join(root, "child.signal") + descendantPIDPath := filepath.Join(root, "descendant.pid") + descendantSignalPath := filepath.Join(root, "descendant.signal") + vendorPath := filepath.Join(vendorDir, "dws") + vendorScript := `#!/usr/bin/env node +"use strict"; +const fs = require("fs"); +const childProcess = require("child_process"); +const descendantSource = String.raw` + "`" + ` +"use strict"; +const fs = require("fs"); +let exiting = false; +process.on("SIGINT", () => { + fs.appendFileSync(process.env.DWS_TEST_DESCENDANT_SIGNAL_FILE, "INT\n"); + if (!exiting) { + exiting = true; + setTimeout(() => process.exit(0), 250); + } +}); +fs.writeFileSync(process.env.DWS_TEST_DESCENDANT_PID_FILE, String(process.pid)); +setInterval(() => {}, 1000); +` + "`" + `; +childProcess.spawn(process.execPath, ["-e", descendantSource], { + stdio: "inherit", + env: process.env, +}); +let exiting = false; +process.on("SIGINT", () => { + fs.appendFileSync(process.env.DWS_TEST_SIGNAL_FILE, "INT\n"); + if (!exiting) { + exiting = true; + setTimeout(() => process.exit(0), 250); + } +}); +fs.writeFileSync(process.env.DWS_TEST_CHILD_PID_FILE, String(process.pid)); +setInterval(() => {}, 1000); +` + if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(nodePath, wrapperPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_CHILD_PID_FILE="+pidPath, + "DWS_TEST_SIGNAL_FILE="+signalPath, + "DWS_TEST_DESCENDANT_PID_FILE="+descendantPIDPath, + "DWS_TEST_DESCENDANT_SIGNAL_FILE="+descendantSignalPath, + ) + configureIsolatedProcessGroup(cmd) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + childPID := waitForPIDFile(t, pidPath) + descendantPID := waitForPIDFile(t, descendantPIDPath) + t.Cleanup(func() { + _ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run() + _ = exec.Command("kill", "-TERM", strconv.Itoa(descendantPID)).Run() + }) + wrapperGroup, err := processGroupID(cmd.Process.Pid) + if err != nil { + t.Fatalf("wrapper process group: %v", err) + } + childGroup, err := processGroupID(childPID) + if err != nil { + t.Fatalf("vendor process group: %v", err) + } + if childGroup == wrapperGroup { + t.Fatalf("vendor process group = wrapper process group %d; foreground signal would be duplicated", childGroup) + } + descendantGroup, err := processGroupID(descendantPID) + if err != nil { + t.Fatalf("descendant process group: %v", err) + } + if descendantGroup != childGroup { + t.Fatalf("descendant process group = %d, want vendor process group %d", descendantGroup, childGroup) + } + + if err := signalProcessGroup(wrapperGroup, syscall.SIGINT); err != nil { + t.Fatalf("signal foreground process group: %v", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err == nil { + t.Fatal("signal-terminated npm wrapper returned success") + } + case <-time.After(5 * time.Second): + t.Fatal("npm wrapper did not exit after foreground SIGINT") + } + + signalData, err := os.ReadFile(signalPath) + if err != nil { + t.Fatalf("vendor did not record SIGINT: %v", err) + } + if got := strings.Fields(string(signalData)); len(got) != 1 || got[0] != "INT" { + t.Fatalf("vendor signals = %q, want exactly one INT", got) + } + descendantSignalData, err := os.ReadFile(descendantSignalPath) + if err != nil { + t.Fatalf("vendor descendant did not record SIGINT: %v", err) + } + if got := strings.Fields(string(descendantSignalData)); len(got) != 1 || got[0] != "INT" { + t.Fatalf("vendor descendant signals = %q, want exactly one INT", got) + } + waitForProcessExit(t, childPID, "vendor") + waitForProcessExit(t, descendantPID, "vendor descendant") +} + +func TestNPMWrapperPreservesInteractiveTTY(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX controlling-terminal contract") + } + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to exercise the npm wrapper") + } + + root := t.TempDir() + binDir := filepath.Join(root, "bin") + vendorDir := filepath.Join(root, "vendor") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(vendorDir, 0o755); err != nil { + t.Fatal(err) + } + + wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js")) + if err != nil { + t.Fatal(err) + } + wrapperPath := filepath.Join(binDir, "dws.js") + if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil { + t.Fatal(err) + } + + resultPath := filepath.Join(root, "interactive.result") + readyPath := filepath.Join(root, "interactive.ready") + vendorPath := filepath.Join(vendorDir, "dws") + vendorScript := "#!/bin/sh\n" + + "printf 'DWS prompt: ' > /dev/tty\n" + + "printf ready > \"$DWS_TEST_INTERACTIVE_READY\"\n" + + "IFS= read -r answer < /dev/tty\n" + + "printf '%s' \"$answer\" > \"$DWS_TEST_INTERACTIVE_RESULT\"\n" + if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(nodePath, wrapperPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_INTERACTIVE_RESULT="+resultPath, + "DWS_TEST_INTERACTIVE_READY="+readyPath, + ) + terminal, err := startWithPTY(cmd) + if err != nil { + t.Fatalf("start npm wrapper with PTY: %v", err) + } + defer terminal.Close() + t.Cleanup(func() { _ = cmd.Process.Kill() }) + var output bytes.Buffer + outputDone := make(chan struct{}) + go func() { + _, _ = io.Copy(&output, terminal) + close(outputDone) + }() + waitForFile(t, readyPath) + if _, err := fmt.Fprintln(terminal, "confirmed"); err != nil { + t.Fatalf("write interactive input: %v", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var waitErr error + select { + case waitErr = <-done: + case <-time.After(5 * time.Second): + t.Fatal("interactive npm wrapper did not exit after terminal input") + } + _ = terminal.Close() + <-outputDone + if waitErr != nil { + t.Fatalf("interactive npm wrapper: %v\noutput:\n%s", waitErr, output.String()) + } + result, err := os.ReadFile(resultPath) + if err != nil { + t.Fatalf("interactive vendor did not record terminal input: %v\noutput:\n%s", err, output.String()) + } + if got := strings.TrimSpace(string(result)); got != "confirmed" { + t.Fatalf("interactive input = %q, want confirmed\noutput:\n%s", got, output.String()) + } +} + +func waitForPIDFile(t *testing.T, path string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil { + pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data))) + if parseErr == nil && pid > 0 { + return pid + } + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("timed out waiting for vendor pid file %s", path) + return 0 +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("timed out waiting for file %s", path) +} + +func waitForProcessExit(t *testing.T, pid int, label string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if err := exec.Command("kill", "-0", strconv.Itoa(pid)).Run(); err != nil { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("%s process %d is still running after npm wrapper exited", label, pid) +} + func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) { t.Parallel() @@ -2082,6 +2431,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { "- verify-darwin-signatures", "- publish-release", "- publish-channels", + "- coverage-baseline-confirmation", "- mirror-gitee-release", "- repair-npm", "- repair-channel", @@ -2091,6 +2441,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { `RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}`, `RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`, `SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`, + `COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}`, "require_publication", `require_result release-contract "$RELEASE_CONTRACT_RESULT" success`, `require_result release-validation "$RELEASE_VALIDATION_RESULT" success`, @@ -2098,6 +2449,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { `require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`, `require_result publish-release "$PUBLISH_RELEASE_RESULT" success`, `require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`, + `require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success`, + `require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped`, "workflow_dispatch:recover_release", "workflow_dispatch:create_release", "workflow_dispatch:plan_release", @@ -2438,8 +2791,12 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } for _, required := range []string{ + "timeout-minutes: 30", + `coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}`, + `coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}`, "id: homebrew-stable", "id: homebrew-beta", + "id: seal-formula", `FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`, `FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`, } { @@ -2481,6 +2838,8 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { `head_sha: commit`, `status: "completed"`, `conclusion: "success"`, + `core.setOutput("coverage_baseline_required", "true")`, + `core.setOutput("coverage_baseline_commit", commit)`, } { if !strings.Contains(seal, required) { t.Errorf("Formula-only Code Admission sealing is missing %q", required) @@ -2492,12 +2851,17 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } } if strings.Count(seal, "github.rest.checks.create") != 1 { - t.Error("Formula-only checks must be created only by the single verified context loop") + t.Error("Formula sealing must write only the reviewed Code Admission contexts") } for _, forbidden := range []string{ "head_sha: context.sha", "head_sha: parent", "head_sha: branch.data.commit.sha", + "github.rest.checks.get", + "promotionComplete", + "setTimeout(resolve, 10000)", + "Coverage Baseline Cache", + "github.rest.repos.createDispatchEvent", } { if strings.Contains(seal, forbidden) { t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden) @@ -2505,6 +2869,9 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } createCheck := strings.Index(seal, "github.rest.checks.create") + if createCheck == -1 { + t.Error("Formula sealing must create the verified Code Admission contexts") + } for name, marker := range map[string]string{ "single-parent Formula-only identity": "const exactFormulaCommit", "successful parent contexts": "invalidParentContexts.length > 0", @@ -2518,6 +2885,67 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } } +func TestReleaseWorkflowConfirmsFormulaCacheWithoutBlockingChannels(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n") + channelsJob := releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n") + confirmation := releaseWorkflowSection( + t, + workflow, + " coverage-baseline-confirmation:\n", + "\n release-delivery-gate:\n", + ) + + for _, required := range []string{ + `if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}`, + "needs: publish-release", + "timeout-minutes: 35", + "checks: write", + "contents: write", + `BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}`, + `FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}`, + "if (!['true', 'false'].includes(rawRequired))", + "Formula baseline requirement is invalid", + "github.rest.checks.create", + "name: 'Coverage Baseline Cache'", + "status: 'queued'", + "external_id: expectedExternalId", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-promote'", + "source_run_id: String(context.runId)", + "check_run_id: String(promotionCheck.id)", + "github.rest.checks.update", + "conclusion: 'failure'", + "github.rest.checks.get", + "currentCheck.head_sha !== targetSha", + "currentCheck.external_id !== expectedExternalId", + "currentCheck.app?.slug !== 'github-actions'", + "for (let attempt = 1; attempt <= 180; attempt += 1)", + "currentCheck.conclusion !== 'success'", + "await new Promise(resolve => setTimeout(resolve, 10000))", + "Formula baseline promotion timed out", + } { + if !strings.Contains(confirmation, required) { + t.Errorf("Formula baseline confirmation missing %q", required) + } + } + if strings.Contains(channelsJob, "coverage-baseline-confirmation") || + strings.Contains(channelsJob, "needs.coverage-baseline-confirmation") { + t.Error("cache acknowledgement must not block npm or mirror publication") + } + for _, forbidden := range []string{ + "Coverage Baseline Cache", + "github.rest.repos.createDispatchEvent", + "github.rest.checks.get", + "promotionComplete", + } { + if strings.Contains(publishJob, forbidden) { + t.Errorf("irreversible publication job must not own cache lifecycle marker %q", forbidden) + } + } +} + func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) { workflow := readReleaseWorkflow(t) script := releaseWorkflowRunScript( diff --git a/test/scripts/package_signal_unix_test.go b/test/scripts/package_signal_unix_test.go new file mode 100644 index 00000000..1e531c26 --- /dev/null +++ b/test/scripts/package_signal_unix_test.go @@ -0,0 +1,27 @@ +//go:build !windows + +package scripts_test + +import ( + "os" + "os/exec" + "syscall" + + "github.com/creack/pty" +) + +func configureIsolatedProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func processGroupID(pid int) (int, error) { + return syscall.Getpgid(pid) +} + +func signalProcessGroup(groupID int, signal syscall.Signal) error { + return syscall.Kill(-groupID, signal) +} + +func startWithPTY(cmd *exec.Cmd) (*os.File, error) { + return pty.Start(cmd) +} diff --git a/test/scripts/package_signal_windows_test.go b/test/scripts/package_signal_windows_test.go new file mode 100644 index 00000000..c2660498 --- /dev/null +++ b/test/scripts/package_signal_windows_test.go @@ -0,0 +1,24 @@ +//go:build windows + +package scripts_test + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func configureIsolatedProcessGroup(*exec.Cmd) {} + +func processGroupID(int) (int, error) { + return 0, errors.New("POSIX process groups are unavailable on Windows") +} + +func signalProcessGroup(int, syscall.Signal) error { + return errors.New("POSIX process groups are unavailable on Windows") +} + +func startWithPTY(*exec.Cmd) (*os.File, error) { + return nil, errors.New("POSIX pseudo terminals are unavailable on Windows") +} diff --git a/test/scripts/reviewer_router_merge_defaults_projection_test.go b/test/scripts/reviewer_router_merge_defaults_projection_test.go new file mode 100644 index 00000000..1f201b3b --- /dev/null +++ b/test/scripts/reviewer_router_merge_defaults_projection_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const mergeDefaultsProjectionHelper = `function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; +}` + +const privilegedMergeDefaultsGuard = `if (mergeDefaultsProjection !== 'reviewed') { + throw new Error( + 'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.', + ); +}` + +func TestReviewerRouterMergeDefaultsProjection(t *testing.T) { + t.Parallel() + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to verify merge-default projection semantics") + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + + var scripts []string + for _, relativePath := range []string{ + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join(".github", "workflows", "reviewer-router.yml"), + } { + path := filepath.Join(root, relativePath) + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read %s: %v", path, readErr) + } + var workflow any + if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil { + t.Fatalf("parse %s: %v", path, unmarshalErr) + } + collectGitHubScripts(workflow, &scripts) + } + + checked := 0 + readOnlyChecks := 0 + privilegedChecks := 0 + for _, script := range scripts { + if strings.Count(script, mergeDefaultsProjectionHelper) != 1 { + continue + } + checked++ + for _, marker := range []string{ + "github.rest.repos.get", + "const mergeDefaultsProjection = classifyMergeDefaults(repository);", + } { + if !strings.Contains(script, marker) { + t.Errorf("merge-default script does not enforce marker %q", marker) + } + } + if strings.Contains(script, "MINTED_REVIEWER_ROUTER_APP_SLUG") { + privilegedChecks++ + for _, marker := range []string{ + privilegedMergeDefaultsGuard, + } { + if !strings.Contains(script, marker) { + t.Errorf("privileged merge-default script missing marker %q", marker) + } + } + guardIndex := strings.Index(script, privilegedMergeDefaultsGuard) + guardEndIndex := guardIndex + len(privilegedMergeDefaultsGuard) + firstMutationIndex := len(script) + for _, mutation := range []string{ + "disablePullRequestAutoMerge", + "enablePullRequestAutoMerge", + } { + if index := strings.Index(script, mutation); index >= 0 && index < firstMutationIndex { + firstMutationIndex = index + } + } + if guardIndex < 0 || firstMutationIndex == len(script) || guardEndIndex >= firstMutationIndex { + t.Error("privileged merge-default validation must precede every auto-merge mutation") + } + continue + } + readOnlyChecks++ + for _, marker := range []string{ + "mergeDefaultsProjection === 'invalid'", + "mergeDefaultsProjection === 'omitted'", + "Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.", + } { + if !strings.Contains(script, marker) { + t.Errorf("read-only merge-default script missing marker %q", marker) + } + } + if strings.Contains(script, "mergeDefaultsProjection !== 'reviewed'") { + t.Error("read-only CI must distinguish exact dual omission from malformed projections") + } + } + if checked != 3 || readOnlyChecks != 1 || privilegedChecks != 2 { + t.Fatalf( + "merge-default scripts checked=%d read-only=%d privileged=%d, want 3/1/2", + checked, + readOnlyChecks, + privilegedChecks, + ) + } + + verification := mergeDefaultsProjectionHelper + ` +const cases = [ + ['reviewed PR title', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_TITLE'}, 'reviewed'], + ['reviewed blank body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'BLANK'}, 'reviewed'], + ['dual omission', {}, 'omitted'], + ['null repository', null, 'invalid'], + ['array repository', [], 'invalid'], + ['title only', {merge_commit_title: 'MERGE_MESSAGE'}, 'invalid'], + ['message only', {merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['own undefined title', {merge_commit_title: undefined, merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['own undefined message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: undefined}, 'invalid'], + ['null title', {merge_commit_title: null, merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['null message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: null}, 'invalid'], + ['unsafe title', {merge_commit_title: 'PR_TITLE', merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['unsafe body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_BODY'}, 'invalid'], +]; +for (const [name, repository, want] of cases) { + const got = classifyMergeDefaults(repository); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} +` + command := exec.Command(node, "-e", verification) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("merge-default projection verification failed: %v\n%s", runErr, output) + } +} diff --git a/test/scripts/reviewer_router_ruleset_projection_test.go b/test/scripts/reviewer_router_ruleset_projection_test.go new file mode 100644 index 00000000..2b9457af --- /dev/null +++ b/test/scripts/reviewer_router_ruleset_projection_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const strictUpdateRuleHelper = `function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); + return ( + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); +}` + +const strictGraphQLUpdateRuleHelper = `function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false + ); +}` + +func TestReviewerRouterAcceptsGitHubStrictUpdateReadProjection(t *testing.T) { + t.Parallel() + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to verify the ruleset projection helper") + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + + var scripts []string + for _, relativePath := range []string{ + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join(".github", "workflows", "reviewer-router.yml"), + } { + path := filepath.Join(root, relativePath) + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read %s: %v", path, readErr) + } + var workflow any + if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil { + t.Fatalf("parse %s: %v", path, unmarshalErr) + } + collectGitHubScripts(workflow, &scripts) + } + + checked := 0 + for _, script := range scripts { + if !strings.Contains(script, "const writerRuleset = writerRulesets[0];") { + continue + } + checked++ + if got := strings.Count(script, strictUpdateRuleHelper); got != 1 { + t.Errorf("writer-ruleset script contains strict update helper %d times, want 1", got) + } + if got := strings.Count(script, strictGraphQLUpdateRuleHelper); got != 1 { + t.Errorf("writer-ruleset script contains GraphQL strict update helper %d times, want 1", got) + } + if strings.Contains( + script, + "parameters?.update_allows_fetch_and_merge !== false", + ) { + t.Error("writer-ruleset script rejects GitHub's omitted strict-false read projection") + } + if !strings.Contains(script, "!isStrictUpdateRule(writerRuleset.rules[0])") { + t.Error("writer-ruleset script does not enforce the strict update helper") + } + for _, marker := range []string{ + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "rules(first: 2)", + "updateAllowsFetchAndMerge", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", + } { + if !strings.Contains(script, marker) { + t.Errorf("writer-ruleset script does not enforce GraphQL marker %q", marker) + } + } + } + if checked != 3 { + t.Fatalf("writer-ruleset scripts checked = %d, want App enable, reconcile, and CI self-check", checked) + } + + verification := strictUpdateRuleHelper + "\n" + strictGraphQLUpdateRuleHelper + ` +const cases = [ + ['omitted parameters', {type: 'update'}, true], + ['explicit false', {type: 'update', parameters: {update_allows_fetch_and_merge: false}}, true], + ['null parameters', {type: 'update', parameters: null}, false], + ['empty parameters', {type: 'update', parameters: {}}, false], + ['boolean parameters', {type: 'update', parameters: false}, false], + ['string parameters', {type: 'update', parameters: 'malformed'}, false], + ['array parameters', {type: 'update', parameters: []}, false], + ['explicit true', {type: 'update', parameters: {update_allows_fetch_and_merge: true}}, false], + ['null field', {type: 'update', parameters: {update_allows_fetch_and_merge: null}}, false], + ['string false', {type: 'update', parameters: {update_allows_fetch_and_merge: 'false'}}, false], + ['numeric false', {type: 'update', parameters: {update_allows_fetch_and_merge: 0}}, false], + ['extra parameter', {type: 'update', parameters: {update_allows_fetch_and_merge: false, future_exception: false}}, false], + ['wrong rule type', {type: 'creation'}, false], + ['null rule', null, false], +]; +for (const [name, rule, want] of cases) { + const got = isStrictUpdateRule(rule); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} + +const restRuleset = {id: 21363804, name: 'main-merge-writers'}; +const graphRule = { + type: 'UPDATE', + parameters: { + __typename: 'UpdateParameters', + updateAllowsFetchAndMerge: false, + }, +}; +const graphRuleset = { + databaseId: 21363804, + name: 'main-merge-writers', + enforcement: 'ACTIVE', + target: 'BRANCH', + rules: {totalCount: 1, nodes: [graphRule]}, +}; +const graphCases = [ + ['valid GraphQL projection', restRuleset, graphRuleset, true], + ['invalid REST id', {id: 0, name: 'main-merge-writers'}, graphRuleset, false], + ['mismatched GraphQL id', restRuleset, {...graphRuleset, databaseId: 7}, false], + ['mismatched name', restRuleset, {...graphRuleset, name: 'other'}, false], + ['inactive ruleset', restRuleset, {...graphRuleset, enforcement: 'EVALUATE'}, false], + ['wrong target', restRuleset, {...graphRuleset, target: 'TAG'}, false], + ['extra rule', restRuleset, {...graphRuleset, rules: {totalCount: 2, nodes: [graphRule, graphRule]}}, false], + ['wrong rule type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, type: 'CREATION'}]}}, false], + ['wrong parameter type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, __typename: 'PullRequestParameters'}}]}}, false], + ['fetch and merge enabled', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, updateAllowsFetchAndMerge: true}}]}}, false], + ['missing GraphQL node', restRuleset, null, false], +]; +for (const [name, rest, graph, want] of graphCases) { + const got = isStrictGraphQLUpdateRule(rest, graph); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} +` + command := exec.Command(node, "-e", verification) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("strict update projection verification failed: %v\n%s", runErr, output) + } +} diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index 61fe102a..e33345e5 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -16,6 +16,7 @@ import ( type reviewerRouterWorkflow struct { On map[string]reviewerRouterTrigger `yaml:"on"` Permissions map[string]string `yaml:"permissions"` + Concurrency map[string]string `yaml:"concurrency"` Jobs map[string]reviewerRouterJob `yaml:"jobs"` } @@ -25,12 +26,16 @@ type reviewerRouterTrigger struct { } type reviewerRouterJob struct { - If string `yaml:"if"` - Steps []reviewerRouterStep `yaml:"steps"` + If string `yaml:"if"` + Permissions map[string]string `yaml:"permissions"` + Steps []reviewerRouterStep `yaml:"steps"` } type reviewerRouterStep struct { + ID string `yaml:"id"` + Name string `yaml:"name"` Uses string `yaml:"uses"` + Env map[string]string `yaml:"env"` With map[string]string `yaml:"with"` } @@ -68,8 +73,8 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { } } - if len(workflow.On) != 1 { - t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On) + if len(workflow.On) != 3 { + t.Fatalf("workflow triggers = %v, want pull_request_target plus protected-main reconciliation", workflow.On) } trigger, ok := workflow.On["pull_request_target"] if !ok { @@ -78,47 +83,108 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { if wantBranches := []string{"main"}; !reflect.DeepEqual(trigger.Branches, wantBranches) { t.Fatalf("pull_request_target branches = %v, want %v", trigger.Branches, wantBranches) } - wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review"} + wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review", "edited", "auto_merge_enabled"} if !reflect.DeepEqual(trigger.Types, wantTypes) { t.Fatalf("pull_request_target types = %v, want %v", trigger.Types, wantTypes) } + if _, ok := workflow.On["workflow_dispatch"]; !ok { + t.Fatalf("workflow triggers = %v, want workflow_dispatch reconciliation", workflow.On) + } + push, ok := workflow.On["push"] + if !ok || !reflect.DeepEqual(push.Branches, []string{"main"}) { + t.Fatalf("push trigger = %v, want protected main only", push) + } wantPermissions := map[string]string{ - "contents": "write", + "contents": "read", "pull-requests": "write", } if !reflect.DeepEqual(workflow.Permissions, wantPermissions) { t.Fatalf("workflow permissions = %v, want exactly %v", workflow.Permissions, wantPermissions) } + wantConcurrency := map[string]string{ + "group": "reviewer-router-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || 'reconcile-main' }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request_target' }}", + } + if !reflect.DeepEqual(workflow.Concurrency, wantConcurrency) { + t.Fatalf("workflow concurrency = %v, want serialized reconciliation and cancellable PR events %v", workflow.Concurrency, wantConcurrency) + } - if len(workflow.Jobs) != 1 { - t.Fatalf("workflow jobs = %v, want one isolated routing job", workflow.Jobs) + if len(workflow.Jobs) != 3 { + t.Fatalf("workflow jobs = %v, want event routing plus controlled reconciliation", workflow.Jobs) } job, ok := workflow.Jobs["route"] if !ok { t.Fatalf("workflow jobs = %v, want route", workflow.Jobs) } - if job.If != "github.event.pull_request.draft == false" { + if job.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" { t.Fatalf("route.if = %q, want non-draft guard", job.If) } const checkoutSHA = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b" - if len(job.Steps) != 2 || job.Steps[0].Uses != checkoutSHA || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[0].With["persist-credentials"] != "false" || job.Steps[1].Uses != githubScriptSHA { - t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps) + const appTokenSHA = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" + if len(job.Steps) != 2 || + job.Steps[0].Uses != checkoutSHA || + job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || + job.Steps[0].With["persist-credentials"] != "false" || + job.Steps[1].Uses != githubScriptSHA { + t.Fatalf("route steps = %#v, want trusted base checkout followed by routing only", job.Steps) } - script := job.Steps[1].With["script"] + autoMergeJob, ok := workflow.Jobs["manage-auto-merge"] + if !ok { + t.Fatalf("workflow jobs = %v, want manage-auto-merge", workflow.Jobs) + } + if autoMergeJob.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" { + t.Fatalf("manage-auto-merge.if = %q, want exact ready event guard", autoMergeJob.If) + } + wantAutoPermissions := map[string]string{ + "contents": "write", + "pull-requests": "write", + } + if !reflect.DeepEqual(autoMergeJob.Permissions, wantAutoPermissions) { + t.Fatalf("manage-auto-merge permissions = %v, want exactly %v", autoMergeJob.Permissions, wantAutoPermissions) + } + if len(autoMergeJob.Steps) != 3 || + autoMergeJob.Steps[0].Uses != githubScriptSHA || + autoMergeJob.Steps[1].ID != "reviewer-router-token" || + autoMergeJob.Steps[1].Uses != appTokenSHA || + autoMergeJob.Steps[2].Uses != githubScriptSHA { + t.Fatalf("manage-auto-merge steps = %#v, want unsafe-owner cleanup before dedicated App enable", autoMergeJob.Steps) + } + + appToken := autoMergeJob.Steps[1] + if len(appToken.With) != 6 { + t.Fatalf("reviewer-router token inputs = %v, want exactly the reviewed repository scope and two permissions", appToken.With) + } + for key, want := range map[string]string{ + "client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}", + "private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-contents": "write", + "permission-pull-requests": "write", + } { + if got := appToken.With[key]; got != want { + t.Errorf("reviewer-router token input %q = %q, want %q", key, got, want) + } + } + if _, ok := appToken.With["skip-token-revoke"]; ok { + t.Error("reviewer-router App token must be revoked automatically after the job") + } + + routingScript := job.Steps[1].With["script"] for _, want := range []string{ "REVIEWER_POOL", "resolveReviewRouting", "github.rest.pulls.get", "github.rest.pulls.listFiles", "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", "currentPull.state !== 'open'", "currentPull.draft", "currentPull.base.ref !== 'main'", "getReadyEventPull('review request')", - "getReadyEventPull('auto-merge enable')", "context.payload.action === 'synchronize'", "context.payload.sender?.login?.toLowerCase()", "reviewer.toLowerCase() !== author", @@ -142,22 +208,441 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "github.rest.pulls.requestReviewers", "trying the next candidate", "Reviewer routing hit an unexpected error", - "enablePullRequestAutoMerge", - "mergeMethod: MERGE", "core.warning", } { - if !strings.Contains(script, want) { + if !strings.Contains(routingScript, want) { t.Errorf("reviewer router script is missing contract marker %q", want) } } + for _, forbidden := range []string{ + "enablePullRequestAutoMerge", + "disablePullRequestAutoMerge", + "mergeMethod: MERGE", + } { + if strings.Contains(routingScript, forbidden) { + t.Errorf("built-in routing token must not control auto-merge through %q", forbidden) + } + } + + cleanupStep := autoMergeJob.Steps[0] + if _, ok := cleanupStep.With["github-token"]; ok { + t.Error("unsafe-owner cleanup must use only the job-scoped built-in token") + } + cleanupScript := cleanupStep.With["script"] + for _, want := range []string{ + "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "const unsafeOwner = 'github-actions[bot]'", + "const skipWorkflowPattern =", + "currentPull.title", + "currentPull.auto_merge?.commit_title", + "currentPull.auto_merge?.commit_message", + "const skipRequested =", + "enabledBy !== unsafeOwner", + "disablePullRequestAutoMerge", + "cleanedPull.auto_merge", + "Cleared workflow-skipping auto-merge metadata", + "throw new Error", + } { + if !strings.Contains(cleanupScript, want) { + t.Errorf("unsafe-owner cleanup is missing contract marker %q", want) + } + } + if strings.Contains(cleanupScript, "enablePullRequestAutoMerge") { + t.Error("built-in cleanup token must never enable auto-merge") + } + if got := strings.Count(cleanupScript, "const skipWorkflowPattern ="); got != 1 { + t.Errorf("unsafe-request cleanup skip-workflow pattern declarations = %d, want exactly 1", got) + } + + autoMergeStep := autoMergeJob.Steps[2] + if got, want := autoMergeStep.With["github-token"], "${{ steps.reviewer-router-token.outputs.token }}"; got != want { + t.Fatalf("auto-merge github-token = %q, want %q", got, want) + } + if got, want := autoMergeStep.Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-token.outputs.app-slug }}"; got != want { + t.Fatalf("minted auto-merge App slug = %q, want %q", got, want) + } + if got, want := autoMergeStep.Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want { + t.Fatalf("configured auto-merge App slug = %q, want %q", got, want) + } + autoMergeScript := autoMergeStep.With["script"] + for _, want := range []string{ + "github.rest.pulls.get", + "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "getReadyEventPull('auto-merge enable')", + "process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim()", + "process.env.REVIEWER_ROUTER_APP_SLUG?.trim()", + "appSlug === 'github-actions'", + "mintedAppSlug !== appSlug", + "const expectedAppOwner = `${appSlug}[bot]`", + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + ".map(rule => Number(rule.ruleset_id))", + "Number.isSafeInteger(rulesetID) && rulesetID > 0", + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + "ruleset.enforcement !== 'active'", + "ruleset.target !== 'branch'", + "ruleset.name === 'main-merge-writers'", + "writerRuleset.source_type !== 'Repository'", + "writerRuleset.source?.toLowerCase() !== repositorySource", + "writerIncludes[0] !== 'refs/heads/main'", + "writerExcludes.length !== 0", + "writerRuleset.rules?.length !== 1", + "function isStrictUpdateRule(rule)", + "rule?.type !== 'update'", + "!Object.prototype.hasOwnProperty.call(rule, 'parameters')", + "parameters === null", + "typeof parameters !== 'object'", + "Array.isArray(parameters)", + "const parameterKeys = Object.keys(parameters)", + "parameterKeys.length === 1", + "parameterKeys[0] === 'update_allows_fetch_and_merge'", + "parameters.update_allows_fetch_and_merge === false", + "!isStrictUpdateRule(writerRuleset.rules[0])", + "typeof writerRuleset.node_id !== 'string'", + "function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)", + "graphRule.parameters.updateAllowsFetchAndMerge === false", + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "... on UpdateParameters", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", + "current_user_can_bypass !== 'pull_requests_only'", + "ruleset.current_user_can_bypass !== 'never'", + "Reviewer Router App must never bypass main ruleset", + "const skipWorkflowPattern =", + "const safeCommitHeadline = `Merge pull request #${pullNumber}`", + "const safeCommitBody =", + "workflow-skip cleanup", + "already has the reviewed ${expectedAppOwner} auto-merge request", + "preserving it", + "non-dedicated owner cleanup", + "unsafe dedicated-App metadata cleanup", + "getReadyEventPull('post-owner cleanup')", + "enablePullRequestAutoMerge", + "mergeMethod: MERGE", + "expectedHeadOid: $expectedHeadOid", + "commitHeadline: $commitHeadline", + "commitBody: $commitBody", + "expectedHeadOid: eventHeadSha", + "const exactRevision =", + "enabledBy === expectedAppOwner", + "disablePullRequestAutoMerge", + "unsafe-merge-message cleanup", + "enabledPull.auto_merge?.commit_title !== safeCommitHeadline", + "enabledPull.auto_merge?.commit_message !== safeCommitBody", + "if (revertedPull.auto_merge)", + "enabledBy !== expectedAppOwner", + } { + if !strings.Contains(autoMergeScript, want) { + t.Errorf("dedicated auto-merge script is missing contract marker %q", want) + } + } + if got := strings.Count(autoMergeScript, "const skipWorkflowPattern ="); got != 1 { + t.Errorf("dedicated auto-merge script skip-workflow pattern declarations = %d, want exactly 1", got) + } + for _, want := range []string{ + "if (!exactRevision && enabledBy === expectedAppOwner)", + "disableAutoMerge(enabledPull, 'stale-enable cleanup')", + "existingOwner === expectedAppOwner &&", + "disableAutoMerge(currentPull, 'workflow-skip cleanup')", + "currentPull.auto_merge.commit_title === safeCommitHeadline", + "currentPull.auto_merge.commit_message === safeCommitBody", + "disableAutoMerge(enabledPull, 'unexpected owner cleanup')", + } { + if !strings.Contains(autoMergeScript, want) { + t.Errorf("dedicated App stale-state cleanup is missing %q", want) + } + } + for _, forbidden := range []string{"core.warning", "catch ("} { + if strings.Contains(autoMergeScript, forbidden) { + t.Errorf("dedicated auto-merge must fail closed instead of handling errors through %q", forbidden) + } + } + for _, forbidden := range []string{ + "rule.ruleset_source_type", + "rule.ruleset_source?.toLowerCase()", + "ruleset.source_type !== 'Repository'", + "ruleset.source?.toLowerCase() !== repositorySource", + } { + if strings.Contains(autoMergeScript, forbidden) { + t.Errorf("dedicated App must inspect every applicable main ruleset, not prefilter through %q", forbidden) + } + } + + reconcile, ok := workflow.Jobs["reconcile"] + if !ok { + t.Fatalf("workflow jobs = %v, want reconcile", workflow.Jobs) + } + if reconcile.If != "(github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'" { + t.Fatalf("reconcile.if = %q, want exact protected-main manual guard", reconcile.If) + } + if len(reconcile.Steps) != 2 || + reconcile.Steps[0].ID != "reviewer-router-reconcile-token" || + reconcile.Steps[0].Uses != appTokenSHA || + reconcile.Steps[1].Uses != githubScriptSHA { + t.Fatalf("reconcile steps = %#v, want scoped App token followed by pinned migration script", reconcile.Steps) + } + if len(reconcile.Permissions) != 0 { + t.Fatalf("reconcile built-in token permissions = %v, want none", reconcile.Permissions) + } + if len(reconcile.Steps[0].With) != 6 { + t.Fatalf("reconcile token inputs = %v, want exactly the reviewed repository scope and two permissions", reconcile.Steps[0].With) + } + for key, want := range map[string]string{ + "client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}", + "private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-contents": "write", + "permission-pull-requests": "write", + } { + if got := reconcile.Steps[0].With[key]; got != want { + t.Errorf("reconcile token input %q = %q, want %q", key, got, want) + } + } + if _, ok := reconcile.Steps[0].With["skip-token-revoke"]; ok { + t.Error("reconcile App token must be revoked automatically after the job") + } + if got, want := reconcile.Steps[1].With["github-token"], "${{ steps.reviewer-router-reconcile-token.outputs.token }}"; got != want { + t.Fatalf("reconcile github-token = %q, want %q", got, want) + } + if got, want := reconcile.Steps[1].Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-reconcile-token.outputs.app-slug }}"; got != want { + t.Fatalf("minted reconcile App slug = %q, want %q", got, want) + } + if got, want := reconcile.Steps[1].Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want { + t.Fatalf("configured reconcile App slug = %q, want %q", got, want) + } + reconcileScript := reconcile.Steps[1].With["script"] + for _, want := range []string{ + "github.paginate(github.rest.pulls.list", + "state: 'open'", + "base: 'main'", + "candidate.draft", + "const candidateOwner =", + "const candidateSkipRequested =", + "const candidateHasSafeAppRequest =", + "candidateOwner === expectedAppOwner", + "!candidate.auto_merge", + "candidateHasSafeAppRequest", + "currentPull.head.sha !== expectedHeadSha", + "currentPull.base.sha !== expectedBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "const currentHasSafeAppRequest =", + "currentOwner === expectedAppOwner", + "currentHasSafeAppRequest", + "async function disableAutoMerge(pullNumber, pullRequestId, phase)", + "disablePullRequestAutoMerge", + "if (revertedPull.auto_merge)", + "return revertedPull", + "replace non-dedicated auto-merge owner", + "repair dedicated-App merge metadata", + "enablePullRequestAutoMerge", + "mergeMethod: MERGE", + "const expectedAppOwner = `${appSlug}[bot]`", + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + ".map(rule => Number(rule.ruleset_id))", + "Number.isSafeInteger(rulesetID) && rulesetID > 0", + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + "ruleset.enforcement !== 'active'", + "ruleset.target !== 'branch'", + "ruleset.name === 'main-merge-writers'", + "writerRuleset.source_type !== 'Repository'", + "writerRuleset.source?.toLowerCase() !== repositorySource", + "writerIncludes[0] !== 'refs/heads/main'", + "writerExcludes.length !== 0", + "writerRuleset.rules?.length !== 1", + "function isStrictUpdateRule(rule)", + "rule?.type !== 'update'", + "!Object.prototype.hasOwnProperty.call(rule, 'parameters')", + "parameters === null", + "typeof parameters !== 'object'", + "Array.isArray(parameters)", + "const parameterKeys = Object.keys(parameters)", + "parameterKeys.length === 1", + "parameterKeys[0] === 'update_allows_fetch_and_merge'", + "parameters.update_allows_fetch_and_merge === false", + "!isStrictUpdateRule(writerRuleset.rules[0])", + "typeof writerRuleset.node_id !== 'string'", + "function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)", + "graphRule.parameters.updateAllowsFetchAndMerge === false", + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "... on UpdateParameters", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", + "current_user_can_bypass !== 'pull_requests_only'", + "ruleset.current_user_can_bypass !== 'never'", + "Reviewer Router App must never bypass main ruleset", + "const skipWorkflowPattern =", + "const skipRequested =", + "workflow-skip directive found in merge metadata; left manual-merge only", + "const safeCommitHeadline = `Merge pull request #${candidate.number}`", + "const safeCommitBody =", + "expectedHeadOid: $expectedHeadOid", + "commitHeadline: $commitHeadline", + "commitBody: $commitBody", + "expectedHeadOid: expectedHeadSha", + "enabledPull.auto_merge?.commit_title !== safeCommitHeadline", + "enabledPull.auto_merge?.commit_message !== safeCommitBody", + "const failures = []", + "catch (error)", + "failures.push(failure)", + "core.setFailed", + "revisionChanged && enabledBy === expectedAppOwner", + "enabledBy !== expectedAppOwner", + "unexpected owner cleanup", + "unsafe merge-message cleanup", + } { + if !strings.Contains(reconcileScript, want) { + t.Errorf("reconciliation script is missing contract marker %q", want) + } + } + if disableIndex, enableIndex := strings.Index(reconcileScript, "disablePullRequestAutoMerge"), strings.Index(reconcileScript, "enablePullRequestAutoMerge"); disableIndex < 0 || enableIndex <= disableIndex { + t.Error("legacy reconciliation must disable the exact unsafe owner before App-token enable") + } + if got := strings.Count(reconcileScript, "const skipWorkflowPattern ="); got != 1 { + t.Errorf("reconciliation script skip-workflow pattern declarations = %d, want exactly 1", got) + } + if strings.Contains(reconcileScript, "const unsafeOwner") { + t.Error("reconciliation must converge every non-App owner, not only the legacy built-in owner") + } + for _, forbidden := range []string{ + "rule.ruleset_source_type", + "rule.ruleset_source?.toLowerCase()", + "ruleset.source_type !== 'Repository'", + "ruleset.source?.toLowerCase() !== repositorySource", + } { + if strings.Contains(reconcileScript, forbidden) { + t.Errorf("reconciliation App must inspect every applicable main ruleset, not prefilter through %q", forbidden) + } + } for _, forbidden := range []string{ "['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']", "PeterGuy326", - "core.setFailed", + "secrets.HOMEBREW_PR_TOKEN", + "secrets.RELEASE_GOVERNANCE_TOKEN", + "secrets.GITHUB_TOKEN", } { if strings.Contains(string(data), forbidden) { t.Errorf("reviewer router must not contain %q", forbidden) } } } + +func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) { + t.Parallel() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("ReadFile(ci.yml) error = %v", err) + } + workflow := string(data) + if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]") { + t.Error("CI must rerun admission when a draft becomes ready or merge metadata changes") + } + start := strings.Index(workflow, "\n test:\n") + end := strings.Index(workflow, "\n test-darwin:\n") + if start < 0 || end <= start { + t.Fatal("CI workflow missing Test aggregate boundaries") + } + testJob := workflow[start:end] + for _, want := range []string{ + "contents: read", + "pull-requests: read", + "name: Verify auto-merge identity", + "if: github.event_name == 'pull_request' && github.event.pull_request.draft == false", + "REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}", + "process.env.REVIEWER_ROUTER_APP_SLUG?.trim()", + "const expectedAppOwner = `${appSlug}[bot]`", + "github.rest.pulls.get", + "github.rest.repos.get", + "function classifyMergeDefaults(repository)", + "mergeDefaultsProjection === 'invalid'", + "mergeDefaultsProjection === 'omitted'", + "exact validation is delegated to the dedicated App", + "const writerRulesetName = 'main-merge-writers'", + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + "rule.ruleset_source_type === 'Repository'", + "rule.ruleset_source?.toLowerCase() === repositorySource", + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + "ruleset.enforcement !== 'active'", + "ruleset.target !== 'branch'", + "ruleset.source_type !== 'Repository'", + "ruleset.source?.toLowerCase() !== repositorySource", + "writerIncludes[0] !== 'refs/heads/main'", + "writerExcludes.length !== 0", + "writerRuleset.rules?.length !== 1", + "function isStrictUpdateRule(rule)", + "rule?.type !== 'update'", + "!Object.prototype.hasOwnProperty.call(rule, 'parameters')", + "parameters === null", + "typeof parameters !== 'object'", + "Array.isArray(parameters)", + "const parameterKeys = Object.keys(parameters)", + "parameterKeys.length === 1", + "parameterKeys[0] === 'update_allows_fetch_and_merge'", + "parameters.update_allows_fetch_and_merge === false", + "!isStrictUpdateRule(writerRuleset.rules[0])", + "typeof writerRuleset.node_id !== 'string'", + "function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)", + "graphRule.parameters.updateAllowsFetchAndMerge === false", + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "... on UpdateParameters", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", + "writerRuleset.current_user_can_bypass !== 'never'", + "deny this built-in Actions identity any bypass", + "const skipWorkflowPattern =", + "currentPull.title", + "currentPull.auto_merge?.commit_title", + "currentPull.auto_merge?.commit_message", + "merge metadata contains a GitHub workflow-skip directive", + "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "currentPull.auto_merge", + "enabled_by?.login", + "enabledBy === expectedAppOwner", + "currentPull.auto_merge.commit_title === safeCommitHeadline", + "currentPull.auto_merge.commit_message === safeCommitBody", + "must be null or owned by ${expectedAppOwner}", + "const maxAttempts = 6", + "setTimeout(resolve, 5000)", + "core.setFailed", + } { + if !strings.Contains(testJob, want) { + t.Errorf("Test aggregate missing auto-merge identity contract %q", want) + } + } + if !strings.Contains(testJob, "Null and non-built-in merge identities emit either the protected-main") || + !strings.Contains(testJob, "main-merge-writers never lets it update main") { + t.Error("auto-merge identity comment must name the push/repair paths and built-in writer self-check") + } + for _, forbidden := range []string{ + "REVIEWER_ROUTER_APP_PRIVATE_KEY", + "HOMEBREW_PR_TOKEN", + "RELEASE_GOVERNANCE_TOKEN", + "can emit protected-main workflow events", + "pull-requests: write", + "contents: write", + "actions: write", + } { + if strings.Contains(testJob, forbidden) { + t.Errorf("read-only admission identity check must not consume %q", forbidden) + } + } +} diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go index fbed6e4e..677ccabf 100644 --- a/test/smoke/cli_smoke_test.go +++ b/test/smoke/cli_smoke_test.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "os/exec" "path/filepath" + "slices" "sort" "strings" "testing" @@ -132,6 +134,265 @@ func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) { } } +func TestCrossPlatformCoverageCLISmokeCommandTypoGuidance(t *testing.T) { + env := isolatedCLIEnv(t) + tests := []struct { + name string + args []string + wantInput string + wantHelp string + wantHint string + wantSuggestion string + wantReason string + }{ + { + name: "legacy group typo", + args: []string{"chat", "searchh", "--query", "ignored", "--format", "json"}, + wantInput: "searchh", + wantHelp: "dws chat --help", + wantSuggestion: "search", + }, + { + name: "nested legacy group typo with child flag", + args: []string{"chat", "message", "liss", "--conversation-id", "cid", "--format", "json"}, + wantInput: "liss", + wantHelp: "dws chat message --help", + wantSuggestion: "list", + }, + { + name: "legacy group typo without suggestion", + args: []string{"chat", "zzzzz", "--format", "json"}, + wantInput: "zzzzz", + wantHelp: "dws chat --help", + }, + { + name: "top level typo", + args: []string{"chatt", "--format", "json"}, + wantInput: "chatt", + wantHelp: "dws --help", + wantSuggestion: "chat", + }, + { + name: "auth group typo", + args: []string{"auth", "sttus", "--format", "json"}, + wantInput: "sttus", + wantHelp: "dws auth --help", + wantSuggestion: "status", + }, + { + name: "event group typo", + args: []string{"event", "lisst", "--format", "json"}, + wantInput: "lisst", + wantHelp: "dws event --help", + wantSuggestion: "list", + }, + { + name: "shortcut group typo", + args: []string{"shortcut", "lst", "--format", "json"}, + wantInput: "lst", + wantHelp: "dws shortcut --help", + wantSuggestion: "list", + }, + { + name: "plus shortcut typo", + args: []string{"chat", "+chat-mesages", "--format", "json"}, + wantInput: "+chat-mesages", + wantHelp: "dws chat --help", + wantSuggestion: "+chat-messages", + wantReason: "unknown_shortcut", + }, + { + name: "audit group typo", + args: []string{"audit", "tal", "--format", "json"}, + wantInput: "tal", + wantHelp: "dws audit --help", + wantSuggestion: "tail", + }, + { + name: "hybrid aisearch typo", + args: []string{"aisearch", "persno", "--query", "ignored", "--format", "json"}, + wantInput: "persno", + wantHelp: "dws aisearch --help", + wantSuggestion: "person", + }, + { + name: "hybrid dev connect typo", + args: []string{"dev", "connect", "statsu", "--format", "json"}, + wantInput: "statsu", + wantHelp: "dws dev connect --help", + wantSuggestion: "status", + }, + { + name: "hybrid aitable view typo", + args: []string{"aitable", "view", "get", "crad", "--format", "json"}, + wantInput: "crad", + wantHelp: "dws aitable view get --help", + wantSuggestion: "card", + }, + { + name: "hybrid chat members typo", + args: []string{"chat", "group", "members", "ad", "--format", "json"}, + wantInput: "ad", + wantHelp: "dws chat group members --help", + wantSuggestion: "add", + }, + { + name: "hybrid doc export typo", + args: []string{"doc", "export", "gte", "--format", "json"}, + wantInput: "gte", + wantHelp: "dws doc export --help", + wantSuggestion: "get", + }, + { + name: "hybrid report inbox typo", + args: []string{"report", "inbox", "lsit", "--format", "json"}, + wantInput: "lsit", + wantHelp: "dws report inbox --help", + wantSuggestion: "list", + }, + { + name: "nested app group typo", + args: []string{"mcp", "url", "gt", "--format", "json"}, + wantInput: "gt", + wantHelp: "dws mcp url --help", + wantSuggestion: "get", + }, + { + name: "calendar group typo", + args: []string{"calendar", "room", "serach", "--format", "json"}, + wantInput: "serach", + wantHelp: "dws calendar room --help", + wantSuggestion: "search", + }, + { + name: "sheet group typo", + args: []string{"sheet", "range", "reead", "--format", "json"}, + wantInput: "reead", + wantHelp: "dws sheet range --help", + wantSuggestion: "read", + }, + { + name: "sheet deep path guidance", + args: []string{"sheet", "read", "--format", "json"}, + wantInput: "read", + wantHelp: "dws sheet --help", + wantSuggestion: "range read", + }, + { + name: "hybrid doc import typo", + args: []string{"doc", "import", "gte", "--format", "json"}, + wantInput: "gte", + wantHelp: "dws doc import --help", + wantSuggestion: "get", + }, + { + name: "hybrid sheet import typo", + args: []string{"sheet", "import", "gte", "--format", "json"}, + wantInput: "gte", + wantHelp: "dws sheet import --help", + wantSuggestion: "get", + }, + { + name: "shared compatibility hint", + args: []string{"todo", "create", "--format", "json"}, + wantInput: "create", + wantHelp: "dws todo --help", + wantHint: "dws todo task create", + }, + { + name: "chat compatibility hint", + args: []string{"chat", "send", "--group", "cid", "--text", "hello", "--format", "json"}, + wantInput: "send", + wantHelp: "dws chat --help", + wantHint: "dws chat message send", + }, + { + name: "contact compatibility hint", + args: []string{"contact", "user", "info", "--ids", "uid", "--format", "json"}, + wantInput: "info", + wantHelp: "dws contact user --help", + wantHint: "dws contact user get", + }, + { + name: "calendar compatibility hint", + args: []string{"calendar", "get", "--format", "json"}, + wantInput: "get", + wantHelp: "dws calendar --help", + wantHint: "dws calendar event get", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr, err := runCLI(t, env, test.args...) + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 3 { + t.Fatalf("dws %s exit = %v, want 3\nstdout:\n%s\nstderr:\n%s", strings.Join(test.args, " "), err, stdout, stderr) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("dws %s wrote stdout:\n%s", strings.Join(test.args, " "), stdout) + } + if strings.Contains(stderr, "available:") { + t.Fatalf("dws %s leaked an unbounded command list:\n%s", strings.Join(test.args, " "), stderr) + } + + var payload struct { + Error struct { + Category string `json:"category"` + Reason string `json:"reason"` + Hint string `json:"hint"` + Details struct { + Input string `json:"input"` + Suggestions []string `json:"suggestions"` + } `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stderr), &payload); err != nil { + t.Fatalf("dws %s returned non-JSON stderr: %v\nstderr:\n%s", strings.Join(test.args, " "), err, stderr) + } + wantReason := test.wantReason + if wantReason == "" { + wantReason = "unknown_subcommand" + } + if payload.Error.Category != "validation" || payload.Error.Reason != wantReason { + t.Fatalf("dws %s error = %#v", strings.Join(test.args, " "), payload.Error) + } + if !strings.Contains(payload.Error.Hint, test.wantHelp) { + t.Fatalf("dws %s hint = %q, want parent help %q", strings.Join(test.args, " "), payload.Error.Hint, test.wantHelp) + } + if test.wantHint != "" && !strings.Contains(payload.Error.Hint, test.wantHint) { + t.Fatalf("dws %s hint = %q, want authored guidance %q", strings.Join(test.args, " "), payload.Error.Hint, test.wantHint) + } + if payload.Error.Details.Input != test.wantInput { + t.Fatalf("dws %s details.input = %q, want %q", strings.Join(test.args, " "), payload.Error.Details.Input, test.wantInput) + } + if len(payload.Error.Details.Suggestions) > 3 { + t.Fatalf("dws %s suggestions = %#v, want at most 3", strings.Join(test.args, " "), payload.Error.Details.Suggestions) + } + if test.wantSuggestion != "" && !slices.Contains(payload.Error.Details.Suggestions, test.wantSuggestion) { + t.Fatalf("dws %s suggestions = %#v, want %q", strings.Join(test.args, " "), payload.Error.Details.Suggestions, test.wantSuggestion) + } + }) + } + + t.Run("legitimate positional commands remain executable", func(t *testing.T) { + const positionalCommandTimeout = 30 * time.Second + + stdout, stderr, err := runCLIWithTimeout(t, env, positionalCommandTimeout, "schema", "chat message send", "--compact", "--format", "json") + if err != nil || strings.TrimSpace(stdout) == "" { + t.Fatalf("dws schema positional path failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + stdout, stderr, err = runCLI(t, env, "completion", "zsh") + if err != nil || strings.TrimSpace(stdout) == "" { + t.Fatalf("dws completion positional shell failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + stdout, stderr, err = runCLIWithTimeout(t, env, positionalCommandTimeout, "help", "auth") + if err != nil || !strings.Contains(stdout, "dws auth") { + t.Fatalf("dws help auth failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + }) +} + // publicCommandPaths follows only canonical, user-visible Cobra commands. // Hidden commands and commands marked Deprecated by Cobra are intentionally // excluded; aliases are not separate command nodes. Parents and leaves are both @@ -196,8 +457,13 @@ func isolatedCLIEnv(t *testing.T) []string { func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { t.Helper() + return runCLIWithTimeout(t, env, 10*time.Second, args...) +} - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +func runCLIWithTimeout(t *testing.T, env []string, timeout time.Duration, args ...string) (string, string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) cmd := exec.CommandContext(ctx, os.Args[0], processArgs...)