Compare commits

...
Author SHA1 Message Date
Dennis a82d945f54 fix(doc): reject explicit revert failure states 2026-08-17 14:48:13 +08:00
Dennis 6846326445 fix(doc): reject revert request echo evidence 2026-08-17 14:48:11 +08:00
Dennis 54c2054a5c fix(doc): ignore generated JSONML defaults 2026-08-17 14:48:09 +08:00
Dennis e5bf332b05 fix(doc): address readback review findings 2026-08-17 14:48:06 +08:00
Dennis 9ed55978d9 fix(doc): cancel readback retry waits 2026-08-17 14:48:04 +08:00
Dennis 7ffbbc4a51 test(doc): cover stable pagination identities 2026-08-17 14:48:02 +08:00
Dennis 2db73a8185 fix(doc): distinguish identical pagination pages 2026-08-17 14:48:00 +08:00
Dennis a62332be93 fix(doc): trust only explicit inserted block IDs 2026-08-17 14:47:58 +08:00
Dennis 1083093cbc test(doc): complete readback coverage evidence 2026-08-17 14:47:56 +08:00
Dennis c6ebe307cd fix(doc): verify inline media from jsonml readback 2026-08-17 14:47:54 +08:00
Dennis 3ee66d4373 fix(doc): harden mutation readback verification 2026-08-17 14:47:51 +08:00
github-actions[bot] a0be395ccc Merge pull request #1006 from DingTalk-Real-AI/codex/fix-aitable-pagination-minutes-unshare
fix(shortcut): harden Aitable pagination and Minutes unshare
2026-08-17 06:37:16 +00:00
Dennis c1a549cd64 fix: close delete readback continuations 2026-08-17 14:13:51 +08:00
Dennis 5a414999ef fix: validate record query previews 2026-08-17 14:13:49 +08:00
Dennis 7aa8240629 fix: preserve record query preview contract 2026-08-17 14:13:47 +08:00
Dennis 37b9a1dc31 fix: bound exact aitable record queries 2026-08-17 14:13:45 +08:00
Dennis 2ab8748c4d test: use native minutes path separators 2026-08-17 14:13:43 +08:00
Dennis f041275811 fix: make minutes polling portable 2026-08-17 14:13:41 +08:00
Dennis f486105836 fix: bound empty aitable pagination 2026-08-17 14:13:38 +08:00
Dennis e14de2b4c2 test: close shortcut fix review gates 2026-08-17 14:13:36 +08:00
Dennis fe2f3ca92f fix: harden aitable pagination and minutes unshare 2026-08-17 14:13:33 +08:00
github-actions[bot] 8e4519cacd Merge pull request #1014 from FloralTide/codex/fix-windows-event-bus
fix(event): support Windows bus lifecycle
2026-08-17 14:12:46 +08:00
炳昱 16abb481e8 Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-17 13:00:07 +08:00
炳昱 7ad82bbf0a fix(event): accept bus exit at stop timeout boundary 2026-08-17 13:00:07 +08:00
chichuan 104eb715c4 Merge pull request #989 from maoqxxmm/codex/sheet-dropdown-source-range
feat(sheet): support SourceRange dropdowns and read completion
2026-08-17 12:19:00 +08:00
chichuan 97ea887ea5 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-17 11:49:42 +08:00
github-actions[bot] bfeb9f6af0 chore: update beta formula for v1.0.59-beta.2 [skip ci] 2026-08-17 03:35:41 +00:00
毛球 e26f278112 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-17 11:17:45 +08:00
chichuan e6b5938bd8 Merge pull request #1025 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.2
docs: seal changelog for v1.0.59-beta.2
2026-08-17 11:03:27 +08:00
chichuan 4f95373420 docs: seal changelog for v1.0.59-beta.2 2026-08-17 10:59:11 +08:00
炳昱 afb90009f6 Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-17 10:49:25 +08:00
github-actions[bot] 6411d26a95 Merge pull request #1023 from DingTalk-Real-AI/fix/app-partition-parallel-jobs
fix(ci): parallelize app test partitions and drop race from the schema partition
2026-08-17 02:45:57 +00:00
毛球 31117d1b89 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-17 10:43:01 +08:00
炳昱 e3553fe7a5 test(event): cover bus ownership validation failures 2026-08-17 10:40:21 +08:00
炳昱 067aff179f Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-17 10:32:08 +08:00
炳昱 353454abb2 fix(event): verify bus owner before fallback stop 2026-08-17 10:32:04 +08:00
chichuan 96b9cbce02 Merge branch 'main' into fix/app-partition-parallel-jobs 2026-08-17 10:22:20 +08:00
chichuan 36877d00dc Merge pull request #1024 from DingTalk-Real-AI/perf/schema-json-projection
perf: skip redundant JSON validation when projecting typed Schema values
2026-08-17 10:21:48 +08:00
xiatian a9a97c2746 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-17 09:43:54 +08:00
chichuan 55d94d3b58 perf: skip redundant JSON validation when projecting typed Schema values
typedJSONValue marshaled a typed value and then routed the result through
rawJSONValue, which runs json.Valid before decoding. On that path the input is
whatever json.Marshal has just produced, so the validation scan can only ever
succeed: it re-read every marshaled document for nothing.

The decode step is now shared by both entry points. rawJSONValue keeps its
json.Valid check, because it still accepts untrusted input, while typedJSONValue
decodes what it marshaled directly. Across the 1121-tool set this removes about a
third of the Schema Catalog projection work: the internal/app schema suite goes
from 26.0s to 17.2s uninstrumented, and from 291.1s to 241.0s under -race.

The delivered Catalog is byte-for-byte unchanged. check-generated-drift,
check-schema-catalog and check-schema-binary each regenerate the same
source_hash sha256:93b8d44eb163bd2898c78397d22af92d378e3dc4e20f56b33277b51e4342e2e6,
and the two error contracts are preserved: typedJSONValue still rejects a value
json.Marshal cannot encode, and rawJSONValue still rejects invalid JSON.
2026-08-16 22:21:16 +08:00
chichuan bfd0976b31 fix(ci): run the app test partitions as parallel shards
The five internal/app partitions ran end to end inside one job, so the app
shard's wall clock was the sum of all five: 780s in CI, of which the schema
partition owned 357s. Each partition is now its own matrix shard, so they run
concurrently and the shard's wall clock is set by its slowest partition rather
than by their total. Every partition shard still selects the same single
internal/app package, so the impacted-package query maps the shard name back to
app and the partition only chooses which tests run.

The helper gains a partition argument and a list-partitions mode. APP_PARTITIONS
is the single source of truth for the set, and the discovery pass still runs in
every job, so each one independently verifies that the partition patterns cover
every top-level test exactly once before running the one it was asked for.

Two fail-closed checks guard the split, because the helper's own coverage check
can no longer prove the whole package ran once the partitions are separate jobs:

- The helper cross-checks APP_PARTITIONS against the coverage counters in both
  directions, so a counted partition that nothing dispatches and a dispatchable
  partition with no counter both fail instead of silently skipping tests.
- TestCIAppRacePartitionMatrixMatchesHelper pins the workflow's app-<partition>
  shards to list-partitions output in both directions, so a partition cannot
  lose its job while every job stays green.

The discovery loop variable is renamed from partition to spec: it would
otherwise shadow the partition requested on the command line, which run mode
reads after the discovery pass completes.
2026-08-16 22:18:04 +08:00
chichuan 4a33e7e893 fix(ci): drop race instrumentation from the app schema partition
The schema partition's 52 tests assert structural Schema-to-Cobra contracts over
a single goroutine: none of them call t.Parallel or start a goroutine, so the
race detector has no concurrent access to observe there. The process-global lazy
metadata that does need race coverage (schema_source_root's atomic.Value, the
parameter-binding lazy loaders) is exercised by internal/cli's concurrent tests,
which stay instrumented.

The instrumentation was not free here. The partition shares a single sync.Once
Catalog build whose work is allocation-heavy, and -race made it roughly 11x
slower: 26s -> 291s locally, and 357s of the app shard's 780s in CI. Within that
partition TestFinalSchemaToolsHaveExecutableBaseCommands alone accounted for
262s, not because the test is expensive but because it is the first caller to pay
for the shared snapshot; its 1121 subtests together measure 0.00s.

run_partition now takes the instrumentation mode explicitly and fails closed on
an unrecognized value, so a typo cannot silently drop -race from a partition that
is supposed to carry it.
2026-08-16 22:17:14 +08:00
github-actions[bot] ee74765383 Merge pull request #1019 from DingTalk-Real-AI/feat/help-feedback-entry
feat: add feedback survey entry to root help
2026-08-16 08:09:01 +08:00
chichuan 35239259fb Merge branch 'main' into feat/help-feedback-entry 2026-08-16 06:57:18 +08:00
github-actions[bot] 85bf2dfc8a Merge pull request #1021 from DingTalk-Real-AI/fix/test-focused-shard-matrix
fix(ci): shard the focused test job instead of one long-lived run
2026-08-15 23:33:12 +08:00
chichuan c4f2ab631b fix(ci): assert the focused path's shard shape in the workflow contract
The workflow contract pinned the focused path by literal: the job name
`Test (changed packages)`, the unsharded
`list "$TEST_BASE_REF" "$TEST_HEAD_REF"` call, and a single
`go test -timeout=15m` line standing in for internal/app's package-level
headroom. Sharding the job changed all three literals, so `Test (workflow
and release contracts)` failed on this branch even though every shard
selection test passed.

Each invariant the contract guarded still holds, so the assertions are
updated to the new shape rather than relaxed:

- the focused job must still exist, now as the matrix job, named the way
  the contract already names `Test (race: ${{ matrix.shard }})`;
- package selection must still derive from the authoritative synthetic
  merge base/head, now with an explicit shard argument, so pointing it at
  any other ref still fails the contract;
- internal/app's headroom is asserted through the process-isolating
  helper and the per-shard budgets, mirroring the assertions already
  applied to test-race. That is stronger than the old single -timeout: it
  pins the mechanism that keeps the suite inside its budget rather than
  the number alone. release-scripts membership is asserted too, because
  its dedicated job only runs at full-suite or release-sensitive scope,
  so losing it here would silently stop testing test/scripts changes.

The shard comparisons in the focused job are quoted so that job reads
verbatim like test-race's.

Ablating the implementation one change at a time turns the contract red
in all five cases: removing the app helper call, dropping release-scripts
from the matrix, selecting from HEAD~1, collapsing the matrix back to a
single unsharded job, and dropping the cli/smoke timeout budget.
2026-08-15 22:58:30 +08:00
chichuan 308e71c783 fix(ci): pass focused shard packages through a file
Reading the package list with `mapfile < file` has unambiguous line
semantics. Routing it through a step output and a here-string instead
would append an extra empty array element if the value ever carried a
trailing newline, and that element would reach go test as an empty
package argument. The step output now carries only a single-line boolean,
and the list travels through RUNNER_TEMP. An explicit empty-entry guard
fails closed if the file is ever malformed.

This job cannot execute on its own pull request — editing a workflow
routes the revision to full_suite, which skips the focused path — so the
implementation deliberately avoids depending on platform-specific
trailing-newline behavior that local verification cannot observe.
2026-08-15 22:32:39 +08:00
chichuan ecce09b355 fix(ci): shard the focused test job instead of one long-lived run
The focused path tested every impacted package in a single job with a
plain `go test -race`, so internal/app ran inside one long-lived process
alongside all of its reverse dependencies. That is exactly the shape
scripts/ci/run-app-race-tests.sh exists to avoid: a single app test
process retains every constructed command tree in framework registries,
so the run grows to 900s and the job stays alive long enough to be
reclaimed by the runner. Recent focused runs failed with SIGTERM after
9-10 minutes without a single test failure, and one earlier run failed
at `internal/app 902.651s`, 2.65s past the package timeout.

Fan the same package plan across the shard matrix test-race already
uses, and run each shard the way test-race runs it: internal/app through
the process-isolating helper, cli/smoke with their wider package budget,
release-scripts without race and with archive tooling.

changed-test-packages.sh gains `list-shard`, which intersects the
impacted set with scripts/ci/test-packages.sh shard membership so shard
definitions stay single-sourced — and so an unknown shard name aborts
there rather than reporting an empty selection, which would let a
mistyped shard skip every test while reporting success.

release-scripts is in the matrix on purpose: its dedicated job only runs
at full-suite or release-sensitive scope, so omitting it here would stop
testing test/scripts changes altogether. A test pins that the shard
selections partition the impacted set exactly, so shard-plan drift
cannot silently shrink focused coverage.
2026-08-15 22:10:28 +08:00
chichuan 1d02ff805d refactor: keep the feedback label out of i18n
Every neighbouring string in the root help listing — service
descriptions, utility descriptions, global flag usage — is hardcoded
Chinese. Routing only the feedback label through i18n therefore rendered
it in English on any host whose LANG is not zh_*, leaving a lone English
line inside an otherwise Chinese screen.

Hardcode the label and drop the two locale entries it needed. A test
assertion now pins the Chinese label so the indirection cannot return
unnoticed.
2026-08-15 16:38:57 +08:00
chichuan 4d843cf7a4 feat: add feedback survey entry to root help
`dws --help` now closes with a Feedback section that links the
user-experience survey form, tagged with source=dws-cli so submissions
arriving through the CLI can be told apart from other channels.

The entry is deliberately root-only: this CLI is driven mostly by AI
agents, and repeating a survey link in every subcommand help would be
pure context noise. A guard test pins that boundary.

The URL is printed on its own unwrapped line — it is longer than the
help rule width, and breaking it would stop terminals from recognizing
it as a clickable hyperlink.
2026-08-15 16:23:27 +08:00
8560830d3e feat: add privacy-safe clitrack telemetry (#1009)
Co-authored-by: zearlin <ruomiao.linrm@alibaba-inc.com>
Co-authored-by: chichuan <30925823+haofeng0705@users.noreply.github.com>
2026-08-15 15:58:31 +08:00
xiatian 9fbd8addbe Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-15 13:40:55 +08:00
xiatian 92195a58a3 fix(sheet): align SourceRange review contract 2026-08-15 13:40:47 +08:00
github-actions[bot] fb9ff7de73 Merge pull request #1017 from typefield/feat/flag-input-file-stdin
feat(corecmd): support @file / stdin input sources on string flags
2026-08-15 12:50:22 +08:00
玉澜 5ee80cdb97 docs(rfc): warn about Input value-space collisions
Fifth-review addition: declaring InputFile silently claims the whole
@-prefixed value space, which matters in this product because at-mention
style values are common (--at-user @zhangsan would report a file read
failure), and declaring InputStdin makes a literal "-" unreachable. Both
are decided at declaration time and cannot be fixed downstream, so record
them next to the confirmation rule in the author rules.
2026-08-15 12:34:33 +08:00
玉澜 bf2c0653ed docs: record Input in the flag/help/schema homology field table
Fourth-review fix: the FlagSpec sub-field table in the homology doc is
the named authority for "what each field does and whether it reaches
Schema parameters", and RFC §5.0.2 asserts declaration fields embed into
dws.schema.*. Input satisfied neither entry, leaving its deliberate
non-projection indistinguishable from an oversight. Add the table row and
the §5.0.2 exception note so the capability stays a declared fact (Usage
prose) rather than inviting an invented annotation.
2026-08-15 12:29:26 +08:00
玉澜 e4daddf9cf test(corecmd): name Input tests for the platform coverage gate
Third-review fix for a CI blocker: run-platform-coverage-gate.sh only
executes ^(TestAllShortcuts|TestCrossPlatformCoverage) yet enforces 100%
coverage of changed production lines, so the TestResolveInputFlags names
left every new input.go statement reported as uncovered. Rename them to
the gate prefix, drop three unreachable pflag Set error branches that no
test could ever cover, and add the reachable stdin read-failure case.
Verified: changed code coverage 100.0000% (67 statements).
2026-08-15 12:23:54 +08:00
玉澜 e92309f7c4 fix(corecmd): match Input name selection to rawValue usability exactly
Second-review fix: explicitInputFlagName judged usability with an
unconditional TrimSpace while rawValue only trims when Trim is set. For
a non-Trim flag a whitespace main value is usable and shadows a changed
alias; the resolver could then rewrite the shadowed alias (and fail on
its @path) while the fallback chain still read the main value. Mirror
rawValue's usable() exactly and pin the shadow case with a regression
test whose alias path does not exist.
2026-08-15 12:14:10 +08:00
玉澜 7a58b0d19a fix(corecmd): align Input prefix check with Trim semantics
Self-review fixes: a Trim flag receiving " @path" judged usability on the
trimmed value (rawValue) while the source prefix check saw the raw value,
so the token would ship as a literal. Trim before the prefix check. Also
build the file-read error once with a conditional hint option, and pin
the default-value/env passthrough plus Trim edge with regression tests.
2026-08-15 12:11:55 +08:00
玉澜 78e6f11d72 docs(rfc): add @file / stdin Input flag usage guide to §5.3
Document the landed corecmd.Input transitional form: declaration shape
(FlagSpec/LeafFlag/shortcut.Flag), runtime resolution semantics and
ordering, author rules (help prose, confirmation interaction with
stdin, construction-time validation), and the delta table against the
target typed InputSource design.
2026-08-15 12:06:02 +08:00
玉澜 9a8a41a318 feat(corecmd): support @file / stdin input sources on string flags
Port the lark-cli Flag.Input capability: a KindString flag may declare
Input sources ("file" for @path, "stdin" for -) and the framework
rewrites the explicit token into the payload content before
required/enum/constraint/Validate checks. @@value escapes to a literal
@value; a single stdin consumer per invocation is enforced; a leading
UTF-8 BOM is stripped. Shortcut.Flag gains the same declaration and the
adapter maps it through; LeafSpec inherits it via the LeafFlag alias.
2026-08-15 10:47:11 +08:00
github-actions[bot] af8e6a9ccc Merge pull request #1015 from DingTalk-Real-AI/codex/wiki-shortcut-search-adapter
fix(wiki): document search parameter adapter
2026-08-15 01:30:26 +08:00
Dennis 547020f47e ci: shard shortcut reverse dependencies 2026-08-15 01:14:51 +08:00
Dennis d5eee82816 fix(wiki): document search parameter adapter 2026-08-15 00:07:00 +08:00
github-actions[bot] 0d8763b917 Merge pull request #1005 from DingTalk-Real-AI/codex/wiki-shortcut-workflows
feat(wiki): publish and harden 20 shortcut workflows
2026-08-14 23:49:35 +08:00
Dennis 600404abd0 fix(wiki): require interactive e2e confirmation 2026-08-14 23:32:43 +08:00
Dennis 247926d0fa fix(wiki): enforce auto-page item cap 2026-08-14 23:02:59 +08:00
Dennis 9ef2a4e652 fix(wiki): publish executable shortcut examples 2026-08-14 22:18:53 +08:00
Dennis d4daf9525c fix(wiki): verify copied node identity 2026-08-14 22:18:51 +08:00
Dennis 63a89e68fa test(wiki): lock confirmation before remote calls 2026-08-14 22:18:49 +08:00
Dennis 29b73a7d5e fix(wiki): close shortcut review gaps 2026-08-14 22:18:47 +08:00
Dennis 3488e11129 docs(wiki): keep review product-neutral 2026-08-14 22:18:45 +08:00
Dennis 596bdce3a1 feat(wiki): align and harden shortcut workflows 2026-08-14 22:18:43 +08:00
github-actions[bot] 58eea98f6c Merge pull request #1013 from DingTalk-Real-AI/codex/chat-reference-card-hardening
fix(chat): split references and harden card updates
2026-08-14 19:52:22 +08:00
炳昱 e742a6c269 Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-14 19:40:58 +08:00
栩朝 b53b84616e fix(cli): match ambiguous from flag exactly 2026-08-14 19:32:54 +08:00
栩朝 15a2fea0dc fix(chat): split references and harden card updates
Split chat message and group references by task, update intent routing and context budget, distinguish accepted card updates from verified writes, and explain the ambiguous chat --from flag.
2026-08-14 18:38:31 +08:00
chichuan 05868610f0 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-14 18:33:24 +08:00
github-actions[bot] d8da9a2e9f Merge pull request #1011 from DingTalk-Real-AI/ci-coverage-speedup
ci: shard full-suite coverage and cache merge-base profile
2026-08-14 18:32:09 +08:00
chichuan 1a6ae856ec Merge branch 'main' into ci-coverage-speedup 2026-08-14 18:16:14 +08:00
炳昱 22649e96ef test(event): cover Unix spawn validation on Windows 2026-08-14 18:11:42 +08:00
chichuan 9c6407ae74 ci: align baseline coverage cache paths 2026-08-14 18:06:49 +08:00
炳昱 f68a11f11d test(event): cover Windows lifecycle edges 2026-08-14 18:05:34 +08:00
炳昱 abe5129306 fix(event): support Windows bus lifecycle 2026-08-14 17:56:51 +08:00
github-actions[bot] b9b8cc2c77 Merge pull request #954 from xlb1130/fix/85200556-im-id-flags-v3
fix(chat): converge IM ID flags
2026-08-14 09:44:45 +00:00
xlb1130 e02fdbdc8f Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 17:29:22 +08:00
github-actions[bot] ce529c9337 chore: update beta formula for v1.0.59-beta.1 [skip ci] 2026-08-14 09:20:43 +00:00
xlb1130 6952b22f45 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 16:39:56 +08:00
chichuan 3aa06e32fa ci: shard full-suite coverage and cache merge-base profile
The Coverage context was the PR critical path (~17 min end to end):
coverage-current re-ran the whole suite serially (-p 1, ~13 min) and
coverage-baseline re-ran it again at the merge-base (~13 min) although
that profile is a pure function of the base commit.

- coverage-current now owns only the scoped (standard-tier) profile;
  full-suite candidate profiles come from a 5-way shard matrix
  (app/cli/generators/helpers/remaining) that keeps -p 1 inside each
  shard on isolated runners. scripts/ci/test-packages.sh list-coverage
  defines the shards and verify proves the union equals the previous
  single-run package set exactly once.
- the aggregate Coverage job reassembles the disjoint shard profiles
  into coverage.txt before make coverage-gate, failing closed when a
  shard file is missing, so gate semantics (100% changed-code +
  scope-matched overall non-regression) are byte-compatible.
- coverage-baseline restores the merge-base full-suite profile from an
  exact-key cache (merge-base SHA + resolved Go version) written by the
  last green main push; any miss falls back to recomputing in the
  merge-base worktree. Exact key only - no prefix fallback, a near-miss
  profile would compare the candidate against the wrong commit.
- new contract tests pin the shard matrix, the assembly step, the
  exact-key cache pair, and the absence of restore-keys; the package
  plan test also covers the coverage shard partition.
2026-08-14 16:24:00 +08:00
xlb1130 b17e030d1f Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 15:42:27 +08:00
xlb1130 afd8422580 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 15:16:17 +08:00
长真 a7a0a97115 test(chat): align open id fixtures with current format 2026-08-14 12:25:07 +08:00
xlb1130 cbaa8c9bf5 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 11:39:54 +08:00
长真 0f5ecb609b fix(cli): restore audit join user guard 2026-08-14 11:38:26 +08:00
长真 d0e6aba319 fix(cli): cover alias exclude guard branches 2026-08-14 00:23:18 +08:00
xlb1130 b0b18986b1 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-14 00:10:33 +08:00
xlb1130 abecb0dee1 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 23:28:09 +08:00
长真 d17f50b9de fix(cli): keep real flags out of alias blocked list 2026-08-13 23:26:08 +08:00
xlb1130 10417396f1 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 22:15:34 +08:00
xlb1130 410a63ea9a Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 20:59:34 +08:00
长真 58c382efb7 Merge remote-tracking branch 'origin/fix/85200556-im-id-flags-v3' into fix/85200556-im-id-flags-v3 2026-08-13 20:57:29 +08:00
长真 3598586bc0 fix(cli): block plural id flag normalization 2026-08-13 20:56:43 +08:00
xlb1130 b066a14f0c Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 20:35:07 +08:00
xlb1130 94d4b5dcc9 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 18:58:13 +08:00
长真 5cbf18713a docs(changes): expand chat im flag migration note 2026-08-13 18:57:44 +08:00
长真 78d94380e7 docs(changes): note chat im id flag migration 2026-08-13 18:54:00 +08:00
xiatian 8cd2b0259d Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 18:04:51 +08:00
xiatian 4b8d94c24e ci: retry interrupted app race shard 2026-08-13 17:15:22 +08:00
xiatian 76e5a8c4d9 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 16:37:12 +08:00
xlb1130 c718b051c2 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 16:34:16 +08:00
xlb1130 6a93f14e0a Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 16:29:35 +08:00
长真 913b7cf9a9 chore(cli): refresh generated param aliases 2026-08-13 16:18:11 +08:00
xiatian 6abffce4e5 fix(sheet): preserve dropdown schema compatibility 2026-08-13 16:13:30 +08:00
xiatian 86b78e45d7 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 16:08:28 +08:00
xlb1130 a354144412 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 15:40:39 +08:00
长真 d525648b45 fix(chat): support read-status conversation aliases 2026-08-13 15:24:27 +08:00
xiatian 5065e4bfb6 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 13:49:52 +08:00
长真 f3f1174407 chore(ci): rerun pr checks 2026-08-13 13:36:42 +08:00
xlb1130 ce6d5fb538 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 13:11:23 +08:00
长真 43882bf959 fix(chat): preserve schema compatibility for im flags 2026-08-13 13:02:34 +08:00
xlb1130 55c6a09bbc Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 11:58:53 +08:00
xiatian 2778bef5bd feat(sheet): support source range dropdowns and read completion 2026-08-13 11:46:58 +08:00
xlb1130 286376df93 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-13 10:54:21 +08:00
xlb1130 b8deec9087 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 23:47:39 +08:00
长真 dbee2de1d5 fix(chat): align im id flag migration scope 2026-08-12 23:45:05 +08:00
长真 a55bd9bff8 fix(chat): complete pending id flag migrations 2026-08-12 20:53:53 +08:00
xlb1130 516bd5d99c Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 20:09:09 +08:00
长真 65a00b497b fix(chat): migrate audit join validation id flag 2026-08-12 20:06:09 +08:00
长真 3e4a3fb9d9 Merge remote-tracking branch 'origin/fix/85200556-im-id-flags-v3' into fix/85200556-im-id-flags-v3 2026-08-12 18:06:56 +08:00
长真 1f1c27d68f fix(chat): restore audit join group flag 2026-08-12 18:06:10 +08:00
xlb1130 1b8ca149cb Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 17:03:29 +08:00
长真 b6c508acdf fix(chat): canonicalize send-card id flags 2026-08-12 11:49:14 +08:00
xlb1130 9472f4a1d9 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 10:48:44 +08:00
xlb1130 90e27c4b86 Merge branch 'main' into fix/85200556-im-id-flags-v3 2026-08-12 10:41:36 +08:00
长真 132dea9aaa fix(chat): hide remaining im id aliases 2026-08-11 22:51:16 +08:00
长真 5034c332fe fix(chat): converge im id flags 2026-08-11 22:38:37 +08:00
174 changed files with 13459 additions and 2302 deletions
@@ -0,0 +1,11 @@
---
category: Fixed
---
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
record queries on the service's 20-record page boundary so multi-page reads
and mutation readbacks no longer report false retryable failures, preserves
`totalCount` when supplied, validates `--dry-run` plans before transport,
follows active deletion readback continuations before proving absence, and
rejects Minutes unshare success until the listening note exists and the
service acknowledges the exact task and member targets.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
@@ -0,0 +1,7 @@
---
category: Changed
---
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
@@ -0,0 +1,6 @@
---
category: Fixed
---
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
@@ -0,0 +1,8 @@
---
category: Changed
---
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
@@ -0,0 +1,9 @@
---
category: Added
---
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
+6
View File
@@ -0,0 +1,6 @@
---
category: Added
---
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
+314 -63
View File
@@ -151,6 +151,10 @@ jobs:
filename.startsWith('scripts/') ||
filename.startsWith('verify/') ||
filename.startsWith('internal/helpers/') ||
// Shortcut declarations feed the live command tree and Schema
// assembly. Their reverse dependencies include the expensive
// app and generator packages, which must run in separate shards.
filename.startsWith('internal/shortcut/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
// Parameter aliases are reduced against the live command tree.
@@ -509,11 +513,38 @@ jobs:
run: node .github/reviewer-routing.test.js
test-focused:
name: Test (changed packages)
name: "Test (focused: ${{ matrix.shard }})"
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
runs-on: ubuntu-latest
# Each shard owns one bounded slice of the impacted set, so no single job
# carries internal/app together with every reverse dependency. The shard
# list and per-shard execution below mirror test-race, which runs the same
# shards at full-suite scope; release-scripts is included because its
# dedicated job only runs at full-suite or release-sensitive scope, and
# dropping it here would stop testing test/scripts changes entirely.
# internal/app is carried by one shard per bounded partition rather than a
# single app shard: the partitions used to run end to end inside one job,
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app-schema
- app-a-b
- app-c
- app-d-r
- app-s-z-example-fuzz
- generators
- helpers
- cli
- smoke
- remaining
- release-scripts
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -542,37 +573,110 @@ jobs:
with:
go-version-file: go.mod
- name: Test changed packages and reverse dependencies
- name: Select impacted packages for shard
id: select
shell: bash
env:
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
# Every app partition shard tests the same single internal/app
# package, so the impacted-package query uses the base shard name and
# the partition only selects which tests run.
package_shard="$TEST_SHARD"
case "$TEST_SHARD" in
app-*) package_shard=app ;;
esac
package_output="$(
./scripts/ci/changed-test-packages.sh \
list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"
)"
if [ -z "$package_output" ]; then
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
>> "$GITHUB_STEP_SUMMARY"
echo "affected=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# The package list travels through a file rather than a step output:
# reading it with `mapfile < file` has unambiguous line semantics,
# whereas a here-string over a multi-line output would append an extra
# empty element if the value ever carried a trailing newline, and an
# empty element would reach go test as an empty package argument.
printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt"
echo "affected=true" >> "$GITHUB_OUTPUT"
- name: Build
if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }}
run: make build
- name: Install archive tooling
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Test shard with Race Detection
if: ${{ steps.select.outputs.affected == 'true' }}
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
package_output="$(
./scripts/ci/changed-test-packages.sh \
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
)"
if [ -z "$package_output" ]; then
echo "No buildable Go package is affected by this revision." \
>> "$GITHUB_STEP_SUMMARY"
mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt"
test "${#packages[@]}" -gt 0
for package in "${packages[@]}"; do
test -n "$package" || {
echo "shard package list contains an empty entry" >&2
exit 1
}
done
case "$TEST_SHARD" in
app-*)
# A single long-lived app test process retains every constructed
# command tree in framework registries. Each partition is its own
# job, so that state is released when the process exits and the
# partitions run concurrently instead of end to end. The helper
# still verifies that the partition patterns cover every top-level
# test exactly once before running the one it was asked for.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
exit 0
;;
esac
if [ "$TEST_SHARD" = "release-scripts" ]; then
# Mirror the dedicated release-contract job: these suites shell out
# to archive tooling and are not race-instrumented there.
go test -v -count=1 -timeout=10m "${packages[@]}"
exit 0
fi
mapfile -t packages <<< "$package_output"
go test -v -race -count=1 -timeout=15m "${packages[@]}"
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
# give them a dedicated package timeout on slower hosted runners.
timeout_budget=12m
if [ "$TEST_SHARD" = "cli" ] ||
[ "$TEST_SHARD" = "smoke" ]; then
timeout_budget=15m
fi
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
test-race:
name: "Test (race: ${{ matrix.shard }})"
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
runs-on: ubuntu-latest
# app runs several independently bounded processes; cli/smoke need headroom
# beyond go test -timeout for setup + assembly.
# internal/app is split across one shard per bounded partition so the
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app
- app-schema
- app-a-b
- app-c
- app-d-r
- app-s-z-example-fuzz
- generators
- helpers
- cli
@@ -598,18 +702,30 @@ jobs:
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
# Every app partition shard tests the same single internal/app
# package, so the package query uses the base shard name and the
# partition only selects which tests run.
package_shard="$TEST_SHARD"
case "$TEST_SHARD" in
app-*) package_shard=app ;;
esac
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
if [ "$TEST_SHARD" = "app" ]; then
# A single long-lived app test process retains every constructed
# command tree in framework registries. Isolate Schema assembly and
# bounded name ranges so each process releases that state on exit.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
exit 0
fi
case "$TEST_SHARD" in
app-*)
# A single long-lived app test process retains every constructed
# command tree in framework registries. Each partition is its own
# job, so that state is released when the process exits and the
# partitions run concurrently instead of end to end. The helper
# still verifies that the partition patterns cover every top-level
# test exactly once before running the one it was asked for.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
exit 0
;;
esac
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
# give them a dedicated package timeout on slower hosted runners.
timeout_budget=12m
@@ -711,7 +827,7 @@ jobs:
failed=0
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
for shard in \
"changed packages:$FOCUSED_RESULT" \
"focused shards:$FOCUSED_RESULT" \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT" \
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
@@ -740,7 +856,7 @@ jobs:
release_expected=success
fi
for shard in \
"changed packages:$FOCUSED_RESULT:$focused_expected" \
"focused shards:$FOCUSED_RESULT:$focused_expected" \
"race shards:$RACE_RESULT:$race_expected" \
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
@@ -911,7 +1027,7 @@ jobs:
coverage-current:
name: Coverage (current)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -926,10 +1042,6 @@ jobs:
with:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -951,41 +1063,33 @@ jobs:
- name: Build
run: make build
- name: Run current unit tests with coverage
- name: Run scoped unit tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
run: |
set -euo pipefail
if [ "$FULL_SUITE" = true ]; then
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
else
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
"${impacted_packages[@]}"
fi
"${impacted_packages[@]}"
fi
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
go tool cover -func=coverage.txt
@@ -998,6 +1102,66 @@ jobs:
path: coverage.txt
retention-days: 1
coverage-current-full:
name: "Coverage (current: ${{ matrix.shard }})"
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app
- cli
- generators
- helpers
- remaining
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
# Each shard keeps -p 1 so the authoritative measurement stays serial
# inside one instrumented process group; shards run on isolated runners,
# and scripts/ci/test-packages.sh verify proves the shard union equals
# the previous single full-suite package set exactly once.
- name: Run current shard tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
COVERAGE_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -count=1 -p 1 \
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
-covermode=atomic \
"${packages[@]}"
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
- name: Upload current shard coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-current-shard-${{ matrix.shard }}
path: coverage-shard-${{ matrix.shard }}.txt
retention-days: 1
coverage-supporting:
name: Coverage (supporting)
needs: lint
@@ -1051,14 +1215,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -1076,7 +1237,34 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
# The merge-base full-suite profile is a pure function of the base
# commit. Reuse the profile published by the last green push run of
# exactly that commit instead of re-running the whole suite; any key
# mismatch falls back to authoritative recomputation. Exact key only,
# never prefix fallback: a near-miss profile would compare the
# candidate against the wrong commit.
- name: Restore cached merge-base coverage profile
id: baseline-cache
if: needs.lint.outputs.full_suite == 'true'
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
- name: Materialize cached merge-base coverage profile
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
cp coverage-cache.txt coverage-base.txt
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
@@ -1125,6 +1313,21 @@ jobs:
fi
)
- name: Prepare merge-base coverage profile cache
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: |
set -eu
test -s coverage-base.txt
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
cp coverage-base.txt coverage-cache.txt
- name: Save merge-base coverage profile cache
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
- name: Upload baseline coverage profile
uses: actions/upload-artifact@v4
with:
@@ -1137,6 +1340,7 @@ jobs:
needs:
- lint
- coverage-current
- coverage-current-full
- coverage-supporting
- coverage-baseline
- coverage-darwin
@@ -1152,6 +1356,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
CURRENT_RESULT: ${{ needs.coverage-current.result }}
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
@@ -1159,6 +1364,7 @@ jobs:
run: |
failed=0
current_expected=success
current_full_expected=skipped
supporting_expected=skipped
baseline_expected=success
native_expected=skipped
@@ -1166,6 +1372,8 @@ jobs:
current_expected=skipped
baseline_expected=skipped
elif [ "$FULL_SUITE" = true ]; then
current_expected=skipped
current_full_expected=success
supporting_expected=success
fi
if [ "$CHANGELOG_ONLY" != true ] &&
@@ -1176,6 +1384,7 @@ jobs:
for profile in \
"current:$CURRENT_RESULT:$current_expected" \
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
"baseline:$BASELINE_RESULT:$baseline_expected"
do
@@ -1211,6 +1420,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
id: setup-go
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
uses: actions/setup-go@v5
with:
@@ -1234,11 +1444,12 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profile
- name: Download current coverage profiles
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-current-profile
pattern: coverage-current-*
merge-multiple: true
path: .
- name: Download supporting coverage profiles
@@ -1255,6 +1466,26 @@ jobs:
name: coverage-baseline-profile
path: .
# Shard profiles cover disjoint package sets, so their block-level
# concatenation is the same candidate profile one serial run produced.
# Every expected shard must be present; a missing shard would silently
# shrink the scope-matched overall comparison.
- name: Assemble full-suite coverage profile
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
shell: bash
run: |
set -euo pipefail
test ! -f coverage.txt
for shard in app cli generators helpers remaining; do
profile="coverage-shard-$shard.txt"
test -f "$profile"
test "$(head -n 1 "$profile")" = "mode: atomic"
done
printf 'mode: atomic\n' > coverage.txt
for shard in app cli generators helpers remaining; do
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
done
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
env:
@@ -1275,6 +1506,26 @@ jobs:
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
# Publish this push's full-suite profile as the merge-base cache for
# future PRs whose merge-base is exactly this commit. Saved only after
# the gate passed so a broken run never becomes a baseline. Both producer
# and consumer use coverage-cache.txt because the cache version includes
# the configured path as well as the compression tool.
- name: Prepare push coverage profile as merge-base cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
run: |
set -eu
test -s coverage.txt
test "$(head -n 1 coverage.txt)" = "mode: atomic"
cp coverage.txt coverage-cache.txt
- name: Save push coverage profile as merge-base cache
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/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
run: |
+4
View File
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
.gitignore
.worktrees/
.qoder/
_logs/
_docs/
_output/
vendor/
# Secrets & credentials
.env
+31
View File
@@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.2] - 2026-08-17
### Added
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
### Changed
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
### Fixed
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
## [1.0.59-beta.1] - 2026-08-14
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.6"
version "1.0.59-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.58-beta.6/dws-darwin-arm64.tar.gz"
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
end
def install
+1 -1
View File
@@ -18,7 +18,7 @@ help:
@printf "Available targets:\n"
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\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 lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
+66 -2
View File
@@ -15,12 +15,76 @@ package main
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
)
var exit = os.Exit
var (
appExecute = app.ExecuteWithTelemetry
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
clitrack.New(cfg).Run(execute, exitCode)
}
)
// trackedExitError tells clitrack that the command failed without asking it to
// print the error a second time. The already-rendered message is published via
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
type trackedExitError struct{}
func (trackedExitError) Error() string { return "" }
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
return clitrack.Config{
PID: "wcCRwZ",
App: "dws",
Version: app.RawVersion(),
UID: identity.UserID,
Username: identity.UserName,
NoCommandLine: true,
NoCwd: true,
NoAutomaticDimensions: true,
ExtraFields: func() map[string]string {
fields := map[string]string{"c9": *commandPath}
if identity.CorpID != "" {
fields["c10"] = identity.CorpID
}
if *errorMessage != "" {
fields["c5"] = *errorMessage
}
return fields
},
}
}
func telemetryOptedOut() bool {
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
}
func main() {
exit(app.Execute())
optedOut := telemetryOptedOut()
identity := app.TelemetryIdentity{}
if !optedOut {
identity = resolveTelemetryIdentity(os.Args[1:])
}
exitCode := 0
commandPath := "dws"
errorMessage := ""
cfg := trackerConfig(identity, &commandPath, &errorMessage)
if optedOut {
cfg.PID = ""
}
trackRun(
cfg,
func() error {
exitCode, commandPath, errorMessage = appExecute()
if exitCode != 0 {
return trackedExitError{}
}
return nil
},
func(error) int { return exitCode },
)
}
+206 -13
View File
@@ -1,27 +1,220 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices"
"sort"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
)
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
previousExit := exit
previousArgs := os.Args
t.Cleanup(func() {
exit = previousExit
os.Args = previousArgs
})
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
for _, wantCode := range []int{0, 1, 3, 5} {
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "")
wantError := ""
if wantCode != 0 {
wantError = "synthetic failure"
}
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
if strings.Join(args, " ") != "sheet read --profile corp-a" {
t.Fatalf("telemetry identity args = %#v", args)
}
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
})
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
called := false
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
called = true
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
}
if cfg.Version != app.RawVersion() {
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
}
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
}
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
}
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
}
err := execute()
if wantCode == 0 && err != nil {
t.Fatalf("successful tracked execute error = %v", err)
}
if wantCode != 0 && (err == nil || err.Error() != "") {
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
}
if gotCode := exitCode(err); gotCode != wantCode {
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
}
fields := cfg.ExtraFields()
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
}
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
}
})
main()
if !called {
t.Fatalf("trackRun was not called for exit code %d", wantCode)
}
})
}
}
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
commandPath := "version"
errorMessage := ""
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
if cfg.UID != "" {
t.Fatalf("empty identity UID = %q", cfg.UID)
}
if cfg.Username != "" {
t.Fatalf("empty identity Username = %q", cfg.Username)
}
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
t.Fatalf("empty organization fields = %#v", fields)
}
}
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
called := false
code := -1
exit = func(value int) {
trackRun(clitrack.Config{}, func() error {
called = true
code = value
return nil
}, nil)
if !called {
t.Fatal("default tracker did not execute callback")
}
os.Args = []string{"dws", "version"}
}
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
t.Setenv("DO_NOT_TRACK", "1")
testseam.Swap(t, &os.Args, []string{"dws", "version"})
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
return app.TelemetryIdentity{}
})
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
t.Fatalf("opted-out tracker config = %#v", cfg)
}
if err := execute(); err != nil {
t.Fatalf("opted-out execution failed: %v", err)
}
if code := exitCode(nil); code != 0 {
t.Fatalf("opted-out exit code = %d, want 0", code)
}
})
main()
if !called || code != 0 {
t.Fatalf("main exit = called %v, code %d", called, code)
}
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
t.Setenv("SHELL", "/bin/zsh")
t.Setenv("TERM_SESSION_ID", "stable-session")
t.Setenv("TMUX_PANE", "%42")
t.Setenv("LANG", "zh_CN.UTF-8")
t.Setenv("LC_ALL", "zh_CN.UTF-8")
t.Chdir(t.TempDir())
requestBody := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
requestBody <- body
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
commandPath := "sheet read"
errorMessage := ""
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
cfg.Endpoint = server.URL
cfg.FlushTimeout = time.Second
clitrack.New(cfg).Run(func() error { return nil }, nil)
var body []byte
select {
case body = <-requestBody:
case <-time.After(time.Second):
t.Fatal("timed out waiting for telemetry request")
}
var envelope map[string]string
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("decode telemetry request %q: %v", body, err)
}
decoded, err := url.QueryUnescape(envelope["gokey"])
if err != nil {
t.Fatalf("decode gokey: %v", err)
}
globalFields, err := url.ParseQuery(decoded)
if err != nil {
t.Fatalf("parse global telemetry fields: %v", err)
}
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
if err != nil {
t.Fatalf("parse event telemetry fields: %v", err)
}
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
for key, want := range map[string]string{
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
} {
if got := globalFields.Get(key); got != want {
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
}
}
for key, want := range map[string]string{
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
} {
if got := eventFields.Get(key); got != want {
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
}
}
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
if globalFields.Has(key) {
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
}
}
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
if eventFields.Has(key) {
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
}
}
}
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
t.Helper()
got := make([]string, 0, len(fields))
for key := range fields {
got = append(got, key)
}
sort.Strings(got)
if !slices.Equal(got, want) {
t.Fatalf("telemetry keys = %v, want %v", got, want)
}
}
+15 -2
View File
@@ -201,8 +201,21 @@ base_ref=$(git merge-base HEAD origin/main)
standard PR, CI derives changed packages and their reverse-dependency test
closure, then generates candidate and merge-base profiles with the same test
scope and `coverpkg`. High-risk and protected-main runs use the complete
profiles. Supporting and (when platform-selected) native profiles are
generated before the aggregate `Coverage` context evaluates them. The
profiles. The complete candidate profile is produced by disjoint per-shard
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
`-p 1` inside each shard; `verify` proves the shard union equals the
full-suite scope exactly once) and concatenated in the aggregate job before
enforcement. The complete merge-base profile is restored from an exact-key
cache written by the last green `main` push of that same commit (key:
merge-base SHA plus resolved Go version); any miss falls back to recomputing
it in a merge-base worktree. The trusted `main` producer and PR consumer use
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
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
statements. Overall coverage remains an unrounded, zero-tolerance,
scope-matched merge-base non-regression check. Candidate and baseline profiles
+1
View File
@@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
#### 1.2.2 编排 / 执行字段(不算声明)
+47 -1
View File
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
- `Constraints`
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
- `ConstParams`(载荷声明;不上用户 flag 表)
@@ -673,6 +673,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
- 构造时拒绝 `InputSourceInvalid`。
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
```go
// LeafSpec / helpers
Flags: []helpers.LeafFlag{
{
Name: "content",
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
Bind: "content",
Input: []string{corecmd.InputFile, corecmd.InputStdin},
},
}
// shortcut
Flags: []shortcut.Flag{
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
Input: []string{"file", "stdin"}},
}
```
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
**作者守则**:
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
| 维度 | 今日 | 目标 |
|---|---|---|
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
核心 FlagSpec 故意没有:
- `Bind`;
+197 -4
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"generated_at": "2026-08-14T13:50:36.324505",
"count": 418,
"results": [
{
"suite": "semantic",
@@ -3659,11 +3659,204 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "wiki",
"command": "+delete-space",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+feed-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+move",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+move-to-drive",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-copy",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+resolve-space",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+wiki-new-doc",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
"availability": "available"
}
]
}
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DWS Wiki Shortcut 全景评审</title>
<style>
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
</style>
</head>
<body><main>
<header class="hero">
<h1>DWS Wiki Shortcut 全景评审</h1>
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
</header>
<div class="grid">
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
</div>
<section class="card">
<h2>结论先行</h2>
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
</section>
<section class="card">
<h2>13 项基线任务逐条映射</h2>
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
</tbody></table></div>
</section>
<section class="card">
<h2>DWS 可挖掘的 7 个额外场景</h2>
<div class="two">
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
</div>
</section>
<section class="card">
<h2>隐藏问题与修复</h2>
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
</tbody></table>
</section>
<section class="card">
<h2>真实数据 E2E 证据矩阵</h2>
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
</tbody></table></div>
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
</section>
<section class="card">
<h2>成功判定与发布门</h2>
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
</section>
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
</main>
<script>
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
</script></body></html>
+4
View File
@@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
go 1.25.9
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
require (
github.com/Microsoft/go-winio v0.6.2
github.com/RealAlexandreAI/json-repair v0.0.15
@@ -16,7 +18,9 @@ require (
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.5
github.com/zalando/go-keyring v0.2.8
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
+2
View File
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+2 -1
View File
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
return nil
+1 -1
View File
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
busState := "personal bus stopped"
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
+40
View File
@@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
}
}
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
search.Flags().String("sender", "", "sender target")
search.Flags().String("start", "", "start time")
root.AddCommand(chat)
chat.AddCommand(search)
orig := fmt.Errorf("unknown flag: --from")
err := flagErrorWithSuggestions(search, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
}
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
t.Fatalf("Message = %q", ae.Message)
}
for _, flag := range []string{"from-file", "from-user"} {
t.Run(flag, func(t *testing.T) {
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
var structured *apperrors.Error
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
}
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
}
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
t.Fatalf("error = %q, want original flag --%s", err, flag)
}
})
}
}
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
+113 -1
View File
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
@@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
})
}
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
return ctx, state, func() {}
})
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
cmd.SetContext(ctx)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
return cmd
})
}
interrupted := func(primaryCompleted bool) *processSignalState {
return &processSignalState{
interruption: &processInterruption{signal: os.Interrupt},
primaryCompletedAtSignal: primaryCompleted,
}
}
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
var stdout bytes.Buffer
install(t, interrupted(false), &stdout, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
t.Fatal("preparse failure reached command execution")
return nil, nil
})
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
}
})
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
var stdout bytes.Buffer
install(t, interrupted(false), &stdout, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
}
})
t.Run("late hook error preserves emitted result", func(t *testing.T) {
install(t, interrupted(true), io.Discard, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, errors.New("late hook failed")
})
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
t.Fatalf("late hook result = code %d summary %q", code, summary)
}
})
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
install(t, interrupted(false), io.Discard, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, nil
})
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
}
})
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
var original bytes.Buffer
install(t, interrupted(false), io.Discard, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
}
return cmd, publicationErr
})
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
}
})
}
type frameworkPanicWriter struct{}
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
+8 -2
View File
@@ -52,6 +52,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
case "query_records":
return `{"success":true,"status":"success","error":{},"data":{}}`
case "search_mail_users":
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
case "search_dept_by_keyword":
@@ -63,7 +65,7 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
return `{"revertedToVersion":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
@@ -479,7 +481,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
if err != nil {
t.Fatalf("alias execution failed: %v", err)
}
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
if alias == "open-conversation-id" {
if ctx == nil || len(ctx.Corrections) != 0 {
t.Fatalf("alias corrections = %#v", ctx)
}
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
t.Fatalf("alias corrections = %#v", ctx)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
+26
View File
@@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
}
}
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
valid bool
}{
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := preparseProfileFlag(tc.args); got != tc.want {
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
}
_, specified, valid := preparseProfileSelection(tc.args)
if !specified || valid != tc.valid {
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
}
})
}
}
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
+132 -24
View File
@@ -80,10 +80,19 @@ var (
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
rootEmitResult = output.EmitResult
rootInstallProcessSignalContext = installProcessSignalContext
)
// Execute runs the root command and returns the process exit code.
func Execute() (exitCode int) {
func Execute() int {
exitCode, _, _ := ExecuteWithTelemetry()
return exitCode
}
// ExecuteWithTelemetry runs the root command and additionally returns a
// privacy-safe command path and error summary for the official CLI entrypoint.
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
commandPath = "dws"
var (
root *cobra.Command
executed *cobra.Command
@@ -91,12 +100,16 @@ func Execute() (exitCode int) {
)
defer func() {
if r := recover(); r != nil {
errorMessage = "internal panic"
target := executed
if target == nil && root != nil {
if found, _, err := root.Find(os.Args[1:]); err == nil {
target = found
}
}
if target != nil {
commandPath = telemetryCommandPath(target)
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
exitCode = code
if target != nil {
@@ -121,6 +134,7 @@ func Execute() (exitCode int) {
CloseFileLogger()
if executed != nil {
if err := closeOutputSink(executed); err != nil {
errorMessage = telemetryErrorSummary(err)
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
exitCode = code
} else {
@@ -144,7 +158,9 @@ func Execute() (exitCode int) {
agentMetadata := readAgentMetadataSnapshot()
if err := agentMetadata.validationError(); err != nil {
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
return apperrors.ExitCode(err)
errorMessage = telemetryErrorSummary(err)
exitCode = apperrors.ExitCode(err)
return
}
timing := NewTimingCollector()
@@ -162,12 +178,13 @@ func Execute() (exitCode int) {
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
defer stopSignals()
initStart := time.Now()
engine := newPipelineEngine()
root = rootNewRootCommandWithEngine(ctx, engine)
commandPath = telemetryCommandPath(root)
timing.Record("cmd_init", time.Since(initStart))
// Run PreParse handlers on raw argv before Cobra parses flags.
@@ -182,15 +199,23 @@ func Execute() (exitCode int) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(target, result)
if emitErr == nil {
return code
errorMessage = telemetryErrorSummary(err)
exitCode = code
return
}
}
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
errorMessage = telemetryErrorSummary(err)
exitCode = apperrors.ExitCode(err)
return
}
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
var err error
executed, err = rootExecuteCommand(root)
if executed != nil {
commandPath = telemetryCommandPath(executed)
}
// PersistentPostRunE normally commits or aborts the transactional output
// sink. Finalize once more at the process boundary so custom execution
// seams, embedding callers, or future hook changes cannot leave publication
@@ -222,7 +247,9 @@ func Execute() (exitCode int) {
// successfully emitted result into a contradictory 130/143 process
// status; likewise, a failed publication must retain its internal
// error code instead of being relabelled as cancellation.
return code
errorMessage = telemetryErrorSummary(interrupted)
exitCode = code
return
}
}
var publicationErr *outputPublicationError
@@ -233,20 +260,26 @@ func Execute() (exitCode int) {
if err != nil {
if executed == nil {
executed = root
commandPath = telemetryCommandPath(root)
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if stderrors.As(err, &publicationErr) {
errorMessage = telemetryErrorSummary(publicationErr)
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
if emitErr == nil {
return failureCode
exitCode = failureCode
return
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
}
return apperrors.ExitCode(publicationErr)
exitCode = apperrors.ExitCode(publicationErr)
return
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
return code
errorMessage = telemetryErrorSummary(err)
exitCode = code
return
}
err = rewordRequiredFlagError(err)
var raw apperrors.RawStderrError
@@ -254,7 +287,9 @@ func Execute() (exitCode int) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(executed, result)
if emitErr == nil {
return code
errorMessage = telemetryErrorSummary(err)
exitCode = code
return
}
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
@@ -264,12 +299,42 @@ func Execute() (exitCode int) {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
errorMessage = telemetryErrorSummary(err)
exitCode = apperrors.ExitCode(err)
return
}
if code, emitted := output.StoredExitCode(resultStore); emitted {
return code
exitCode = code
return
}
return 0
return
}
func telemetryCommandPath(command *cobra.Command) string {
if command == nil {
return "dws"
}
path := strings.TrimSpace(command.CommandPath())
root := command.Root()
rootName := strings.TrimSpace(root.Name())
if path == rootName {
return rootName
}
if rootName != "" {
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
}
return path
}
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
if root == nil {
return "dws"
}
command, _, err := root.Find(args)
if err != nil || command == nil {
return telemetryCommandPath(root)
}
return telemetryCommandPath(command)
}
// emitEarlyAgentMetadataValidationError preserves each built-in command's
@@ -511,6 +576,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
msgWithTail := errMsg + tail
if flag, ok := unknownFlagName(errMsg); ok && flag == "from" {
switch cmd.CommandPath() {
case "dws chat +search-msg", "dws chat +chat-messages":
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
apperrors.WithReason("ambiguous_flag"),
apperrors.WithCause(err),
apperrors.WithActions(
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
}
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
reason := "blocked_flag"
@@ -579,15 +660,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
if cmd == nil {
return "", "", false
}
const prefix = "unknown flag: --"
idx := strings.Index(errMsg, prefix)
if idx < 0 {
flag, ok := unknownFlagName(errMsg)
if !ok {
return "", "", false
}
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
flag = flag[:i]
}
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
if !ok {
return "", "", false
@@ -602,6 +678,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
return "", "", false
}
func unknownFlagName(errMsg string) (string, bool) {
const prefix = "unknown flag: --"
idx := strings.Index(errMsg, prefix)
if idx < 0 {
return "", false
}
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
flag = flag[:i]
}
return flag, flag != ""
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
var raw apperrors.RawStderrError
if stderrors.As(err, &raw) {
@@ -935,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
}
func preparseProfileFlag(args []string) string {
profile, _, valid := preparseProfileSelection(args)
if !valid {
return ""
}
return profile
}
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
args, _ = normalizeProfileFlagArgs(args)
valid = true
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--profile" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case arg == "--profile":
specified = true
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
profile = ""
valid = false
continue
}
profile = strings.TrimSpace(args[i+1])
valid = profile != ""
i++
case strings.HasPrefix(arg, "--profile="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
specified = true
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
valid = profile != ""
}
}
return ""
return profile, specified, valid
}
func normalizeProcessProfileArgs() func() {
+45 -10
View File
@@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
var executedLeaf *cobra.Command
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
sheet := &cobra.Command{Use: "sheet"}
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
sheet.AddCommand(executedLeaf)
root.AddCommand(sheet)
return root
}
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
if code := Execute(); code == 0 {
t.Fatal("pre-parse conflict returned zero")
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
}
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
if code := Execute(); code != 5 {
t.Fatalf("panic Execute code = %d", code)
os.Args = []string{"dws", "sheet", "read"}
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
}
}
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
if got := telemetryCommandPath(nil); got != "dws" {
t.Fatalf("nil command path = %q, want dws", got)
}
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
t.Fatalf("nil root command path = %q, want dws", got)
}
root := &cobra.Command{Use: "dws"}
sheet := &cobra.Command{Use: "sheet"}
read := &cobra.Command{Use: "read <range>"}
sheet.AddCommand(read)
root.AddCommand(sheet)
if got := telemetryCommandPath(root); got != "dws" {
t.Fatalf("root command path = %q, want dws", got)
}
if got := telemetryCommandPath(read); got != "sheet read" {
t.Fatalf("leaf command path = %q, want sheet read", got)
}
root.PersistentFlags().String("profile", "", "")
read.Aliases = []string{"get"}
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
t.Fatalf("pre-execution command path = %q, want sheet read", got)
}
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
}
}
+30
View File
@@ -2,6 +2,7 @@ package app
import (
"fmt"
"io"
"strings"
"text/tabwriter"
@@ -13,6 +14,12 @@ import (
"github.com/spf13/pflag"
)
// feedbackFormURL points at the DingTalk Notable form collecting dws CLI
// user-experience feedback. The source parameter tags submissions that
// originated from the CLI help output so they can be told apart from
// responses arriving through other channels.
const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli"
func configureRootHelp(root *cobra.Command) {
if root == nil {
return
@@ -101,6 +108,29 @@ func renderRootHelp(root *cobra.Command) {
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w, tui.Dim(long))
}
// Keep the feedback entry last: everything above it is operational guidance
// an agent acts on, while the survey is addressed to human readers who
// scroll to the end.
_, _ = fmt.Fprintln(w)
renderRootFeedback(w)
}
// renderRootFeedback prints the user-experience survey entry. The URL occupies
// its own line and is never wrapped or padded through a tabwriter: it is longer
// than the help rule width, and breaking it would stop terminals from
// recognizing it as a clickable hyperlink. Soft wrapping performed by the
// terminal itself keeps the link intact.
//
// The label is intentionally not routed through i18n. Everything surrounding it
// in this listing — service descriptions, utility descriptions, global flag
// usage — is hardcoded Chinese, so translating this one line would render it in
// English on any host whose LANG is not zh_*, leaving a single English line
// inside an otherwise Chinese screen.
func renderRootFeedback(w io.Writer) {
_, _ = fmt.Fprintln(w, tui.Section("Feedback:"))
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)"))
_, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL))
}
func renderRootGlobalFlags(root *cobra.Command) {
+45 -2
View File
@@ -53,6 +53,49 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
}
}
func TestRootHelpShowsFeedbackEntry(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("root help: %v\n%s", err, out.String())
}
help := out.String()
// The label stays Chinese regardless of the host locale: the rest of this
// listing is hardcoded Chinese, so a translated label would show up as a
// lone English line on any host whose LANG is not zh_*.
for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} {
if !strings.Contains(help, want) {
t.Fatalf("root help missing %q:\n%s", want, help)
}
}
// The form URL is longer than the help rule width; it must stay on a
// single unbroken line so terminals keep recognizing it as a hyperlink.
if !strings.Contains(help, "\n "+feedbackFormURL+"\n") {
t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help)
}
}
// The feedback entry is deliberately root-only: this CLI is driven mostly by
// AI agents, and repeating a survey link in every subcommand help would be
// pure context noise. Guard the boundary so a future refactor cannot move the
// rendering into the shared subcommand help path unnoticed.
func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"chat", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat help: %v\n%s", err, out.String())
}
if help := out.String(); strings.Contains(help, feedbackFormURL) {
t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help)
}
}
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
@@ -94,8 +137,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
}
}
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
+10 -3
View File
@@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
testseam.Protect(t, &os.Args)
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
t.Chdir(dir)
// Keep argv portable: an absolute Windows path contains a volume colon,
// which the CLI intentionally rejects as unsafe user-supplied output.
target := "result.json"
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
originalInfo, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
@@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
}
assertOutputFile(t, target, "original", 0o640)
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
assertNoOutputTemps(t, target)
}
+1 -1
View File
@@ -27,7 +27,7 @@ import (
"github.com/spf13/cobra"
)
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
+79 -3
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 399
publicShortcutCount = 418
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 401
schemaPublishedShortcutCount = 420
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 399
publiclyDeliveredShortcutCount = 418
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
}
reason := schemaContractString(leaf["interface_reason"])
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
if !strings.Contains(reason, fragment) {
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
}
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
}
}
}
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
checked := 0
for _, declared := range shortcut.All() {
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
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 {
argv, err := cli.ParseAgentExampleArgv(example)
if err != nil {
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
}
for _, flag := range declared.Flags {
if !flag.Required {
continue
}
names := append([]string{flag.Name}, flag.Aliases...)
if !schemaExampleHasLongFlag(argv, names...) {
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
}
}
}
}
if checked != 20 {
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
}
}
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
for _, argument := range argv {
for _, name := range names {
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
return true
}
}
}
return false
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
@@ -207,7 +279,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
})
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
}
}
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
+3 -3
View File
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
})
}
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
for _, tc := range []struct {
name string
signal syscall.Signal
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
}
}
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
@@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
}
}
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
stderrors "errors"
"fmt"
"regexp"
"strings"
"unicode"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
const maxTelemetryErrorRunes = 200
var (
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
)
func telemetryErrorSummary(err error) string {
if err == nil {
return ""
}
var patError *apperrors.PATError
if stderrors.As(err, &patError) {
return "permission error"
}
var rawError apperrors.RawStderrError
if stderrors.As(err, &rawError) {
return "raw stderr error"
}
if isUnknownCommandError(err) {
return "unknown command"
}
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
return "unknown flag: " + match[1]
}
return sanitizeTelemetryErrorText(err.Error())
}
func telemetryPanicMessages(value any) (display, summary string) {
return fmt.Sprintf("internal panic: %v", value), "internal panic"
}
func sanitizeTelemetryErrorText(message string) string {
message = output.SanitizeForTerminal(message)
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
message = redactTelemetryQuotedText(message)
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
var hasLetter, hasDigit bool
for _, r := range value {
hasLetter = hasLetter || unicode.IsLetter(r)
hasDigit = hasDigit || unicode.IsDigit(r)
}
if hasLetter && hasDigit {
return "<id>"
}
return value
})
message = strings.Join(strings.Fields(message), " ")
return truncateTelemetryText(message, maxTelemetryErrorRunes)
}
func redactTelemetryQuotedText(message string) string {
var result strings.Builder
runes := []rune(message)
for index := 0; index < len(runes); {
quote := runes[index]
if quote != '\'' && quote != '"' && quote != '`' {
result.WriteRune(quote)
index++
continue
}
result.WriteRune(quote)
result.WriteString("<redacted>")
index++
escaped := false
for index < len(runes) {
current := runes[index]
index++
if escaped {
escaped = false
continue
}
if current == '\\' && quote != '`' {
escaped = true
continue
}
if current == quote {
result.WriteRune(quote)
break
}
}
}
return result.String()
}
func truncateTelemetryText(message string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(message)
if len(runes) <= maxRunes {
return message
}
if maxRunes <= 3 {
return string(runes[:maxRunes])
}
return string(runes[:maxRunes-3]) + "..."
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type telemetryRawError string
func (e telemetryRawError) Error() string { return string(e) }
func (e telemetryRawError) RawStderr() string { return string(e) }
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
for _, tc := range []struct {
name string
err error
want string
}{
{name: "nil", want: ""},
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := telemetryErrorSummary(tc.err); got != tc.want {
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
}
})
}
}
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
message := "\x1b[31mfailed\x1b[0m " +
"--client-secret very-secret " +
"--access-token access-secret " +
"Authorization: Bearer abcdefghijklmnop1234 " +
"url=https://example.test/path?token=secret " +
`body={"access_token":"secret"} ` +
`user="Alice" email=alice@example.test phone=13800138000 ` +
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
got := sanitizeTelemetryErrorText(message)
for _, secret := range []string{
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
"./private/secrets.txt",
} {
if strings.Contains(got, secret) {
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
}
}
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
if !strings.Contains(got, marker) {
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
}
}
}
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
got := sanitizeTelemetryErrorText(message)
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
}
display, summary := telemetryPanicMessages("token-secret")
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
t.Fatalf("panic messages = display %q summary %q", display, summary)
}
if got := truncateTelemetryText("value", 0); got != "" {
t.Fatalf("zero-limit truncation = %q", got)
}
if got := truncateTelemetryText("value", 3); got != "val" {
t.Fatalf("short-limit truncation = %q", got)
}
}
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
const opaqueWord = "abcdefghijklmnop"
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
t.Fatalf("escaped quote sanitization = %q", got)
}
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"fmt"
"strings"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
// record that may be attached to a CLI execution event.
type TelemetryIdentity struct {
UserID string
UserName string
CorpID string
}
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
// selected by args. Multi-profile executions are attributed to the current
// default profile. Resolution is deliberately best-effort: telemetry must not
// refresh credentials or change command behavior when local auth data is
// missing, invalid, or unreadable.
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
defer func() {
if recover() != nil {
identity = TelemetryIdentity{}
}
}()
selector, specified, valid := preparseProfileSelection(args)
if specified && !valid {
return TelemetryIdentity{}
}
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
if err != nil || profile == nil {
return TelemetryIdentity{}
}
return TelemetryIdentity{
UserID: strings.TrimSpace(profile.UserID),
UserName: strings.TrimSpace(profile.UserName),
CorpID: strings.TrimSpace(profile.CorpID),
}
}
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
selector = strings.TrimSpace(selector)
if selector == "" || !strings.Contains(selector, ",") {
return telemetryResolveProfileMetadata(configDir, selector)
}
// A local profile name may itself contain a comma. Match the runtime
// resolver by trying the full selector before interpreting it as CSV.
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
return profile, nil
}
for _, part := range strings.Split(selector, ",") {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
}
profile, err := telemetryResolveProfileMetadata(configDir, part)
if err != nil {
return nil, err
}
if profile == nil {
return nil, fmt.Errorf("profile %q not found", part)
}
}
return telemetryResolveProfileMetadata(configDir, "")
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"errors"
"reflect"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
profiles := map[string]*authpkg.ProfileMetadata{
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
}
for _, tc := range []struct {
name string
args []string
profiles map[string]*authpkg.ProfileMetadata
wantCalls []string
want TelemetryIdentity
}{
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
} {
t.Run(tc.name, func(t *testing.T) {
var calls []string
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
if configDir != "/telemetry-config" {
t.Fatalf("resolver config dir = %q", configDir)
}
calls = append(calls, selector)
profile := tc.profiles[selector]
if profile == nil {
return nil, errors.New("profile not found")
}
clone := *profile
return &clone, nil
})
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
}
if !reflect.DeepEqual(calls, tc.wantCalls) {
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
}
})
}
}
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
for _, args := range [][]string{
{"version", "--profile"},
{"--profile=corp-a", "version", "--profile="},
{"--profile", "--debug", "version"},
} {
t.Run(strings.Join(args, "_"), func(t *testing.T) {
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
t.Fatal("invalid profile syntax attempted metadata resolution")
return nil, nil
})
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
t.Fatalf("invalid profile identity = %#v", got)
}
})
}
}
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
fail := errors.New("metadata unavailable")
for _, tc := range []struct {
name string
resolve func(string, string) (*authpkg.ProfileMetadata, error)
}{
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
t.Fatalf("failed-closed identity = %#v", got)
}
})
}
}
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
if selector == "corp-a" {
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
}
return nil, errors.New("profile not found")
})
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
t.Fatalf("invalid multi-profile identity = %#v", got)
}
}
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
switch selector {
case "corp-a,,corp-b":
return nil, errors.New("not a literal profile")
case "corp-a":
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
case "missing":
return nil, nil
default:
return nil, errors.New("unexpected selector")
}
})
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
t.Fatalf("empty multi-profile selector error = %v", err)
}
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("missing multi-profile selector error = %v", err)
}
}
@@ -0,0 +1,70 @@
// 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 auth
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// ProfileMetadata is the minimal, non-sensitive identity projection exposed to
// telemetry callers. It intentionally excludes profile names, client IDs,
// organization names, token material, and credential status.
type ProfileMetadata struct {
UserID string
UserName string
CorpID string
}
// ResolveProfileMetadataReadOnly resolves one identity exclusively from the
// non-sensitive profiles.json metadata. It deliberately avoids auth locks,
// token stores, Keychain access, migrations, quarantine renames, and writes.
// A missing metadata file or an empty current profile returns (nil, nil).
func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) {
data, err := profilesReadFile(ProfilesPath(configDir))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read profile metadata: %w", err)
}
var cfg ProfilesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse profile metadata: %w", err)
}
if cfg.Version > profilesMaxVersion {
return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion)
}
normalizeProfilesConfig(&cfg)
selector = strings.TrimSpace(selector)
if selector == "" {
selector = strings.TrimSpace(cfg.CurrentProfile)
if selector == "" {
return nil, nil
}
}
profile, _, err := resolveProfileSelection("", &cfg, selector)
if err != nil {
return nil, err
}
return &ProfileMetadata{
UserID: profile.UserID,
UserName: profile.UserName,
CorpID: profile.CorpID,
}, nil
}
@@ -0,0 +1,97 @@
// 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 auth
import (
"errors"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) {
const metadata = `{
"version": 3,
"currentProfile": "corp-a:user-a",
"profiles": [
{"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"},
{"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"}
]
}`
reads := 0
testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) {
reads++
if !strings.HasSuffix(path, profilesJSONFile) {
t.Fatalf("metadata path = %q", path)
}
return []byte(metadata), nil
})
current, err := ResolveProfileMetadataReadOnly("/config", "")
if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" {
t.Fatalf("current metadata profile = %#v, %v", current, err)
}
explicit, err := ResolveProfileMetadataReadOnly("/config", "beta")
if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" {
t.Fatalf("explicit metadata profile = %#v, %v", explicit, err)
}
if reads != 2 {
t.Fatalf("profile metadata reads = %d, want 2", reads)
}
}
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) {
fail := errors.New("read failed")
for _, tc := range []struct {
name string
read func(string) ([]byte, error)
wantErr string
}{
{name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }},
{name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"},
{name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"},
{name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"},
{name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &profilesReadFile, tc.read)
testseam.Swap(t, &profilesRename, func(string, string) error {
t.Fatal("read-only metadata resolution attempted a quarantine rename")
return nil
})
got, err := ResolveProfileMetadataReadOnly("/config", "")
if tc.wantErr == "" {
if err != nil || got != nil {
t.Fatalf("read-only metadata = %#v, %v", got, err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil {
t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr)
}
})
}
}
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) {
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) {
return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil
})
profile, err := ResolveProfileMetadataReadOnly("/config", "missing")
if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("unknown read-only profile = %#v, %v", profile, err)
}
}
+18 -12
View File
@@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
aliasMap := make(map[string]string)
blockedSet := make(map[string]bool)
excludedSet := make(map[string]bool)
claimedRealSet := make(map[string]bool)
pendingReview := ov.Confirm || ov.Investigate
for boundFlag, conceptID := range ov.Bind {
@@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
if len(candidates) == 0 {
continue
}
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
claimedRealSet[m] = true
}
}
for _, exclude := range concept.Excludes {
excludedSet[cmdutil.Morph(exclude)] = true
}
visible := distinctRealNames(candidates, true)
var canon string
switch len(visible) {
@@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
}
aliasMap[m] = canon
}
// Excludes are not passive prose: once this concept is active on a
// reviewed command, a non-real excluded spelling is protected from
// downstream fuzzy correction. A real flag is left alone because it
// already has an independently valid command-local meaning.
for _, exclude := range concept.Excludes {
morphed := cmdutil.Morph(exclude)
if _, isReal := realByMorph[morphed]; !isReal {
excludedSet[morphed] = true
}
}
}
for excluded := range excludedSet {
if _, isAlias := aliasMap[excluded]; !isAlias {
blockedSet[excluded] = true
if _, isAlias := aliasMap[excluded]; isAlias {
continue
}
if claimedRealSet[excluded] {
continue
}
if _, isReal := realByMorph[excluded]; isReal {
continue
}
blockedSet[excluded] = true
}
// (b) Command scoped aliases override concept reductions.
+146 -265
View File
@@ -889,13 +889,9 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"cursor"},
},
{
CLIPath: "chat category add-conv",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat category add-conv",
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat category create",
@@ -920,13 +916,9 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"category-ids"},
},
{
CLIPath: "chat category remove-conv",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat category remove-conv",
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat category rename",
@@ -970,23 +962,19 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat conversation-info",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat group audit-join-validation",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
},
{
@@ -1006,12 +994,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group dismiss",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group get-by-group-id",
@@ -1020,12 +1005,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group invite-url",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group members",
@@ -1100,52 +1082,37 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group notice create",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice edit",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice get",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice list",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group quit",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group rename",
@@ -1161,23 +1128,17 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group set-admin",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"user-ids": "users",
"chat-id": "conversation-id",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
},
{
CLIPath: "chat group set-history",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group share-invite",
@@ -1191,151 +1152,111 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group transfer-owner",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group update-alias",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-icon",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-nick",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-settings",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-mute",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-icon",
Aliases: map[string]string{
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-nick",
Aliases: map[string]string{
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-settings",
Aliases: map[string]string{
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-mute",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat group-mute-member",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"user-ids": "users",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat group-role add",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role list",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role query-user",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role remove",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role remove-user",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role set-user",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role update",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat hide",
@@ -1369,12 +1290,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message add-emoji",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message add-favorite",
@@ -1391,12 +1310,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message add-text-emotion",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message combine-forward",
@@ -1427,8 +1344,6 @@ var generatedParamAliases = []ParamAliasEntry{
Aliases: map[string]string{
"dest-open-cid": "dest-conversation-id",
"destination-conversation-id": "dest-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
"source-conversation-id": "src-conversation-id",
"src-open-cid": "src-conversation-id",
"target-conversation-id": "dest-conversation-id",
@@ -1453,21 +1368,20 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message list",
Aliases: map[string]string{
"chat-id": "group",
"max-result": "limit",
"max-results": "limit",
"open-conversation-id": "group",
"page-size": "limit",
"per-page": "limit",
"staff-id": "user",
"start": "time",
"take": "limit",
"top": "limit",
"uid": "user",
"user-id": "user",
"userid": "user",
"max-result": "limit",
"max-results": "limit",
"page-size": "limit",
"per-page": "limit",
"staff-id": "user",
"start": "time",
"take": "limit",
"top": "limit",
"uid": "user",
"user-id": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message list-all",
@@ -1514,12 +1428,9 @@ var generatedParamAliases = []ParamAliasEntry{
},
},
{
CLIPath: "chat message list-mentions",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat message list-mentions",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message list-pin-msg",
@@ -1534,8 +1445,7 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message list-topic-replies",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
@@ -1549,11 +1459,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message read-status",
Aliases: map[string]string{
"chat-id": "conversation-id",
"msg-id": "message-id",
"open-conversation-id": "conversation-id",
"open-message-id": "message-id",
"user-ids": "users",
"chat-id": "conversation-id",
"msg-id": "message-id",
"open-message-id": "message-id",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"},
},
@@ -1561,30 +1470,25 @@ var generatedParamAliases = []ParamAliasEntry{
CLIPath: "chat message recall",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
{
CLIPath: "chat message recall-by-bot",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
"robot": "robot-code",
"robot": "robot-code",
},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message remove-emoji",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message remove-favorite",
@@ -1601,12 +1505,10 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message remove-text-emotion",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
},
{
CLIPath: "chat message reply",
@@ -1620,12 +1522,9 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
},
{
CLIPath: "chat message search",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
CLIPath: "chat message search",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message search-advanced",
@@ -1638,28 +1537,25 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message send",
Aliases: map[string]string{
"chat-id": "group",
"file": "file-path",
"open-conversation-id": "group",
"staff-id": "user",
"to-user": "user",
"uid": "user",
"user-id": "user",
"userid": "user",
"file": "file-path",
"staff-id": "user",
"to-user": "user",
"uid": "user",
"user-id": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message send-by-bot",
Aliases: map[string]string{
"at-users": "at-user-ids",
"chat-id": "group",
"open-conversation-id": "group",
"robot": "robot-code",
"user-ids": "users",
"at-users": "at-user-ids",
"robot": "robot-code",
"user-ids": "users",
},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"},
Ambiguous: []string{"at-ids"},
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message send-by-webhook",
@@ -1668,14 +1564,9 @@ var generatedParamAliases = []ParamAliasEntry{
},
},
{
CLIPath: "chat message send-card",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
CLIPath: "chat message send-card",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
},
{
CLIPath: "chat message set-pin-msg",
@@ -1684,8 +1575,6 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1696,8 +1585,6 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1708,8 +1595,6 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1720,17 +1605,13 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
{
CLIPath: "chat mute",
Aliases: map[string]string{
"chat-id": "conversation-id",
"group": "conversation-id",
"open-conversation-id": "conversation-id",
"chat-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
},
@@ -2548,7 +2429,7 @@ var generatedParamAliases = []ParamAliasEntry{
"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"},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
+104
View File
@@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
})
}
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
[]Concept{
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
{ID: "query", Members: []string{"query", "keyword"}},
},
CommandOverride{Bind: map[string]string{"id": "base_id"}},
)
if len(problems) != 0 {
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
}
if entry == nil {
t.Fatal("reduceLeafParamAliases() entry = nil")
}
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
}
if entry.Aliases["keyword"] != "query" {
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
}
if containsParamAlias(entry.Blocked, "keyword") {
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
}
if containsParamAlias(entry.Blocked, "name") {
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
}
if containsParamAlias(entry.Blocked, "query") {
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
}
if !containsParamAlias(entry.Blocked, "unsafe") {
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
}
}
func TestParamAliasEntryLookupMethods(t *testing.T) {
entry := ParamAliasEntry{
Aliases: map[string]string{"uid": "user"},
@@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
}
}
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
concepts := []Concept{
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
}
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if containsParamAlias(entry.Blocked, "item-ids") {
t.Fatalf("real exclude was blocked: %#v", entry)
}
}
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
for name, override := range map[string]CommandOverride{
@@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
}
}
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
for _, entry := range generatedParamAliases {
entries[entry.CLIPath] = entry
}
assertBlocked := func(path string, names ...string) {
t.Helper()
entry, ok := entries[path]
if !ok {
t.Fatalf("missing generated alias entry for %q", path)
}
for _, name := range names {
if !entry.IsBlocked(cmdutil.Morph(name)) {
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
}
}
}
for _, path := range []string{
"chat message add-emoji",
"chat message remove-emoji",
"chat message add-text-emotion",
"chat message remove-text-emotion",
} {
assertBlocked(path, "msg-ids", "message-ids")
}
for _, path := range []string{
"chat message send",
"chat conversation-info",
"chat category add-conv",
"chat category remove-conv",
"chat message list",
"chat message list-mentions",
"chat message recall-by-bot",
"chat message search",
} {
assertBlocked(path, "conversation-ids")
}
}
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
entry, ok := LookupParamAlias("chat group audit-join-validation")
if !ok {
t.Fatal("missing generated alias entry for chat group audit-join-validation")
}
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
}
}
}
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
// at the Go level, complementing the byte-identity drift gate.
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
+19 -12
View File
@@ -60,11 +60,17 @@
"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 message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --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"},
@@ -84,12 +90,12 @@
"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"], "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", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; 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."},
@@ -98,8 +104,8 @@
"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"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
"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."},
@@ -109,7 +115,7 @@
"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"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"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."},
@@ -142,7 +148,7 @@
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "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."},
@@ -328,7 +334,7 @@
{"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": "override:scoped"},
{"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"},
@@ -466,6 +472,7 @@
{"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"},
+52 -8
View File
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
interfaceType string
}{
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "message-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
{"chat message send", "idempotency-key", "uuid", false, ""},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
{"chat category list-by-conv", "group", "openConversationId", true, ""},
{"chat group update-nick", "group", "openConversationId", true, ""},
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
{"chat group update-nick", "conversation-id", "", true, ""},
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
{"chat message list-favorites", "size", "", false, "string"},
@@ -1397,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
}
}
// Hidden conversation aliases must stay unpublished (merge-base parity).
// Manifest-covered migrations hide legacy aliases; manifest-external
// commands keep their existing visible flags for compatibility.
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
if err != nil {
t.Fatal(err)
@@ -1421,9 +1422,52 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
t.Fatal(err)
}
listParams := schemaMap(listByConv["parameters"])
for _, hidden := range []string{"conversation-id", "id"} {
if _, ok := listParams[hidden]; ok {
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
if _, ok := listParams["conversation-id"]; !ok {
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
}
if _, ok := listParams["group"]; !ok {
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
}
if _, ok := listParams["id"]; ok {
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
}
for _, path := range []string{
"chat message add-emoji",
"chat message remove-emoji",
"chat message add-text-emotion",
"chat message remove-text-emotion",
} {
leaf, err := queryDeliverySchemaPayload([]string{path})
if err != nil {
t.Fatal(err)
}
params := schemaMap(leaf["parameters"])
if _, ok := params["conversation-id"]; !ok {
t.Fatalf("%s missing public canonical --conversation-id", path)
}
for _, visible := range []string{"group", "id", "chat"} {
if _, ok := params[visible]; !ok {
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
}
}
}
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
if err != nil {
t.Fatal(err)
}
groupBotsParams := schemaMap(groupBots["parameters"])
group := groupBotsParams["group"]
if group == nil {
t.Fatal("chat group bots missing public legacy --group")
}
if group["property"] != "openConversationId" {
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
}
for _, migrated := range []string{"conversation-id", "group-name"} {
if _, ok := groupBotsParams[migrated]; ok {
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
}
}
}
@@ -43,19 +43,14 @@ func init() {
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
})
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
+18 -2
View File
@@ -1127,21 +1127,37 @@ func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error {
return nil
}
// rawJSONValue decodes a JSON value that may come from an untrusted source, so
// it validates before decoding. Callers holding output that json.Marshal just
// produced should use typedJSONValue instead of paying the validation scan.
func rawJSONValue(raw json.RawMessage) (any, error) {
if !json.Valid(raw) {
return nil, fmt.Errorf("invalid JSON value")
}
return decodeValidJSONValue(raw), nil
}
// decodeValidJSONValue decodes JSON whose validity the caller has already
// established, either by json.Valid or by having just marshaled it. Decode
// errors are unreachable under that precondition and are therefore discarded,
// exactly as this path behaved when the decode was inlined into rawJSONValue.
func decodeValidJSONValue(raw json.RawMessage) any {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
_ = decoder.Decode(&value)
return value, nil
return value
}
// typedJSONValue projects a typed value into the generic JSON shape the payload
// renderers consume. json.Marshal output is valid by construction, so this path
// decodes it directly: routing through rawJSONValue re-scanned every marshaled
// document with json.Valid, which measured ~34% of Schema Catalog assembly time
// across the 1121-tool set (26.0s -> 17.2s for the internal/app schema suite).
func typedJSONValue(value any) (any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
return rawJSONValue(data)
return decodeValidJSONValue(data), nil
}
@@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
@@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
+40 -12
View File
@@ -145,6 +145,15 @@ type FlagSpec struct {
// alike) and makes a whitespace-only value count as empty in required checks.
Trim bool
// Input declares extra input sources for a KindString flag beyond the
// literal command-line value: InputFile enables @path (value replaced by
// the file content), InputStdin enables - (value replaced by stdin).
// "@@value" always escapes to the literal "@value". Only explicit CLI
// tokens are resolved; EnvVar fallback and registration defaults pass
// through unchanged. Resolution runs before required/enum/constraint/
// Validate checks, so they see the payload content. Empty = flag value only.
Input []string
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
Enum []string // accepted values
Format string // machine-readable format (e.g. uri)
@@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command {
validateDispatchDecl(spec)
validateSafetySpec(spec)
validateContractDecl(spec)
validateInputSpecs(spec.Use, spec.Flags)
// Help prose inherits the declaration when not authored separately:
// Selection.Examples (already contract-validated against the real flags)
// double as the --help Example block, keeping one authored source.
@@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
return err
}
}
// Input resolution rewrites explicit @file / stdin values in place so the
// required/enum/constraint/Validate stages below check the payload content.
if err := resolveInputFlags(cmd, spec.Flags); err != nil {
return err
}
if err := ValidateRequired(cmd, spec.Flags); err != nil {
return err
}
@@ -578,18 +593,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
for _, alias := range flag.Aliases {
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
if registered := cmd.Flags().Lookup(alias); registered != nil {
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
flag.Name,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
AnnotateFlagAlias(cmd, alias, flag.Name)
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
@@ -600,6 +604,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
}
}
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
// compatibility alias for canonicalName. It is for commands that already own
// their Cobra flag registration outside FlagSpec but still need the same
// interface-snapshot alias contract as FlagSpec.Aliases.
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
if cmd == nil {
return
}
registered := cmd.Flags().Lookup(aliasName)
if registered == nil {
return
}
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
canonicalName,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
// RegisterFlag registers one flag by Kind. Default is applied at registration
// for every kind so --help DefValue matches the declared fallback.
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
+10
View File
@@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
}
}
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
AnnotateFlagAlias(nil, "alias", "canonical")
cmd := newTestCommand()
AnnotateFlagAlias(cmd, "missing", "canonical")
if flag := cmd.Flags().Lookup("missing"); flag != nil {
t.Fatalf("unexpected missing flag registered: %#v", flag)
}
}
// ── effective value fallback chain ─────────────────────────────────
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
+191
View File
@@ -0,0 +1,191 @@
// 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"
"io"
"os"
"strings"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// FlagSpec.Input source constants. They declare extra input sources for a
// KindString flag beyond the literal command-line value, mirroring the
// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV)
// never need shell quoting.
const (
// InputFile allows the flag value @path to be replaced by the file content.
InputFile = "file"
// InputStdin allows the flag value - to be replaced by the stdin content.
InputStdin = "stdin"
)
// utf8BOM is stripped from file/stdin content so a Windows-edited payload
// cannot corrupt the first CSV cell or break JSON parsing downstream.
const utf8BOM = "\ufeff"
// validateInputSpecs rejects malformed Input declarations at build time. Like
// the other declaration checks this panics: a bad Input spec is a programming
// error every test and startup path should trip immediately.
func validateInputSpecs(use string, flags []FlagSpec) {
for _, flag := range flags {
if len(flag.Input) == 0 {
continue
}
if flag.Kind != KindString {
panic(fmt.Sprintf(
"command %q flag %q: Input is only supported on KindString flags",
use, flag.Name))
}
seen := map[string]bool{}
for _, source := range flag.Input {
if source != InputFile && source != InputStdin {
panic(fmt.Sprintf(
"command %q flag %q: unknown Input source %q (allowed: %s, %s)",
use, flag.Name, source, InputFile, InputStdin))
}
if seen[source] {
panic(fmt.Sprintf(
"command %q flag %q: duplicate Input source %q",
use, flag.Name, source))
}
seen[source] = true
}
}
}
// inputSupports reports whether the flag declared the given input source.
func inputSupports(flag FlagSpec, source string) bool {
for _, s := range flag.Input {
if s == source {
return true
}
}
return false
}
// explicitInputFlagName picks the declared name that carries the user's
// explicit token, mirroring rawValue's order and usability judgement exactly:
// the main flag wins only when changed and usable (trim-judged only when
// Trim is set), then declared aliases in order. Diverging here could rewrite
// an alias that rawValue would shadow (or vice versa). EnvVar fallback and
// registration defaults are never input-resolved — @file only applies to what
// the user literally typed.
func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string {
usable := func(v string) bool {
if flag.Trim {
v = strings.TrimSpace(v)
}
return v != ""
}
for _, name := range append([]string{flag.Name}, flag.Aliases...) {
if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) {
return name
}
}
return ""
}
// resolveInputFlags rewrites the explicit values of Input-declaring flags
// before required/enum/constraint/Validate checks, so every downstream stage
// sees the real payload content. Semantics:
//
// - "-" reads stdin; a process has a single stdin, so a second Input flag
// using "-" in the same invocation is rejected.
// - "@path" reads the named file (leading/trailing whitespace trimmed).
// - "@@value" escapes to the literal "@value" and is not source-resolved.
//
// Resolution runs once per invocation inside runDeclaredPreflight. It
// rewrites the cobra flag value in place (Set keeps Changed=true), so
// fallback-chain readers (EffectiveValue/BuildArgs) observe the content.
//
// Interaction with confirmation: stdin is consumed here, before the Safety
// prompt of a user_required write would read it; such an interactive prompt
// then sees EOF and fails closed with confirmation_required. Write commands
// declaring InputStdin must be invoked with --yes (or --dry-run).
func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error {
stdinConsumed := false
for _, flag := range flags {
if len(flag.Input) == 0 {
continue
}
name := explicitInputFlagName(cmd, flag)
if name == "" {
continue
}
raw := cmdutil.MustGetFlag(cmd, name)
// Trim flags judge usability on the trimmed value (rawValue), so the
// prefix check must agree or " @path" would slip through unresolved.
if flag.Trim {
raw = strings.TrimSpace(raw)
}
switch {
case raw == "-":
if !inputSupports(flag, InputStdin) {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name))
}
if stdinConsumed {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name),
apperrors.WithHint(fmt.Sprintf(
"一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)",
flag.Name)))
}
stdinConsumed = true
data, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err))
}
// Setting a registered string flag cannot fail.
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
case strings.HasPrefix(raw, "@@"):
// Escape: strip the first @, keep the rest as a literal inline value.
_ = cmd.Flags().Set(name, raw[1:])
case strings.HasPrefix(raw, "@"):
if !inputSupports(flag, InputFile) {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name))
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name))
}
data, err := os.ReadFile(path)
if err != nil {
var opts []apperrors.Option
if inputSupports(flag, InputStdin) {
// Rejected @file paths are usually absolute (temp files).
// Steer toward stdin rather than copying the file around.
opts = append(opts, apperrors.WithHint(fmt.Sprintf(
"该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name)))
}
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...)
}
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
}
}
return nil
}
+334
View File
@@ -0,0 +1,334 @@
// 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 (
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can
// assert what the full pipeline (resolution → validation → BuildArgs) ships.
func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command {
return New(Spec{
Use: "t",
Short: "t",
Flags: flags,
Invoke: func(c *Ctx, toolArgs map[string]any) error {
*captured = toolArgs
return nil
},
})
}
func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error {
t.Helper()
cmd.SetArgs(args)
return cmd.Execute()
}
func writeInputFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "payload.txt")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
}, &got)
path := writeInputFile(t, "# hello\nworld")
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "# hello\nworld" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
}, &got)
cmd.SetIn(strings.NewReader("piped payload"))
if err := runInputCommand(t, cmd, "--content", "-"); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "piped payload" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// The @@ escape keeps a literal leading @ inline and must not be treated as a
// source reference even when Input is declared.
func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
}, &got)
if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "@literal@x" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "\ufeff{\"a\":1}")
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "{\"a\":1}" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// Required must be satisfied by the resolved payload, proving resolution runs
// before the required stage.
func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "payload")
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "payload" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// Enum validation sees the resolved content, not the "@path" token.
func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "sideways")
err := runInputCommand(t, cmd, "--mode", "@"+path)
if err == nil || !strings.Contains(err.Error(), "不合法") {
t.Fatalf("expected enum rejection on resolved content, got %v", err)
}
}
// A value passed through a declared alias is resolved exactly like the main
// name (fallback-chain parity).
func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "via alias")
if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "via alias" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// When the main flag shadows a changed alias (rawValue usability order), the
// shadowed alias must not be input-resolved: resolution targets exactly the
// name the fallback chain will read. The whitespace main value is usable for
// a non-Trim flag, and the alias path does not exist on purpose — a resolver
// that wrongly picked the alias would fail the read instead of shipping " ".
func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
}, &got)
missing := filepath.Join(t.TempDir(), "missing.txt")
if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != " " {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
}, &got)
err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt")
if err == nil || !strings.Contains(err.Error(), "不支持文件输入") {
t.Fatalf("expected file-input rejection, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
}, &got)
cmd.SetIn(strings.NewReader("x"))
err := runInputCommand(t, cmd, "--content", "-")
if err == nil || !strings.Contains(err.Error(), "不支持 stdin") {
t.Fatalf("expected stdin rejection, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}},
{Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}},
}, &got)
cmd.SetIn(strings.NewReader("x"))
err := runInputCommand(t, cmd, "--first", "-", "--second", "-")
if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") {
t.Fatalf("expected single-stdin rejection, got %v", err)
}
}
// failingReader (corecmd_test.go) fails every read, making the stdin error
// branch reachable.
func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
}, &got)
cmd.SetIn(failingReader{})
err := runInputCommand(t, cmd, "--content", "-")
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
t.Fatalf("expected stdin read failure, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
}, &got)
err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt"))
if err == nil || !strings.Contains(err.Error(), "读取文件") {
t.Fatalf("expected read failure, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
}, &got)
err := runInputCommand(t, cmd, "--content", "@ ")
if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") {
t.Fatalf("expected empty-path rejection, got %v", err)
}
}
// A flag without Input keeps its literal value even when it looks like a
// source reference — resolution is strictly opt-in per declaration.
func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "token", Usage: "T", Bind: "token"},
}, &got)
if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil {
t.Fatalf("execute: %v", err)
}
if got["token"] != "@not-a-file" {
t.Fatalf("toolArgs[token] = %q", got["token"])
}
}
// Registration defaults and env fallback are never input-resolved: @file only
// applies to what the user literally typed on the command line.
func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}},
}, &got)
if err := runInputCommand(t, cmd); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "@not-a-file" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// Trim flags judge usability on the trimmed value (rawValue), so a leading
// whitespace before @ must still resolve instead of shipping as a literal.
func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "trimmed payload")
if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "trimmed payload" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) {
cases := []struct {
name string
flag FlagSpec
}{
{"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}},
{"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}},
{"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
New(Spec{
Use: "t",
Short: "t",
Flags: []FlagSpec{tc.flag},
Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil },
})
})
}
}
+8 -3
View File
@@ -361,13 +361,18 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
}
return nil, net.ErrClosed
}}
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)}
d.acceptLoop(context.Background())
d.shuttingDown.Store(true)
d.acceptLoop(context.Background())
d.triggerShutdown("test")
if !l.closed {
t.Fatal("trigger did not close listener")
select {
case reason := <-d.stopReq:
if reason != "test" {
t.Fatalf("stop reason = %q", reason)
}
default:
t.Fatal("trigger did not notify lifecycle loop")
}
d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
+14 -7
View File
@@ -186,6 +186,7 @@ func Run(ctx context.Context, cfg Config) error {
dedup: dd,
started: time.Now().UTC(),
idleStop: make(chan struct{}),
stopReq: make(chan string, 1),
}
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
@@ -263,6 +264,9 @@ func Run(ctx context.Context, cfg Config) error {
}
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
case reason := <-d.stopReq:
shutdownReason = reason
log.Info("bus: shutdown requested via IPC", "reason", reason)
}
// 7. Graceful shutdown — cancel runCtx first so all background
@@ -292,6 +296,7 @@ type daemon struct {
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
stopReq chan string
credentialHandoffMu sync.Mutex
terminalMu sync.RWMutex
@@ -749,15 +754,17 @@ func (d *daemon) idleWatch(ctx context.Context) {
}
}
// triggerShutdown is called from RPC handlers that want to end the bus.
// It works by closing the listener (which unblocks Run's select via the
// source error path, indirectly). For v1 a full ctx-cancellation hook is
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
// shutdown path.
// triggerShutdown is called from RPC handlers that want to end the bus. The
// buffered request wakes Run's lifecycle select, which then cancels the cloud
// source and performs the same graceful shutdown used for signals and idle
// expiry. It is intentionally non-blocking so repeated stop requests cannot
// strand connection handlers.
func (d *daemon) triggerShutdown(reason string) {
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
// Best-effort: a future version wires a context.CancelFunc here.
select {
case d.stopReq <- reason:
default:
}
}
// shutdown performs the graceful tear-down sequence:
+61
View File
@@ -141,6 +141,67 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
}
}
func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) {
workDir := shortTempDir(t)
identityHash := dwsevent.IdentityHash(workDir)
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash)
runDone := make(chan error, 1)
go func() {
runDone <- Run(context.Background(), Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: "ding_ipc_stop_test",
Edition: "open",
SourceKind: dwsevent.SourceKindAppStream,
IdentityHash: identityHash,
Source: &fakeSource{},
})
}()
var conn net.Conn
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var err error
conn, err = transport.Dial(endpoint)
if err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if conn == nil {
t.Fatal("bus IPC endpoint did not become ready")
}
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleStop,
}); err != nil {
t.Fatalf("write stop hello: %v", err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatalf("read stop response: %v", err)
}
_ = conn.Close()
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
t.Fatalf("stop response = %#v", bye)
}
select {
case err := <-runDone:
if err != nil {
t.Fatalf("Run returned after IPC stop: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Run did not return after IPC stop")
}
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 {
t.Fatalf("bus lock retained pid %d after IPC stop", pid)
}
}
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
skipOnWindows(t, "uses Unix socket dial")
workDir := shortTempDir(t)
+41
View File
@@ -114,6 +114,47 @@ func ReadHolderPID(path string) int {
return parsePID(b)
}
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
// path. If the lock can be acquired, no daemon owns it: the PID payload is
// stale and is cleared while the probe holds the lock. The PID is read again
// after a busy result so a daemon replacement cannot be mistaken for the
// original owner.
//
// Callers use this immediately before sending a process-level termination
// fallback. A live PID by itself is not proof of ownership because operating
// systems may reuse PIDs after an unclean daemon exit.
func ValidateHolderOwnership(path string, pid int) (bool, error) {
if pid <= 0 || ReadHolderPID(path) != pid {
return false, nil
}
probe, err := busTryAcquire(path)
if err != nil {
if errors.Is(err, ErrBusy) {
return ReadHolderPID(path) == pid, nil
}
return false, fmt.Errorf("bus: verify lock owner: %w", err)
}
defer probe.Close()
// We own the lock, so the recorded PID cannot own it. Clear only the PID
// we validated above; a changed payload is left untouched defensively.
f := probe.File()
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
return false, fmt.Errorf("bus: seek stale lock: %w", err)
}
current, err := busReadAll(f)
if err != nil {
return false, fmt.Errorf("bus: read stale lock: %w", err)
}
if parsePID(current) == pid {
if err := truncateAndWritePID(f, 0); err != nil {
return false, fmt.Errorf("bus: clear stale PID: %w", err)
}
}
return false, nil
}
// Close releases the flock and best-effort blanks the PID body so a stale
// reader (e.g. `event status` racing our shutdown) does not see our
// long-dead PID and try to signal it. The lock file itself is NOT removed
+107
View File
@@ -16,9 +16,13 @@ package bus
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"testing"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestAcquire_WritesOurPID(t *testing.T) {
@@ -112,6 +116,109 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
held, err := busTryAcquire(path)
if err != nil {
t.Fatalf("hold lock: %v", err)
}
defer held.Close()
if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil {
t.Fatalf("write holder PID: %v", err)
}
owned, err := ValidateHolderOwnership(path, os.Getpid())
if err != nil {
t.Fatalf("ValidateHolderOwnership: %v", err)
}
if !owned {
t.Fatal("held lock was not attributed to its recorded PID")
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
// The current PID is alive but deliberately does not hold this lock. This
// models a stale daemon PID that the OS has reassigned to another process.
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
t.Fatalf("write stale PID: %v", err)
}
owned, err := ValidateHolderOwnership(path, os.Getpid())
if err != nil {
t.Fatalf("ValidateHolderOwnership: %v", err)
}
if owned {
t.Fatal("live reused PID without the bus lock was accepted as owner")
}
if got := ReadHolderPID(path); got != 0 {
t.Fatalf("stale PID after validation = %d, want cleared", got)
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
t.Fatal(err)
}
owned, err := ValidateHolderOwnership(path, 456)
if err != nil || owned {
t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err)
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) {
errInjected := errors.New("injected ownership validation failure")
writeLivePID := func(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), LockFileName)
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
t.Fatal(err)
}
return path
}
t.Run("probe", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) {
return nil, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("probe error = %v", err)
}
})
t.Run("seek", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) {
return 0, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("seek error = %v", err)
}
})
t.Run("read", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) {
return nil, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("read error = %v", err)
}
})
t.Run("clear", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busTruncate, func(*os.File, int64) error {
return errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("clear error = %v", err)
}
})
}
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
l1, err := Acquire(path)
@@ -284,11 +284,13 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) {
func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
origRead := stopReadHolderPID
origValidateOwner := stopValidateHolderOwner
origAlive := stopAlive
origFind := stopFindProcess
origSignal := stopSignalProcess
t.Cleanup(func() {
stopReadHolderPID = origRead
stopValidateHolderOwner = origValidateOwner
stopAlive = origAlive
stopFindProcess = origFind
stopSignalProcess = origSignal
@@ -307,6 +309,7 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
t.Fatal(err)
}
stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil }
stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
t.Fatalf("done signal = %v", err)
+3 -2
View File
@@ -112,8 +112,9 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
}
_, spawnErr := cfg.Spawn(SpawnConfig{
ClientID: cfg.ClientID,
ExtraArgs: cfg.SpawnExtraArgs,
ClientID: cfg.ClientID,
IPCEndpoint: cfg.IPCEndpoint,
ExtraArgs: cfg.SpawnExtraArgs,
})
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
// Hard error (couldn't even exec the child). Stop here — no bus
+4 -1
View File
@@ -122,7 +122,10 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
closer()
}
})
fakeSpawn := func(SpawnConfig) (int, error) {
fakeSpawn := func(cfg SpawnConfig) (int, error) {
if cfg.IPCEndpoint != sock {
t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock)
}
closer = startStubBus(t, sock)
return 12345, nil
}
+15 -14
View File
@@ -43,18 +43,14 @@ var (
spawnPipe = os.Pipe
)
// ErrSpawnFailed is returned when the child reports startup failure via
// the ready pipe ('E' byte). The child's exit error / log file holds the
// actual cause; this sentinel just lets the caller distinguish "ready
// pipe said no" from "ready pipe timed out / closed early".
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
// ErrSpawnFailed is returned when the child reports startup failure through
// the Unix ready pipe or exits before binding the Windows named pipe.
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure")
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
// SpawnConfig describes one spawn attempt. ClientID is the only field
// inspected by the child; the rest govern process attributes the parent
// applies before exec.
// SpawnConfig describes one spawn attempt.
type SpawnConfig struct {
// ExecPath is the dws binary to exec. Default os.Executable().
ExecPath string
@@ -62,18 +58,23 @@ type SpawnConfig struct {
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
ClientID string
// IPCEndpoint is the bus endpoint the child binds. Windows uses the
// existing named pipe as its readiness handshake because os/exec does not
// support ExtraFiles there. Unix keeps the inherited ready-pipe protocol.
IPCEndpoint string
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
// pass `--extra-flag-for-test` etc.
ExtraArgs []string
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
// entry is appended automatically.
// Env to pass to the child. Defaults to os.Environ(). Unix appends the
// ReadyFDEnv entry automatically; Windows removes any inherited copy.
Env []string
}
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
// waits for it to signal readiness via the ready pipe. Returns the child's
// PID on success — the caller can then dial the bus IPC endpoint.
// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a
// detached `dws event _bus --client-id <id>` child and waits for the inherited
// ready pipe. Windows has a named-pipe implementation in spawn_windows.go.
//
// stdio detach (plan invariant #7):
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
@@ -89,7 +90,7 @@ type SpawnConfig struct {
// Parent (this function):
// - Holds the read end open until either 1 byte is read or ReadyTimeout
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
func Spawn(cfg SpawnConfig) (pid int, err error) {
func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) {
if cfg.ClientID == "" {
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
}
+24 -1
View File
@@ -22,6 +22,8 @@ import (
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
@@ -35,7 +37,8 @@ import (
// env-marker pattern is what Go's own os/exec tests use and stays
// confined to this file.
const (
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
// values:
// "ready" — write 'R' then sleep 30s (parent should see ready)
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
@@ -70,6 +73,26 @@ func TestMain(m *testing.M) {
writeReady('R')
time.Sleep(30 * time.Second)
os.Exit(0)
case "windows-ready":
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
if err != nil {
os.Exit(3)
}
defer listener.Close()
conn, err := listener.Accept()
if err != nil {
os.Exit(4)
}
_ = conn.Close()
time.Sleep(30 * time.Second)
os.Exit(0)
case "windows-fail":
os.Exit(5)
case "windows-exit":
os.Exit(0)
case "windows-stall":
time.Sleep(30 * time.Second)
os.Exit(0)
}
os.Exit(m.Run())
}
+7
View File
@@ -20,6 +20,13 @@ import (
"syscall"
)
// Spawn starts the detached bus and waits for its inherited ready pipe.
// ExtraFiles is intentionally confined to Unix: Go does not support it on
// Windows.
func Spawn(cfg SpawnConfig) (pid int, err error) {
return spawnWithReadyPipe(cfg)
}
// applyDetach configures the child to live past parent death and not share
// the parent's controlling terminal. Setsid puts the child in a new
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
+87
View File
@@ -16,14 +16,101 @@
package busctl
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
const createNewProcessGroup = 0x00000200
var (
spawnWindowsDial = transport.Dial
spawnWindowsPollInterval = 25 * time.Millisecond
)
// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by
// Go on Windows). Readiness is confirmed by dialing the bus's existing named
// pipe. The child is reaped in the background after readiness, while an early
// process exit is surfaced as ErrSpawnFailed.
func Spawn(cfg SpawnConfig) (pid int, err error) {
if strings.TrimSpace(cfg.ClientID) == "" {
return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required")
}
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows")
}
if cfg.ExecPath == "" {
execPath, err := spawnExecutable()
if err != nil {
return 0, fmt.Errorf("busctl: locate executable: %w", err)
}
cfg.ExecPath = execPath
}
if cfg.Env == nil {
cfg.Env = os.Environ()
}
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
cmd := exec.Command(cfg.ExecPath, args...)
cmd.Env = withoutReadyFDEnv(cfg.Env)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
applyDetach(cmd)
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
}
pid = cmd.Process.Pid
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
timer := time.NewTimer(ReadyTimeout)
defer timer.Stop()
ticker := time.NewTicker(spawnWindowsPollInterval)
defer ticker.Stop()
for {
if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil {
_ = conn.Close()
return pid, nil
}
select {
case waitErr := <-waitDone:
if waitErr == nil {
return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed)
}
return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr)
case <-timer.C:
_ = cmd.Process.Kill()
select {
case <-waitDone:
case <-time.After(time.Second):
}
return pid, ErrSpawnTimeout
case <-ticker.C:
}
}
}
func withoutReadyFDEnv(env []string) []string {
prefix := strings.ToUpper(ReadyFDEnv) + "="
out := make([]string, 0, len(env))
for _, entry := range env {
if strings.HasPrefix(strings.ToUpper(entry), prefix) {
continue
}
out = append(out, entry)
}
return out
}
func applyDetach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: createNewProcessGroup,
+132
View File
@@ -0,0 +1,132 @@
// 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.
//go:build windows
package busctl
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageWindowsSpawnUsesNamedPipeReadiness(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-test-%d-%d`, os.Getpid(), time.Now().UnixNano())
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
})
if err != nil {
t.Fatalf("Spawn() = %v", err)
}
if pid <= 0 {
t.Fatalf("Spawn() pid = %d", pid)
}
proc, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
_ = proc.Kill()
}
func TestCrossPlatformCoverageWindowsSpawnReportsEarlyExit(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-fail-%d-%d`, os.Getpid(), time.Now().UnixNano())
_, err := spawnWithMarker(t, "windows-fail", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnFailed) {
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnReportsCleanEarlyExit(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-exit-%d-%d`, os.Getpid(), time.Now().UnixNano())
_, err := spawnWithMarker(t, "windows-exit", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnFailed) {
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnResolvesExecutable(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-resolve-%d-%d`, os.Getpid(), time.Now().UnixNano())
testseam.Swap(t, &spawnExecutable, func() (string, error) { return os.Args[0], nil })
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
cfg.ExecPath = ""
cfg.IPCEndpoint = endpoint
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
})
if err != nil {
t.Fatalf("Spawn() = %v", err)
}
proc, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
_ = proc.Kill()
}
func TestCrossPlatformCoverageWindowsSpawnValidationAndStartErrors(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-errors-%d-%d`, os.Getpid(), time.Now().UnixNano())
if _, err := Spawn(SpawnConfig{ClientID: "client"}); err == nil {
t.Fatal("missing endpoint unexpectedly succeeded")
}
testseam.Swap(t, &spawnExecutable, func() (string, error) {
return "", errors.New("executable unavailable")
})
if _, err := Spawn(SpawnConfig{ClientID: "client", IPCEndpoint: endpoint}); err == nil {
t.Fatal("executable lookup unexpectedly succeeded")
}
if _, err := Spawn(SpawnConfig{
ClientID: "client",
IPCEndpoint: endpoint,
ExecPath: `C:\\definitely-missing-dws.exe`,
}); err == nil {
t.Fatal("missing executable unexpectedly started")
}
}
func TestCrossPlatformCoverageWindowsSpawnReadyTimeoutTerminatesChild(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-stall-%d-%d`, os.Getpid(), time.Now().UnixNano())
testseam.Swap(t, &ReadyTimeout, 100*time.Millisecond)
_, err := spawnWithMarker(t, "windows-stall", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnTimeout) {
t.Fatalf("Spawn() error = %v, want ErrSpawnTimeout", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnRemovesInheritedReadyFD(t *testing.T) {
env := withoutReadyFDEnv([]string{
"PATH=C:\\Windows",
ReadyFDEnv + "=3",
strings.ToLower(ReadyFDEnv) + "=4",
})
if len(env) != 1 || !strings.HasPrefix(env[0], "PATH=") {
t.Fatalf("withoutReadyFDEnv() = %#v", env)
}
}
func TestCrossPlatformCoverageWindowsUnixReadyPipeValidation(t *testing.T) {
if _, err := spawnWithReadyPipe(SpawnConfig{}); err == nil {
t.Fatal("spawnWithReadyPipe() unexpectedly accepted an empty client ID")
}
}
+95 -24
View File
@@ -17,15 +17,17 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to
// exit after the signal is sent. 5s covers the bus's own graceful tear-down
// (broadcast Bye → consumer goroutines drain → cleanup) with margin.
// DefaultStopTimeout is the wall-clock budget Stop waits for each graceful or
// fallback termination phase. 5s covers the bus's own tear-down (broadcast
// Bye → consumer goroutines drain → cleanup) with margin.
const DefaultStopTimeout = 5 * time.Second
// ErrNotRunning indicates bus.lock either does not exist or its recorded
@@ -33,20 +35,32 @@ const DefaultStopTimeout = 5 * time.Second
// distinguish "nothing to stop" from "failed to stop".
var ErrNotRunning = errors.New("busctl: bus is not running")
// ErrOwnerUnverified indicates the PID from bus.lock is alive but no longer
// owns that lock. Stop refuses to send a process-level signal in this state
// because the operating system may have reused the stale PID.
var ErrOwnerUnverified = errors.New("busctl: bus process ownership could not be verified")
var (
stopReadHolderPID = bus.ReadHolderPID
stopAlive = process.Alive
stopFindProcess = os.FindProcess
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
stopReadHolderPID = bus.ReadHolderPID
stopValidateHolderOwner = bus.ValidateHolderOwnership
stopAlive = process.Alive
stopFindProcess = os.FindProcess
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
stopDial = transport.Dial
stopRequest = requestBusStop
stopWaitForBusExit = waitForBusExit
)
// StopConfig identifies the target bus and tunes timing.
type StopConfig struct {
// WorkDir holds bus.lock; Stop reads the PID from there.
WorkDir string
// Timeout is the total wall-clock budget for graceful exit. After this,
// Stop returns an error; it does NOT escalate to SIGKILL — leave that
// to the operator.
// IPCEndpoint enables the cross-platform graceful stop RPC. Callers that
// know the bus identity should always provide it. Empty preserves the
// legacy signal-only fallback for compatibility and focused tests.
IPCEndpoint string
// Timeout is the wall-clock budget for graceful exit and, if required,
// the subsequent platform termination fallback.
Timeout time.Duration
}
@@ -54,13 +68,12 @@ type StopConfig struct {
// for the process to actually die. Returns ErrNotRunning if no bus is
// running for that work dir.
//
// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop
// watches its parent ctx for cancellation; the cobra `event _bus` command
// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful
// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which
// the Go runtime maps to TerminateProcess for processes outside our
// console group; for v1 that's acceptable (Windows graceful shutdown is
// future work — plan §16 v2).
// Stop first asks the bus to shut down through its owner-only IPC endpoint.
// This is the normal path on every platform and lets the daemon cancel its
// cloud source, notify consumers, and release its lock. If the endpoint is
// unavailable (for example an older bus) or graceful shutdown times out, the
// platform signal is used as a compatibility fallback: SIGTERM on Unix and
// TerminateProcess via os.Kill on Windows.
func Stop(cfg StopConfig) error {
if cfg.WorkDir == "" {
return errors.New("busctl: StopConfig.WorkDir is required")
@@ -81,6 +94,32 @@ func Stop(cfg StopConfig) error {
if err != nil {
return fmt.Errorf("busctl: find process %d: %w", pid, err)
}
if strings.TrimSpace(cfg.IPCEndpoint) != "" {
if err := stopRequest(cfg.IPCEndpoint); err == nil && stopWaitForBusExit(pid, cfg.Timeout) {
return nil
}
}
// The bus may finish shutting down immediately after the graceful wait
// reaches its deadline (or after a failed IPC request). Recheck before
// inspecting ownership so a completed stop is not reported as stale.
if !stopAlive(pid) {
return nil
}
owner, err := stopValidateHolderOwner(LockPath(cfg.WorkDir), pid)
if err != nil {
return fmt.Errorf("busctl: verify bus pid=%d ownership: %w", pid, err)
}
if !owner {
// ValidateHolderOwnership observes a released lock both for a stale,
// reused PID and for a bus that exits during the ownership probe. Only
// the still-alive case is unsafe to signal.
if !stopAlive(pid) {
return nil
}
return fmt.Errorf("%w: pid=%d does not own %s; stale PID was not signalled", ErrOwnerUnverified, pid, LockPath(cfg.WorkDir))
}
if err := stopSignalProcess(proc, stopSignal()); err != nil {
// On many Unix platforms Signal returns "process already finished"
// when the bus has just exited on its own — treat that as success.
@@ -90,13 +129,45 @@ func Stop(cfg StopConfig) error {
return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err)
}
// Poll for actual exit.
deadline := time.Now().Add(cfg.Timeout)
for time.Now().Before(deadline) {
if !stopAlive(pid) {
return nil
}
time.Sleep(50 * time.Millisecond)
if stopWaitForBusExit(pid, cfg.Timeout) {
return nil
}
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
}
func requestBusStop(endpoint string) error {
conn, err := stopDial(endpoint)
if err != nil {
return fmt.Errorf("busctl: dial bus for stop: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleStop,
}); err != nil {
return fmt.Errorf("busctl: write stop hello: %w", err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
return fmt.Errorf("busctl: read stop response: %w", err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
return fmt.Errorf("busctl: unexpected stop response type=%q reason=%q", bye.Type, bye.Reason)
}
return nil
}
func waitForBusExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !stopAlive(pid) {
return true
}
time.Sleep(50 * time.Millisecond)
}
return !stopAlive(pid)
}
+270 -1
View File
@@ -16,6 +16,7 @@ package busctl
import (
"context"
"errors"
"net"
"os"
"os/exec"
"path/filepath"
@@ -23,7 +24,10 @@ import (
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestStop_NotRunningWhenLockMissing(t *testing.T) {
@@ -34,7 +38,7 @@ func TestStop_NotRunningWhenLockMissing(t *testing.T) {
}
}
func TestStop_NotRunningWhenPIDDead(t *testing.T) {
func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) {
dir := shortTempDir(t)
// Write a definitely-dead PID into bus.lock.
if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil {
@@ -46,6 +50,269 @@ func TestStop_NotRunningWhenPIDDead(t *testing.T) {
}
}
func TestCrossPlatformCoverageStopPrefersGracefulIPC(t *testing.T) {
const pid = 4242
stopped := false
signals := 0
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !stopped })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(endpoint string) error {
if endpoint != "test-endpoint" {
t.Fatalf("stop endpoint = %q", endpoint)
}
stopped = true
return nil
})
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
if err := Stop(StopConfig{
WorkDir: "test-workdir",
IPCEndpoint: "test-endpoint",
Timeout: time.Second,
}); err != nil {
t.Fatalf("Stop() = %v", err)
}
if signals != 0 {
t.Fatalf("graceful IPC stop used %d process signals", signals)
}
}
func TestCrossPlatformCoverageStopDoesNotSignalUnverifiedReusedPID(t *testing.T) {
const pid = 4242
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return true })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return errors.New("stale endpoint") })
testseam.Swap(t, &stopValidateHolderOwner, func(path string, gotPID int) (bool, error) {
if path != LockPath("test-workdir") || gotPID != pid {
t.Fatalf("ownership check path=%q pid=%d", path, gotPID)
}
return false, nil
})
signals := 0
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
err = Stop(StopConfig{
WorkDir: "test-workdir",
IPCEndpoint: "stale-endpoint",
Timeout: time.Millisecond,
})
if !errors.Is(err, ErrOwnerUnverified) {
t.Fatalf("Stop() error = %v, want ErrOwnerUnverified", err)
}
if signals != 0 {
t.Fatalf("unverified reused PID received %d signals", signals)
}
}
func TestCrossPlatformCoverageStopAcceptsExitAtGracefulTimeoutBoundary(t *testing.T) {
const pid = 4242
t.Run("before ownership validation", func(t *testing.T) {
exited := false
validated := false
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return nil })
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool {
exited = true
return false
})
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
validated = true
return false, nil
})
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
t.Fatalf("Stop() = %v, want success after bus exit", err)
}
if validated {
t.Fatal("ownership was validated after the bus had already exited")
}
})
t.Run("during ownership validation", func(t *testing.T) {
exited := false
signals := 0
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return nil })
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { return false })
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
exited = true
return false, nil
})
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
t.Fatalf("Stop() = %v, want success after exit during ownership validation", err)
}
if signals != 0 {
t.Fatalf("exited bus received %d fallback signals", signals)
}
})
}
func TestCrossPlatformCoverageStopReportsOwnershipValidationError(t *testing.T) {
const pid = 4242
errInjected := errors.New("ownership validation failed")
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return true })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
return false, errInjected
})
signals := 0
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
err = Stop(StopConfig{WorkDir: "test-workdir"})
if !errors.Is(err, errInjected) {
t.Fatalf("Stop() error = %v, want injected ownership error", err)
}
if signals != 0 {
t.Fatalf("ownership validation error sent %d signals", signals)
}
}
func TestCrossPlatformCoverageRequestBusStopProtocol(t *testing.T) {
dir := shortTempDir(t)
endpoint := dwsevent.IPCEndpoint(
dir,
"open",
dwsevent.SourceKindAppStream,
dwsevent.IdentityHash(dir),
)
listener, err := transport.Listen(endpoint)
if err != nil {
t.Fatalf("Listen() = %v", err)
}
defer listener.Close()
serverDone := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
r := transport.NewReader(conn)
w := transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
serverDone <- err
return
}
if hello.Type != transport.FrameTypeHello || hello.Role != transport.HelloRoleStop {
serverDone <- errors.New("unexpected stop hello")
return
}
serverDone <- w.WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: "stop_request",
})
}()
if err := requestBusStop(endpoint); err != nil {
t.Fatalf("requestBusStop() = %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("stop protocol server = %v", err)
}
}
func TestCrossPlatformCoverageRequestBusStopErrors(t *testing.T) {
t.Run("dial", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return nil, errors.New("dial failed")
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("write", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return &queryErrorConn{failAt: 1}, nil
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("read", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return &queryErrorConn{}, nil
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("unexpected response", func(t *testing.T) {
client, server := net.Pipe()
t.Cleanup(func() {
_ = client.Close()
_ = server.Close()
})
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return client, nil
})
serverDone := make(chan error, 1)
go func() {
var hello transport.Hello
if err := transport.NewReader(server).ReadJSON(&hello); err != nil {
serverDone <- err
return
}
serverDone <- transport.NewWriter(server).WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: "unexpected",
})
}()
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
if err := <-serverDone; err != nil {
t.Fatalf("stop protocol server = %v", err)
}
})
}
func TestStop_SignalsLiveProcess(t *testing.T) {
skipOnWindows(t)
dir := shortTempDir(t)
@@ -63,6 +330,7 @@ func TestStop_SignalsLiveProcess(t *testing.T) {
}
}()
pid := cmd.Process.Pid
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
// Reap the child in background so Wait doesn't leave a zombie.
waited := make(chan error, 1)
@@ -111,6 +379,7 @@ func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) {
_, _ = cmd.Process.Wait()
}()
pid := cmd.Process.Pid
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
t.Fatal(err)
+4 -5
View File
@@ -17,8 +17,7 @@ package busctl
import "os"
// stopSignal returns the graceful-shutdown signal for Windows. The Go
// runtime maps os.Interrupt to TerminateProcess for non-console-group
// processes — not truly graceful, but acceptable for v1 (Windows graceful
// shutdown via Ctrl+Break is in the v2 backlog, plan §16).
func stopSignal() os.Signal { return os.Interrupt }
// stopSignal is only the fallback after the graceful IPC stop path fails or
// times out. Go maps os.Kill to TerminateProcess on Windows; os.Interrupt is
// unsupported and returns syscall.EWINDOWS.
func stopSignal() os.Signal { return os.Kill }
@@ -0,0 +1,27 @@
// 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.
//go:build windows
package busctl
import (
"os"
"testing"
)
func TestCrossPlatformCoverageWindowsStopFallbackUsesKill(t *testing.T) {
if stopSignal() != os.Kill {
t.Fatalf("stopSignal() = %v, want os.Kill", stopSignal())
}
}
+808 -446
View File
File diff suppressed because it is too large Load Diff
+260 -8
View File
@@ -3,6 +3,7 @@ package helpers
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
@@ -32,14 +34,26 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
t.Helper()
command, _, err := newChatCommand().Find(path)
InitDeps(&scriptedToolCaller{})
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
installExampleGlobalFlags(root)
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().Bool("verbose", false, "")
command, _, err := root.Find(path)
if err != nil {
return err
}
for name, value := range flags {
if err := command.Flags().Set(name, value); err != nil {
flag := command.Flag(name)
if flag == nil {
return fmt.Errorf("no such flag -%s", name)
}
if err := flag.Value.Set(value); err != nil {
return err
}
flag.Changed = true
}
return command.RunE(command, nil)
}
@@ -93,7 +107,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
hint string
}{
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
} {
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
@@ -113,6 +127,80 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
}
}
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
restoreChatManifestExternalVisibleFlags(nil)
mismatchedRoot := &cobra.Command{Use: "chat"}
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
missingPrimary := &cobra.Command{Use: "leaf"}
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
t.Fatalf("alias registered without canonical flag: %#v", flag)
}
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
skipGroup.Flags().String("conversation-id", "", "")
skipGroup.Flags().String("group-name", "", "")
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
t.Fatalf("group alias registered beside group-name: %#v", flag)
}
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
t.Fatal("non-group alias was not registered")
}
preRunCalled := false
withPreRun := &cobra.Command{
Use: "leaf",
PreRunE: func(cmd *cobra.Command, args []string) error {
preRunCalled = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
withPreRun.Flags().String("conversation-id", "", "")
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
withPreRun.SetArgs([]string{"--group", "cid"})
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
t.Fatalf("execute with alias and previous PreRunE: %v", err)
}
if !preRunCalled {
t.Fatal("previous PreRunE was not called")
}
}
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
command, _, err := root.Find([]string{"message", "forward"})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"message-id", "msg-id"} {
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
}
}
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
t.Fatal(err)
}
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
t.Fatal(err)
}
err = command.RunE(command, nil)
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
t.Fatalf("forward missing message id error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -199,9 +287,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
{"group", "set-history", "--group=cid", "--option=ALL"},
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
{"text", "translate", "--query=hello", "--to=zh_CN"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
@@ -293,7 +382,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send-card",
"--group=cid",
"--conversation-id=cid",
"--at-open-dingtalk-ids=D1,D2,D1",
"--at-all",
)
@@ -314,13 +403,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
name string
args []string
}{
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
t.Fatalf("error = %v, want group-only mention validation", err)
}
if caller.calls != 0 {
@@ -330,6 +419,168 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
args []string
want map[string]any
}{
{
name: "group alias",
args: []string{"--group=cid"},
want: map[string]any{"openConversationId": "cid"},
},
{
name: "receiver alias",
args: []string{"--receiver=DAAAAAAAAAAAiE"},
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
}
})
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
flag string
}{
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
{name: "hidden group alias", flag: "--group=cid"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group", "audit-join-validation",
tc.flag,
"--record-id=123",
"--applicant=D-applicant",
"--inviter=D-inviter",
"--status=AuditDelete",
"--description=deny",
)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"openConversationId": "cid",
"applyRecordId": int64(123),
"applicantUid": "D-applicant",
"inviterUid": "D-inviter",
"status": "AuditDelete",
"auditDescription": "deny",
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
}
})
}
}
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
path []string
flag map[string]string
want string
}{
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
}
probe := newChatCommand()
messageList, _, err := probe.Find([]string{"message", "list"})
if err != nil {
t.Fatal(err)
}
if got, err := chatConversationID(messageList); err != nil || got != "" {
t.Fatalf("empty chatConversationID = %q, %v", got, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := runChatCoverageDirect(t, tc.path, tc.flag)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want containing %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
t.Run(alias, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
t.Fatal(err)
}
if caller.server != "im" || caller.tool != "query_msg_read_status" {
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
}
if got := caller.args["openConversationId"]; got != "cid-1" {
t.Fatalf("openConversationId = %#v, want cid-1", got)
}
if got := caller.args["openMessageId"]; got != "msg-1" {
t.Fatalf("openMessageId = %#v, want msg-1", got)
}
})
}
}
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -343,6 +594,7 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
oldGet := httpGetFile
+170 -2
View File
@@ -17,8 +17,88 @@ import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
if err != nil {
t.Fatal(err)
}
canonical := leaf.Flags().Lookup("conversation-id")
if canonical == nil || canonical.Hidden {
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
}
legacy := leaf.Flags().Lookup("group")
if legacy == nil || !legacy.Hidden {
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
t.Fatalf("group alias_of annotation = %#v", got)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("group alias_origin annotation = %#v", got)
}
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
t.Fatalf("hidden group alias kept required annotation: %#v", got)
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
restoreChatGroupBotsLegacyRequired(nil)
restoreChatPendingMigrationCanonicalRequired(nil)
root := &cobra.Command{Use: "chat"}
root.AddCommand(&cobra.Command{Use: "other"})
restoreChatGroupBotsLegacyRequired(root)
restoreChatPendingMigrationCanonicalRequired(root)
}
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "bots"})
if err != nil {
t.Fatal(err)
}
group := leaf.Flags().Lookup("group")
if group == nil || group.Hidden {
t.Fatalf("group flag = %#v, want visible legacy flag", group)
}
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
t.Fatalf("group required annotation = %#v, want true", got)
}
if leaf.Flags().Lookup("conversation-id") != nil {
t.Fatalf("chat group bots still exposes migrated --conversation-id")
}
if leaf.Flags().Lookup("group-name") != nil {
t.Fatalf("chat group bots still exposes migrated --group-name")
}
}
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
if err != nil {
t.Fatal(err)
}
canonical := leaf.Flags().Lookup("conversation-id")
if canonical == nil {
t.Fatal("missing conversation-id flag")
}
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
t.Fatalf("conversation-id required annotation = %#v, want true", got)
}
legacy := leaf.Flags().Lookup("group")
if legacy == nil || !legacy.Hidden {
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
t.Fatalf("group alias_of annotation = %#v", got)
}
}
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
tests := []struct {
name string
@@ -51,7 +131,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
contains: []string{
"send -> query-send-status -> edit",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
@@ -61,7 +141,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
contains: []string{
"send -> query-send-status -> recall",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
@@ -183,3 +263,91 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te
})
}
}
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
t.Run(command, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", command, "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
}
help := output.String()
if !strings.Contains(help, "--conversation-id") {
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
}
for _, visible := range []string{"--group", "--id", "--chat"} {
if !strings.Contains(help, visible+" string") {
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
}
}
})
}
}
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"group", "bots", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
}
help := output.String()
if !strings.Contains(help, "--group string") {
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
}
for _, hidden := range []string{"--conversation-id", "--group-name"} {
if strings.Contains(help, hidden) {
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
}
}
}
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", "send-card", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
}
help := output.String()
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
if !strings.Contains(help, visible) {
t.Fatalf("send-card help missing %s:\n%s", visible, help)
}
}
for _, visible := range []string{"--group", "--receiver"} {
if !strings.Contains(help, visible+" string") {
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
}
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
}
help := output.String()
if !strings.Contains(help, "--conversation-id") {
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
}
if strings.Contains(help, "--group string") {
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
}
}
+67 -19
View File
@@ -81,6 +81,32 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
},
},
},
{
name: "open-conversation-id alias",
args: []string{
"message", "update-text-emotion",
"--open-conversation-id", "conv-3",
"--message-id", "msg-3",
"--old-emotion-id", "old-3",
"--emotion-id", "new-3",
"--emotion-name", "smile",
"--text", "done",
"--background-id", "im_bg_2",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-3",
"openMsgId": "msg-3",
"oldEmotionId": "old-3",
"emotionId": "new-3",
"emotionName": "smile",
"text": "done",
"backgroundId": "im_bg_2",
},
},
},
}
for _, test := range tests {
test := test
@@ -127,7 +153,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
{
name: "missing conversation-id and aliases",
args: dropFlag("--conversation-id"),
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
},
{
name: "missing old-emotion-id",
@@ -135,7 +161,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
wantErr: `required flag(s) "old-emotion-id" not set`,
},
{
name: "missing msg-id and background-id",
name: "missing message-id and background-id",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
@@ -144,7 +170,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
"--emotion-name", "like",
"--text", "nice",
},
wantErr: `required flag(s) "background-id", "msg-id" not set`,
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
},
}
for _, test := range tests {
@@ -163,19 +189,41 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
}
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config", "--group", "conv-1")
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
want := guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
for _, test := range []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "legacy group alias",
args: []string{"group", "get-mute-config", "--group", "conv-1"},
want: guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
},
},
{
name: "canonical conversation id",
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
want: guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-2"},
},
},
} {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
}
}
@@ -196,12 +244,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config")
if err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("err = %v, want message containing --group", err)
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("err = %v, want message containing --conversation-id", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
+281 -6
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -492,10 +493,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
return nil
}
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
// 2. HTTP PUT file content to OSS
// 3. insert_document_block with attachment element
// 4. list_document_blocks → prove the uploaded resource is visible in the document
func runMediaInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -551,7 +553,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -569,7 +571,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 2: HTTP PUT file to OSS
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -592,7 +594,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 3: insert block into document
deps.Out.PrintInfo("[3/3] 插入块到文档...")
deps.Out.PrintInfo("[3/4] 插入块到文档...")
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
@@ -644,7 +646,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
insertArgs["referenceBlockId"] = v
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
if err != nil {
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
@@ -665,6 +668,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
apperrors.WithCause(err),
)
}
insertResult := map[string]any{}
if strings.TrimSpace(insertText) != "" {
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
}
}
insertedBlockID := insertedDocBlockID(insertResult)
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
if verifyErr != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
}
if insertedBlockID == "" {
insertedBlockID = verifiedBlockID
}
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
@@ -674,16 +694,271 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
"inserted": true, "verified": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "success"},
},
})
}
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
return apperrors.NewAPI(
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_verification_failed"),
apperrors.WithFailureStage("verify"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "failed"},
},
}),
apperrors.WithCause(cause),
)
}
var docMediaVerifyWait = waitForDocVerification
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
if err != nil {
lastErr = err
} else {
lastErr = nil
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
return found, nil
}
}
if attempt < len(delays) {
if err := docMediaVerifyWait(ctx, delays[attempt]); err != nil {
return "", err
}
}
}
if lastErr != nil {
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
}
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
}
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
const pageSize = 50
const maxItems = 5000
all := make([]any, 0, pageSize)
seenPageIdentities := map[string]bool{}
for start := 0; start < maxItems; start += pageSize {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
}
payload = nestedDocMap(payload)
blocks, ok := payload["blocks"].([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
pageIdentity := docBlockPageIdentity(blocks)
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
return nil, fmt.Errorf("list_document_blocks 分页停滞")
}
if pageIdentity != "" {
seenPageIdentities[pageIdentity] = true
}
all = append(all, blocks...)
hasMore, hasMoreKnown := payload["hasMore"].(bool)
if hasMoreKnown && !hasMore {
return all, nil
}
if !hasMoreKnown {
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
return all, nil
}
}
if !hasMoreKnown && len(blocks) < pageSize {
return all, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
}
}
return nil, fmt.Errorf("文档块超过安全回读上限")
}
func docBlockPageIdentity(blocks []any) string {
if len(blocks) == 0 {
return ""
}
ids := make([]string, 0, len(blocks))
for _, value := range blocks {
id := ""
switch block := value.(type) {
case map[string]any:
id = directDocBlockIdentity(block)
if id == "" {
if element, ok := block["element"].(map[string]any); ok {
id = directDocBlockIdentity(element)
}
}
case []any:
if len(block) > 1 {
if attributes, ok := block[1].(map[string]any); ok {
id = directDocBlockIdentity(attributes)
}
}
}
if id == "" {
return ""
}
ids = append(ids, id)
}
encoded, _ := json.Marshal(ids)
return string(encoded)
}
func directDocBlockIdentity(block map[string]any) string {
for _, key := range []string{"blockId", "id", "uuid", "elementId"} {
if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func nestedDocMap(data map[string]any) map[string]any {
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
return nestedDocMap(nested)
}
}
return data
}
func nestedDocString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
orderedKeys := make([]string, 0, len(typed))
for key := range typed {
orderedKeys = append(orderedKeys, key)
}
sort.Strings(orderedKeys)
for _, key := range orderedKeys {
if text := nestedDocString(typed[key], keys...); text != "" {
return text
}
}
case []any:
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
}
return ""
}
// insertedDocBlockID only accepts explicit block IDs from the insert result or
// known response wrappers. Arbitrary IDs may belong to the document, operator,
// or request and must not become a hard constraint for the media readback.
func insertedDocBlockID(data map[string]any) string {
for _, key := range []string{"blockId", "elementId"} {
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if text := insertedDocBlockID(inner); text != "" {
return text
}
}
}
return ""
}
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
for _, value := range blocks {
candidateID := nestedDocString(value, "blockId", "id", "uuid")
if candidateID == "" || (blockID != "" && candidateID != blockID) {
continue
}
mediaValue := docMediaReadbackValue(value)
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
return candidateID
}
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
return candidateID
}
}
return ""
}
func docMediaReadbackValue(value any) any {
block, ok := value.(map[string]any)
if !ok {
return value
}
encoded, ok := block["jsonml"].(string)
if !ok || strings.TrimSpace(encoded) == "" {
return value
}
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) != nil {
return value
}
return decoded
}
func docNumberAsInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
if typed >= 0 && typed == float64(int(typed)) {
return int(typed), true
}
case int:
return typed, typed >= 0
}
return 0, false
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
var data map[string]any
+226 -1
View File
@@ -3,7 +3,9 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,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"
"github.com/spf13/cobra"
)
@@ -74,6 +77,8 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Cleanup(func() { os.Args = oldArgs })
oldPut, oldGet := httpPutFile, httpGetFile
t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
testseam.Swap(t, &helperSleep, func(time.Duration) {})
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
@@ -193,6 +198,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Fatal("media insert failure returned nil")
}
})
t.Run("media insert response parse failure", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`},
{text: `{`},
}}
if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
t.Fatal("invalid media insert response returned nil")
}
})
t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"blockId":"media-block"}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
if caller.args["format"] != "jsonml" {
t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"])
}
})
t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
})
t.Run("large image becomes attachment", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
large := filepath.Join(t.TempDir(), "large.png")
@@ -202,7 +248,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
if err := os.Truncate(large, 21*1024*1024); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{}`},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, large, "image/png"); err != nil {
t.Fatal(err)
}
@@ -240,6 +290,181 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) {
testseam.Swap(t, &helperSleep, func(time.Duration) {})
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
ctx := context.Background()
for _, tc := range []struct {
name string
steps []scriptedToolStep
}{
{"call failure", []scriptedToolStep{{err: errors.New("read")}}},
{"invalid json", []scriptedToolStep{{text: `{`}}},
{"missing blocks", []scriptedToolStep{{text: `{}`}}},
{"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}},
{"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}},
} {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("defensive readback returned nil")
}
})
}
t.Run("total count and nested payload", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}})
blocks, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) {
blocks := make([]any, 50)
for index := range blocks {
blocks[index] = map[string]any{"blockType": "paragraph"}
}
first, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": true, "totalCount": 100})
if err != nil {
t.Fatal(err)
}
second, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": false, "totalCount": 100})
if err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: string(first)}, {text: string(second)}}}
installScriptedCaller(t, caller)
got, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(got) != 100 || caller.calls != 2 {
t.Fatalf("blocks=%d calls=%d err=%v, want 100 blocks from two pages", len(got), caller.calls, err)
}
})
t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"blocks":[{"id":"first"}],"hasMore":true,"totalCount":1}`},
{text: `{"blocks":[{"id":"second"}],"hasMore":false,"totalCount":2}`},
}}
installScriptedCaller(t, caller)
got, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(got) != 2 || caller.calls != 2 {
t.Fatalf("blocks=%d calls=%d err=%v, want both explicitly advertised pages", len(got), caller.calls, err)
}
})
t.Run("page identity accepts nested and JSONML block IDs", func(t *testing.T) {
if got := docBlockPageIdentity([]any{map[string]any{"element": map[string]any{"blockId": "nested-block"}}}); got != `["nested-block"]` {
t.Fatalf("nested block page identity = %q", got)
}
if got := docBlockPageIdentity([]any{[]any{"p", map[string]any{"uuid": "jsonml-block"}}}); got != `["jsonml-block"]` {
t.Fatalf("JSONML block page identity = %q", got)
}
})
t.Run("unknown pagination short page", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}})
if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("bounded retry failure", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil {
t.Fatal("missing media unexpectedly verified")
}
})
t.Run("transient read failure retries", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{err: errors.New("temporary read failure")},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
installScriptedCaller(t, caller)
blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "")
if err != nil || blockID != "media-block" || caller.calls != 2 {
t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err)
}
})
t.Run("block identity alone does not verify media", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil {
t.Fatal("matching block ID with a different resource unexpectedly verified")
}
})
t.Run("block read safety limit", func(t *testing.T) {
steps := make([]scriptedToolStep, 100)
for index := range steps {
steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)}
}
installScriptedCaller(t, &scriptedToolCaller{steps: steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("oversized block read returned nil")
}
})
if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true {
t.Fatalf("nested map=%#v", got)
}
if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" {
t.Fatal("nested string traversal failed")
}
if got := nestedDocString(map[string]any{"z": map[string]any{"id": "last"}, "a": map[string]any{"id": "first"}}, "id"); got != "first" {
t.Fatalf("nested string traversal = %q, want deterministic key order", got)
}
if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" {
t.Fatalf("trusted inserted block ID = %q, want block", got)
}
if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" {
t.Fatalf("untrusted inserted block ID = %q, want empty", got)
}
blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}}
if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" {
t.Fatal("media block matching failed")
}
if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" {
t.Fatal("invalid JSONML unexpectedly verified")
}
if got := docMediaReadbackValue("plain"); got != "plain" {
t.Fatalf("non-object media readback = %#v, want unchanged value", got)
}
for _, tc := range []struct {
value any
want bool
}{
{float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false},
} {
_, ok := docNumberAsInt(tc.value)
if ok != tc.want {
t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
}
}
}
func TestCrossPlatformCoverageDocMediaReadbackStopsOnCancellation(t *testing.T) {
if err := waitForDocVerification(nil, time.Nanosecond); err != nil {
t.Fatalf("completed verification wait = %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled verification wait = %v, want context.Canceled", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}}
installScriptedCaller(t, caller)
if _, err := verifyInsertedDocMedia(cancelled, "node", "", "missing", ""); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled media verification = %v, want context.Canceled", err)
}
if caller.calls != 1 {
t.Fatalf("cancelled media verification calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
t.Cleanup(func() {
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
@@ -194,6 +195,7 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
+45 -1
View File
@@ -60,6 +60,11 @@ func translateBatchOp(op map[string]any) (map[string]any, error) {
if !ok {
return nil, fmt.Errorf("unsupported toolName %q: must be a CLI command name (e.g. \"range clear\", \"range update\", \"merge-cells\"). Run 'dws sheet batch-update --help' for the full list", toolName)
}
if toolName == "set-dropdown" {
if err := validateBatchSetDropdownInput(input); err != nil {
return nil, err
}
}
return map[string]any{
"toolName": mapping.mcpTool,
@@ -67,6 +72,33 @@ func translateBatchOp(op map[string]any) (map[string]any, error) {
}, nil
}
func validateBatchSetDropdownInput(input map[string]any) error {
if _, exists := input["source-colors"]; exists {
return fmt.Errorf("set-dropdown: 顶层 source-colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持")
}
if _, exists := input["colors"]; exists {
return fmt.Errorf("set-dropdown: 顶层 colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持")
}
options, hasOptions := input["options"]
hasOptions = hasOptions && options != nil
sourceRange := batchStr(input, "source-range")
sourceSheetID := batchStr(input, "source-sheet-id")
hasSourceRange := sourceRange != ""
if hasOptions == hasSourceRange {
return fmt.Errorf("set-dropdown: options 与 source-range 必须且只能指定一个")
}
if hasSourceRange != (sourceSheetID != "") {
return fmt.Errorf("set-dropdown: source-range 与 source-sheet-id 必须同时指定")
}
if hasSourceRange {
if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil {
return fmt.Errorf("set-dropdown: %w", err)
}
}
return nil
}
// ── BuildXxxArgs: CLI flag → MCP param 转换函数 ──────────────────────────────────
// 每个函数接收 CLI flag 名(kebab-case)的 map,输出 MCP 参数名(camelCase)的 map。
// 目前集中放在此文件;后续拆分命令文件时可移到各命令所在文件。
@@ -215,7 +247,15 @@ func BuildSetDropdownArgs(input map[string]any) map[string]any {
args := map[string]any{
"sheetId": batchStr(input, "sheet-id"),
"range": batchStr(input, "range"),
"options": input["options"],
}
if options, ok := input["options"]; ok && options != nil {
args["options"] = options
}
if sourceRange := batchStr(input, "source-range"); sourceRange != "" {
args["sourceRange"] = map[string]any{
"sheetId": batchStr(input, "source-sheet-id"),
"a1Notation": sourceRange,
}
}
if v, ok := input["multi-select"]; ok {
args["enableMultiSelect"] = v
@@ -417,6 +457,8 @@ func newBatchUpdateCmd() *cobra.Command {
toolName 使用 CLI 命令名(与原子命令一致),input 的键用 CLI flag 名去掉 --。
CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
source-range 的语义按 toolName 隔离:set-dropdown 中表示下拉候选项来源,
range fill/copy-to/move-to 中表示待填充、复制或移动的数据源区域。
支持的 CLI 命令名:
range clear / range update / merge-cells / unmerge-cells / update-dimension
@@ -425,6 +467,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
set-dropdown / delete-dropdown / csv-put / delete-float-image
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
set-dropdown 不接受顶层 colors/source-colors;Inline 颜色应写在 options[].color,
SourceRange 颜色写入暂不支持。
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
dws sheet group-dimension 命令。
+9 -1
View File
@@ -23,7 +23,15 @@ func TestCrossPlatformCoverageSheetBatchOperationTranslationCoversEveryMapping(t
"group-state": "fold",
}
for name, mapping := range batchOpDispatch {
got, err := translateBatchOp(map[string]any{"toolName": name, "input": input})
opInput := input
if name == "set-dropdown" {
opInput = make(map[string]any, len(input))
for key, value := range input {
opInput[key] = value
}
delete(opInput, "source-range")
}
got, err := translateBatchOp(map[string]any{"toolName": name, "input": opInput})
if err != nil {
t.Errorf("translateBatchOp(%q): %v", name, err)
continue
+10 -2
View File
@@ -388,7 +388,10 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
csv 带 [row=N] 行号前缀的 CSV 文本
colIndices 列字母映射数组(定位列用 colIndices[j],禁止手数逗号)
rowIndices 行号映射数组
hasMore 是否因 maxChars 截断
hasMore 目标范围是否还有未返回数据
truncationReasons 部分返回原因:max_cells / max_chars
resolvedRange 未传 --range 时底层解析出的完整目标范围
returnedRange 本次实际完整返回的范围
取值模式(--value-render-option):
formatted_value 格式化后的展示值(默认),如 ¥1,000.00、2025-06-01
@@ -398,9 +401,14 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
与 range read 的区别:
- CSV 格式 token 消耗约为 JSON 的 1/3
- 支持选择取值模式
- 自动防爆(max-chars 截断 + has_more 标志)
- 自动防爆(30,000 单元格 / max-chars 上限 + hasMore 标志)
- [row=N] 前缀防止行号计算错误
当 hasMore=true 时本命令不会自动续读;请结合目标范围和
returnedRange 从下一行显式传 --range。max_cells 不能通过增大
--max-chars 解决。收到 forbidden.document.sizeOverLimit 表示工作簿整体
无法装载,应创建更小副本或拆分工作簿,缩小 --range 不能解决。
注意:csv-get 不返回合并单元格结构。查看合并范围请使用
dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。`,
Example: ` dws sheet csv-get --node NODE_ID
+93 -22
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"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,6 +19,23 @@ func sizeTypeEnumHint(dimension string) string {
return "pixel / standard"
}
func validateDropdownSourceRangeInput(sourceSheetID, sourceRange string) error {
if strings.TrimSpace(sourceSheetID) == "" {
return fmt.Errorf("--source-sheet-id 不能为空")
}
sourceRange = strings.TrimSpace(sourceRange)
if sourceRange == "" {
return fmt.Errorf("--source-range 不能为空")
}
if strings.Contains(sourceRange, "!") {
return fmt.Errorf("--source-range 不能包含工作表前缀;请通过 --source-sheet-id 指定来源工作表")
}
if strings.HasPrefix(sourceRange, "=") || strings.Contains(sourceRange, ",") {
return fmt.Errorf("--source-range 必须是单一连续区域,不能是公式、表达式或多区域引用")
}
return nil
}
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
func newDimensionCmds() []*cobra.Command {
@@ -775,15 +793,24 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
Short: "设置下拉列表",
Long: `在钉钉表格的指定单元格范围内设置下拉列表。
设置后,用户可以在这些单元格中从预定义的选项列表中选择值。
支持自定义每个选项的颜色和是否允许多选。
Inline 模式通过 --options 提供静态选项,支持选项颜色。
SourceRange 模式通过 --source-sheet-id + --source-range 引用同一工作簿内的来源区域,
支持跨工作表、整行和整列引用;读取配置时不会展开来源区域的当前值或颜色。
两种模式都支持 --multi-select,并且 --options 与 --source-range 必须且只能指定一个。
如果目标范围已存在下拉列表,会被新的配置覆盖。
nodeId 支持传入文档链接 URL 或文档 ID(dentryUuid),系统自动识别。
sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获取。
--options 为 JSON 数组,每个元素包含 value(必填)和 color(可选)。
选项值不能包含英文逗号。`,
选项值不能包含英文逗号。
--source-range 只接受不含工作表前缀的 A1 区域,例如 T1:T3、T:T 或 1:3;
来源工作表由 --source-sheet-id 单独指定。SourceRange 颜色写入暂不支持。
已验证行为:工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 valid;
已验证的 move-dimension 场景会使引用变为 invalid。列删除、删除整个来源区域或来源工作表等未覆盖场景不能预设结果;
结构操作后应先回读 sourceRangeStatus,只有 invalid 时才重新选择来源并写入。`,
Example: ` # 设置单选下拉列表
dws sheet set-dropdown --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" \
--options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]'
@@ -793,32 +820,63 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
--multi-select
# 引用同一工作簿内另一工作表的区域作为候选项来源
dws sheet set-dropdown --node NODE_ID --sheet-id TARGET_SHEET_ID --range "C2:C100" \
--source-sheet-id SOURCE_SHEET_ID --source-range "T1:T3"
# 使用文档链接 URL
dws sheet set-dropdown --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" \
--sheet-id SHEET_ID --range "C1:C10" --options '[{"value":"是"},{"value":"否"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
optionsStr := mustGetFlag(cmd, "options")
var options []map[string]any
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
return fmt.Errorf("--options JSON 解析失败: %w", err)
sourceSheetID := mustGetFlag(cmd, "source-sheet-id")
sourceRange := mustGetFlag(cmd, "source-range")
hasOptions := optionsStr != ""
hasSourceRange := sourceRange != ""
if hasOptions == hasSourceRange {
return fmt.Errorf("--options 与 --source-range 必须且只能指定一个")
}
if len(options) == 0 {
return fmt.Errorf("--options 至少包含 1 个选项")
if hasSourceRange && sourceSheetID == "" {
return fmt.Errorf("使用 --source-range 时必须同时指定 --source-sheet-id")
}
for i, opt := range options {
val, ok := opt["value"].(string)
if !ok || val == "" {
return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i)
}
if strings.Contains(val, ",") {
return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val)
}
if !hasSourceRange && sourceSheetID != "" {
return fmt.Errorf("使用 --source-sheet-id 时必须同时指定 --source-range")
}
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"range": mustGetFlag(cmd, "range"),
"options": options,
}
if hasOptions {
var options []map[string]any
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
return fmt.Errorf("--options JSON 解析失败: %w", err)
}
if len(options) == 0 {
return fmt.Errorf("--options 至少包含 1 个选项")
}
for i, opt := range options {
val, ok := opt["value"].(string)
if !ok || val == "" {
return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i)
}
if strings.Contains(val, ",") {
return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val)
}
}
toolArgs["options"] = options
} else {
if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil {
return err
}
toolArgs["sourceRange"] = map[string]any{
"sheetId": sourceSheetID,
"a1Notation": sourceRange,
}
}
if multiSelect, _ := cmd.Flags().GetBool("multi-select"); multiSelect {
toolArgs["enableMultiSelect"] = true
@@ -839,36 +897,49 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
CLIPath: "sheet set-dropdown",
PrimaryCLIPath: "sheet set-dropdown",
},
Description: "为指定范围设置下拉列表(可多选、可带颜色)。",
Description: "为指定范围设置 Inline 或 SourceRange 下拉列表(可多选;颜色仅 Inline 支持)。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_dropdown_lists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定范围设置下拉列表(可多选、可带颜色)。",
AgentSummary: "为指定范围设置 Inline 或 SourceRange 下拉列表;SourceRange 可跨同一工作簿内的工作表。",
UseWhen: []string{"需要给单元格配置可选值下拉约束时"},
AvoidWhen: []string{"查看已有下拉用 get-dropdown;移除下拉用 delete-dropdown"},
Examples: []string{"dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'"},
Examples: []string{
"dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'",
"dws sheet set-dropdown --node <NODE_ID> --sheet-id <TARGET_SHEET_ID> --range \"B2:B100\" --source-sheet-id <SOURCE_SHEET_ID> --source-range \"T1:T3\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "multi-select", Property: "enableMultiSelect"},
{Name: "node", Property: "nodeId"},
{Name: "source-sheet-id", Property: "sourceRange.sheetId", Required: boolPtr(false), RequiredWhen: "--source-range is provided"},
{Name: "source-range", Property: "sourceRange.a1Notation", Required: boolPtr(false), RequiredWhen: "exactly one of --options or --source-range must be provided"},
},
},
})
setDropdownCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
setDropdownCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
setDropdownCmd.Flags().String("range", "", "目标单元格范围,A1 表示法,如 A2:A100 (必填)")
setDropdownCmd.Flags().String("options", "", `下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'`)
setDropdownCmd.Flags().String("options", "", `Inline 下拉选项 JSON 数组,与 --source-range 二选一,如 '[{"value":"选项1","color":"#ff0000"}]'`)
setDropdownCmd.Flags().String("source-sheet-id", "", "SourceRange 来源工作表 ID;使用 --source-range 时必填")
setDropdownCmd.Flags().String("source-range", "", "SourceRange 来源区域,与 --options 二选一;不含工作表前缀,如 T1:T3、T:T 或 1:3")
setDropdownCmd.Flags().Bool("multi-select", false, "是否允许多选(默认单选)")
cli.AnnotateRuntimeFlagFormat(setDropdownCmd, "source-range", "a1-range")
setDropdownCmd.MarkFlagsOneRequired("options", "source-range")
setDropdownCmd.MarkFlagsMutuallyExclusive("options", "source-range")
setDropdownCmd.MarkFlagsRequiredTogether("source-sheet-id", "source-range")
getDropdownCmd := &cobra.Command{
Use: "get-dropdown",
Short: "获取下拉列表配置",
Long: `查询钉钉表格指定范围内的下拉列表配置。
返回范围内所有单元格的下拉列表配置信息,包括选项值和颜色。
Inline 配置返回 conditionValues 和 options;SourceRange 配置返回
sourceType/sourceRangeStatus/enableMultiSelect,且仅在 sourceRangeStatus="valid" 时返回
sourceRange。invalid 结果仍保留配置组,但省略 sourceRange;两种状态都不展开来源区域的候选值或颜色。
如果范围内存在多个不同的下拉列表配置,会分别返回每组配置及其覆盖的单元格列表。
如果范围内没有设置下拉列表,返回空。
@@ -0,0 +1,248 @@
package helpers
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
)
func TestCrossPlatformCoverageSetDropdownSourceRangeCommand(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
installScriptedCaller(t, caller)
err := executeDimensionCoverage(t, "set-dropdown",
"--node", "node-1",
"--sheet-id", "target-sheet",
"--range", "B2:B100",
"--source-sheet-id", "source-sheet",
"--source-range", "$t$1:$t$3",
"--multi-select",
)
if err != nil {
t.Fatalf("set SourceRange dropdown: %v", err)
}
if caller.tool != "set_dropdown_lists" {
t.Fatalf("tool = %q, want set_dropdown_lists", caller.tool)
}
if _, exists := caller.args["options"]; exists {
t.Fatalf("SourceRange request must omit options: %#v", caller.args)
}
source, ok := caller.args["sourceRange"].(map[string]any)
if !ok {
t.Fatalf("sourceRange = %#v", caller.args["sourceRange"])
}
if source["sheetId"] != "source-sheet" || source["a1Notation"] != "$t$1:$t$3" {
t.Fatalf("sourceRange = %#v", source)
}
if caller.args["enableMultiSelect"] != true {
t.Fatalf("enableMultiSelect = %#v", caller.args["enableMultiSelect"])
}
}
func TestCrossPlatformCoverageSetDropdownInlineCommandStillOmitsSourceRange(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
installScriptedCaller(t, caller)
err := executeDimensionCoverage(t, "set-dropdown",
"--node", "node-1",
"--sheet-id", "sheet-1",
"--range", "A1:A3",
"--options", `[{"value":"one","color":"#ff0000"}]`,
)
if err != nil {
t.Fatalf("set inline dropdown: %v", err)
}
if _, exists := caller.args["sourceRange"]; exists {
t.Fatalf("inline request must omit sourceRange: %#v", caller.args)
}
options, ok := caller.args["options"].([]map[string]any)
if !ok || len(options) != 1 || options[0]["value"] != "one" {
t.Fatalf("options = %#v", caller.args["options"])
}
}
func TestCrossPlatformCoverageSetDropdownModeConstraints(t *testing.T) {
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
tests := []struct {
name string
args []string
want string
}{
{name: "neither", want: "one of the flags"},
{name: "both", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source", "--source-range", "A1:A3"}, want: "none of the others"},
{name: "source sheet without range", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source"}, want: "must all be set"},
{name: "range without source sheet", args: []string{"--source-range", "A1:A3"}, want: "must all be set"},
{name: "sheet prefix", args: []string{"--source-sheet-id", "source", "--source-range", "Sheet2!A1:A3"}, want: "不能包含工作表前缀"},
{name: "formula", args: []string{"--source-sheet-id", "source", "--source-range", "=A1:A3"}, want: "必须是单一连续区域"},
{name: "multi region", args: []string{"--source-sheet-id", "source", "--source-range", "A1:A3,C1:C3"}, want: "必须是单一连续区域"},
{name: "blank source range", args: []string{"--source-sheet-id", "source", "--source-range", " \t "}, want: "--source-range 不能为空"},
{name: "blank source sheet", args: []string{"--source-sheet-id", " \t ", "--source-range", "A1:A3"}, want: "--source-sheet-id 不能为空"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
args := append(append([]string{}, base...), tc.args...)
err := executeDimensionCoverage(t, "set-dropdown", args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, validation must fail before request", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageSetDropdownRunEValidation(t *testing.T) {
tests := []struct {
name string
options string
source string
rangeID string
want string
}{
{name: "neither mode", want: "必须且只能指定一个"},
{name: "source range without sheet", rangeID: "A1:A3", want: "必须同时指定 --source-sheet-id"},
{name: "source sheet without range", options: `[{"value":"one"}]`, source: "source", want: "必须同时指定 --source-range"},
{name: "invalid options JSON", options: "{", want: "JSON 解析失败"},
{name: "missing option value", options: `[{}]`, want: "value 为空"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd := dimensionCoverageCommand(t, "set-dropdown")
for name, value := range map[string]string{
"node": "node", "sheet-id": "sheet", "range": "A1",
"options": tc.options, "source-sheet-id": tc.source, "source-range": tc.rangeID,
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageSetDropdownPreservesSchemaConstraints(t *testing.T) {
cmd := dimensionCoverageCommand(t, "set-dropdown")
if constraints := runtimeannotate.CommandConstraints(cmd); !runtimeannotate.ConstraintsEmpty(constraints) {
t.Fatalf("constraints = %#v, want no new schema-level constraints", constraints)
}
}
func TestCrossPlatformCoverageBatchSetDropdownSourceRange(t *testing.T) {
got, err := translateBatchOp(map[string]any{
"toolName": "set-dropdown",
"input": map[string]any{
"sheet-id": "target-sheet",
"range": "B2:B100",
"source-sheet-id": "source-sheet",
"source-range": "T:T",
"multi-select": true,
},
})
if err != nil {
t.Fatalf("translate SourceRange batch op: %v", err)
}
input := got["input"].(map[string]any)
if _, exists := input["options"]; exists {
t.Fatalf("SourceRange batch input must omit options: %#v", input)
}
source := input["sourceRange"].(map[string]any)
if source["sheetId"] != "source-sheet" || source["a1Notation"] != "T:T" {
t.Fatalf("sourceRange = %#v", source)
}
invalid := []map[string]any{
{},
{"options": []any{map[string]any{"value": "one"}}, "source-sheet-id": "source", "source-range": "A1:A3"},
{"source-range": "A1:A3"},
{"source-sheet-id": "source", "source-range": "A1:A3", "colors": []any{"#fff"}},
{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}},
{"source-sheet-id": "source", "source-range": "Sheet2!A1:A3"},
}
for _, value := range invalid {
if _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": value}); err == nil {
t.Errorf("invalid batch SourceRange input %#v returned nil", value)
}
}
}
func TestCrossPlatformCoverageBatchSetDropdownValidationGuidance(t *testing.T) {
tests := []struct {
name string
input map[string]any
want string
}{
{
name: "inline top-level colors",
input: map[string]any{"options": []any{map[string]any{"value": "one"}}, "colors": []any{"#fff"}},
want: "Inline 颜色请写入 options[].color",
},
{
name: "source top-level colors",
input: map[string]any{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}},
want: "SourceRange 颜色写入暂不支持",
},
{
name: "blank source range",
input: map[string]any{"source-sheet-id": "source", "source-range": " \t "},
want: "--source-range 不能为空",
},
{
name: "blank source sheet",
input: map[string]any{"source-sheet-id": " \t ", "source-range": "A1:A3"},
want: "--source-sheet-id 不能为空",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": tc.input})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageDropdownHelpDescribesDynamicAndInvalidContracts(t *testing.T) {
setDropdown := dimensionCoverageCommand(t, "set-dropdown")
if !strings.Contains(setDropdown.Long, "会自动调整引用并保持 valid") ||
!strings.Contains(setDropdown.Long, "只有 invalid 时才重新选择来源并写入") {
t.Fatalf("set-dropdown help does not describe verified structural behavior:\n%s", setDropdown.Long)
}
getDropdown := dimensionCoverageCommand(t, "get-dropdown")
if !strings.Contains(getDropdown.Long, `仅在 sourceRangeStatus="valid" 时返回`) ||
!strings.Contains(getDropdown.Long, "invalid 结果仍保留配置组,但省略 sourceRange") {
t.Fatalf("get-dropdown help does not describe conditional sourceRange readback:\n%s", getDropdown.Long)
}
}
func TestCrossPlatformCoverageValidateSourceRangeDataValidation(t *testing.T) {
valid := []any{
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "missing-sheet", "a1Notation": "not-validated-locally"}, "enableMultiSelect": true},
}
for _, value := range valid {
if err := validateDataValidation(value, "dv"); err != nil {
t.Errorf("valid SourceRange data validation %#v: %v", value, err)
}
}
invalid := []any{
map[string]any{"type": "dropdown", "options": []any{map[string]any{"value": "one"}}, "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}},
map[string]any{"type": "dropdown", "sourceRange": "T1:T3"},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"a1Notation": "T1:T3"}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source"}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3", "colors": []any{"#fff"}}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}, "enableMultiSelect": "yes"},
}
for _, value := range invalid {
if err := validateDataValidation(value, "dv"); err == nil {
t.Errorf("invalid SourceRange data validation %#v returned nil", value)
}
}
}
+18 -1
View File
@@ -26,6 +26,16 @@ func newRangeCmd() *cobra.Command {
value 单元格值(内容由 --value-render-option 决定)
dataValidation 数据验证配置(下拉列表/复选框),无则为 null
hyperlink 单元格级超链接(path/sheet/range),无则省略
顶层还会返回 rowIndices / colIndices 与完成度字段:
hasMore 目标范围是否还有未返回数据
truncationReasons 部分返回原因(max_cells)
resolvedRange 未传 --range 时底层解析出的完整目标范围
returnedRange 本次实际完整返回的范围
单次最多返回 30,000 个单元格。hasMore=true 是部分成功,本命令
不会自动续读;请结合目标范围和 returnedRange 从下一行显式传
--range。收到 forbidden.document.sizeOverLimit 则表示工作簿整体无法装载,
应创建更小副本或拆分工作簿,缩小 --range 不能解决。
注意:range read/get 不返回合并单元格结构。查看合并范围请使用
dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。
@@ -157,7 +167,12 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
2) {"dataValidation":{"type":"none"}} → 显式清除该单元格 DV
3) {"dataValidation":{"type":"dropdown",...}} → 写新 dropdown(覆盖)
{"dataValidation":{"type":"checkbox",...}} → 写新 checkbox(覆盖)
dropdown: {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}}
Inline dropdown:
{"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}}
SourceRange dropdown(同一工作簿内可跨工作表;不展开来源 values,不支持 colors):
{"dataValidation":{"type":"dropdown","sourceRange":{"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"},"enableMultiSelect":false}}
dropdown 的 options 与 sourceRange 必须且只能传一个。SourceRange 支持普通区域、整行和整列;
a1Notation 不带工作表前缀,来源工作表单独写在 sheetId。
checkbox: {"dataValidation":{"type":"checkbox","checked":true}}
可与 text/richText 共存,也可单独使用(如 {dataValidation:{type:"none"}} 仅清除 DV 不写值)
@@ -169,6 +184,8 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;move-dimension 及其他未覆盖的删除/移动场景后先回读 sourceRangeStatus,仅 invalid 时重新选源写入
- 同一 cell 的 value/style 已写入但 SourceRange 校验失败时,服务端可返回 success=true,并通过 message 明确下拉未创建;必须检查 message,必要时重新读取确认
- 清空整片区域请用 dws sheet range clear`,
Example: ` # 写入文本
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
@@ -0,0 +1,202 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
)
func executeSheetReadCompletionCommand(t *testing.T, caller *scriptedToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
installScriptedCaller(t, caller)
installSheetProductArgs(t)
stdout := &bytes.Buffer{}
deps.Out.w = stdout
cmd := newSheetCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
return stdout, cmd.Execute()
}
func decodeSheetReadOutput(t *testing.T, stdout *bytes.Buffer) map[string]any {
t.Helper()
var got map[string]any
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode sheet read output %q: %v", stdout.String(), err)
}
return got
}
func assertSheetReadCompletionMetadata(t *testing.T, got map[string]any) {
t.Helper()
if hasMore, ok := got["hasMore"].(bool); !ok || !hasMore {
t.Fatalf("hasMore = %#v, want true", got["hasMore"])
}
if got["resolvedRange"] != "A1:A30001" {
t.Fatalf("resolvedRange = %#v", got["resolvedRange"])
}
if got["returnedRange"] != "A1:A30000" {
t.Fatalf("returnedRange = %#v", got["returnedRange"])
}
reasons, ok := got["truncationReasons"].([]any)
if !ok || len(reasons) == 0 || reasons[0] != "max_cells" {
t.Fatalf("truncationReasons = %#v", got["truncationReasons"])
}
}
// csv-get intentionally projects the MCP response rather than inventing a
// client-side page model. Keep all completion metadata visible and make one
// request only, even when the server reports a partial success.
func TestSheetCSVGetPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) {
const response = `{"csv":"[row=1]1\n","colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells","max_chars"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: response}},
}
stdout, err := executeSheetReadCompletionCommand(t, caller,
"csv-get", "--node", "NODE")
if err != nil {
t.Fatalf("csv-get: %v", err)
}
if caller.calls != 1 || caller.tool != "get_range_as_csv" {
t.Fatalf("calls/tool = %d/%q, want one get_range_as_csv call", caller.calls, caller.tool)
}
if _, exists := caller.args["range"]; exists {
t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"])
}
if got := strings.TrimSpace(stdout.String()); got != response {
t.Fatalf("raw csv-get response changed:\n got: %s\nwant: %s", got, response)
}
got := decodeSheetReadOutput(t, stdout)
assertSheetReadCompletionMetadata(t, got)
reasons := got["truncationReasons"].([]any)
if len(reasons) != 2 || reasons[1] != "max_chars" {
t.Fatalf("truncationReasons = %#v, want max_cells and max_chars", reasons)
}
}
// range read parses the get_cell_infos envelope to remove empty per-cell
// schema shells. That cleanup must not discard new top-level completion fields.
func TestSheetRangeReadPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) {
const response = `{"cells":[[{"value":"1","dataValidation":{"type":null},"hyperlink":{"url":null}}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{text: response}},
}
stdout, err := executeSheetReadCompletionCommand(t, caller,
"range", "read", "--node", "NODE")
if err != nil {
t.Fatalf("range read: %v", err)
}
if caller.calls != 1 || caller.tool != "get_cell_infos" {
t.Fatalf("calls/tool = %d/%q, want one get_cell_infos call", caller.calls, caller.tool)
}
if _, exists := caller.args["range"]; exists {
t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"])
}
got := decodeSheetReadOutput(t, stdout)
assertSheetReadCompletionMetadata(t, got)
cells := got["cells"].([]any)
row := cells[0].([]any)
cell := row[0].(map[string]any)
if _, exists := cell["dataValidation"]; exists {
t.Fatalf("empty dataValidation shell was not removed: %#v", cell)
}
if _, exists := cell["hyperlink"]; exists {
t.Fatalf("empty hyperlink shell was not removed: %#v", cell)
}
}
// sheet +read calls get_cell_infos through this direct passthrough instead of
// callMCPToolCellInfos. Lock that third read surface to the same raw envelope
// contract and to one request only.
func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T) {
const response = `{"cells":[[{"value":"1"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: response}},
}
installScriptedCaller(t, caller)
stdout := &bytes.Buffer{}
deps.Out.w = stdout
if err := CallMCPToolOnServer("sheet", "get_cell_infos", map[string]any{"nodeId": "NODE"}); err != nil {
t.Fatalf("direct get_cell_infos: %v", err)
}
if caller.calls != 1 || caller.server != "sheet" || caller.tool != "get_cell_infos" {
t.Fatalf("calls/server/tool = %d/%q/%q", caller.calls, caller.server, caller.tool)
}
if got := strings.TrimSpace(stdout.String()); got != response {
t.Fatalf("raw get_cell_infos response changed:\n got: %s\nwant: %s", got, response)
}
assertSheetReadCompletionMetadata(t, decodeSheetReadOutput(t, stdout))
}
// 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.
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."}`
tests := []struct {
name string
args []string
tool string
direct bool
}{
{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},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{text: response}},
}
var stdout *bytes.Buffer
var err error
if test.direct {
installScriptedCaller(t, caller)
stdout = &bytes.Buffer{}
deps.Out.w = stdout
err = CallMCPToolOnServer("sheet", test.tool, map[string]any{"nodeId": "NODE"})
} else {
stdout, err = executeSheetReadCompletionCommand(t, caller, test.args...)
}
if err == nil {
t.Fatal("large-CP response was accepted as success")
}
var cliErr *CLIError
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)
}
for _, want := range []string{
"forbidden.document.sizeOverLimit",
"The workbook data is too large to process.",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not preserve %q", err, want)
}
}
if stdout.Len() != 0 {
t.Fatalf("failure wrote a success body: %q", stdout.String())
}
if caller.calls != 1 || caller.tool != test.tool {
t.Fatalf("calls/tool = %d/%q, want one %s call", caller.calls, caller.tool, test.tool)
}
})
}
}
+40 -14
View File
@@ -239,22 +239,48 @@ func validateDataValidation(dvRaw any, path string) error {
}
switch dvType {
case "dropdown":
optionsRaw, ok := dv["options"]
if !ok {
return fmt.Errorf("%s: type=dropdown 必须包含 options 数组", path)
optionsRaw, hasOptions := dv["options"]
sourceRangeRaw, hasSourceRange := dv["sourceRange"]
hasOptions = hasOptions && optionsRaw != nil
hasSourceRange = hasSourceRange && sourceRangeRaw != nil
if hasOptions == hasSourceRange {
return fmt.Errorf("%s: type=dropdown 的 options 与 sourceRange 必须且只能包含一个", path)
}
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return fmt.Errorf("%s.options 必须为非空数组", path)
}
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i)
if hasOptions {
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return fmt.Errorf("%s.options 必须为非空数组", path)
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i)
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i)
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i)
}
}
} else {
sourceRange, ok := sourceRangeRaw.(map[string]any)
if !ok {
return fmt.Errorf("%s.sourceRange 必须为 object", path)
}
if _, exists := sourceRange["colors"]; exists {
return fmt.Errorf("%s.sourceRange.colors 暂不支持", path)
}
sheetID, ok := sourceRange["sheetId"].(string)
if !ok || strings.TrimSpace(sheetID) == "" {
return fmt.Errorf("%s.sourceRange.sheetId 必须为非空字符串", path)
}
a1Notation, ok := sourceRange["a1Notation"].(string)
if !ok || strings.TrimSpace(a1Notation) == "" {
return fmt.Errorf("%s.sourceRange.a1Notation 必须为非空字符串", path)
}
}
if multi, exists := dv["enableMultiSelect"]; exists {
if _, ok := multi.(bool); !ok {
return fmt.Errorf("%s.enableMultiSelect 必须为 boolean", path)
}
}
case "checkbox":
+6 -3
View File
@@ -428,7 +428,7 @@ func newWikiCommand() *cobra.Command {
})
// space create flags
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 100 字符)")
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 32 字符)")
spaceCreateCmd.Flags().String("desc", "", "知识库描述 (选填,不超过 500 字符)")
spaceCreateCmd.Flags().String("icon", "", "知识库图标标识 (选填)")
@@ -696,7 +696,7 @@ func newWikiCommand() *cobra.Command {
Short: "查询知识库成员列表",
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
ORG 类型授权不会出现在查询结果中。`,
Example: ` dws wiki member list --workspace <workspaceId>
@@ -717,6 +717,9 @@ ORG 类型授权不会出现在查询结果中。`,
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
if limit > 50 {
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
}
toolArgs["maxResults"] = limit
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
@@ -762,7 +765,7 @@ ORG 类型授权不会出现在查询结果中。`,
})
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
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")
@@ -116,8 +116,8 @@ func requireWukongWeeklySyncConfirmation(t *testing.T, err error) {
func TestCrossPlatformCoverageWukongWeeklyChatCategoryQueries(t *testing.T) {
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, "category", "list-by-conv")
if err == nil || !strings.Contains(err.Error(), "flag --group is required") {
t.Fatalf("missing group error = %v", err)
if err == nil || !strings.Contains(err.Error(), "missing required flag: --conversation-id") {
t.Fatalf("missing conversation ID error = %v", err)
}
requireWukongWeeklySyncNoCalls(t, caller)
@@ -378,15 +378,15 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpdateNickClearSemantics(t *testin
if findErr != nil {
t.Fatal(findErr)
}
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("direct missing group error = %v", err)
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("direct missing conversation-id error = %v", err)
}
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
"group", "update-nick")
if err == nil || !strings.Contains(err.Error(), "group") {
t.Fatalf("missing group error = %v", err)
if err == nil || !strings.Contains(err.Error(), "conversation-id") {
t.Fatalf("missing conversation-id error = %v", err)
}
requireWukongWeeklySyncNoCalls(t, caller)
@@ -428,8 +428,8 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpgradeValidationAndSafety(t *test
upgrade.Flags().Bool("yes", false, "")
}
_ = upgrade.Flags().Set("yes", "true")
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("direct missing group error = %v", err)
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("direct missing conversation-id error = %v", err)
}
tests := []struct {
+1
View File
@@ -217,6 +217,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
Enum: append([]string(nil), f.Enum...),
Aliases: append([]string(nil), f.Aliases...),
Input: append([]string(nil), f.Input...),
})
}
return out
+1 -1
View File
@@ -733,7 +733,7 @@ var RecordQuery = shortcut.Shortcut{
if rt.Changed("cursor") {
params["cursor"] = rt.Str("cursor")
}
return rt.CallMCP("query_records", params)
return executeRecordQuery(rt, params)
},
}
+24 -11
View File
@@ -98,7 +98,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
step.Status = "unknown"
step.Error = writeErr.Error()
}
remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch)
remaining, verifyErr := queryDeletedRecordsByIDs(rt, baseID, tableID, batch)
if verifyErr == nil && len(remaining) > 0 {
verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ","))
}
@@ -303,20 +303,33 @@ func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batc
}
func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids),
})
window, err := queryRecordWindow(rt, map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": ids,
}, len(ids))
if err != nil {
return nil, err
}
records, found := findRecords(data)
if !found {
return nil, fmt.Errorf("query_records read-back is missing records")
// Exact-ID verification below compares every requested ID with the returned
// records. The service can publish a continuation even after all requested
// IDs are present, so hasMore is not evidence that this bounded read-back is
// incomplete.
return window.Records, nil
}
func queryDeletedRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
remaining := make([]map[string]any, 0)
for offset := 0; offset < len(ids); offset += recordQueryServicePageSize {
end := minInt(offset+recordQueryServicePageSize, len(ids))
chunk := ids[offset:end]
window, err := queryRecordWindow(rt, map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": chunk,
}, len(chunk))
if err != nil {
return nil, err
}
remaining = append(remaining, window.Records...)
}
if responseHasMore(data) {
return nil, fmt.Errorf("query_records read-back is incomplete")
}
return records, nil
return remaining, nil
}
func recordIDs(records []map[string]any) []string {
+233 -1
View File
@@ -10,13 +10,245 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// query_records is backed by a service pager whose stable typed-envelope
// boundary is 20 records. Asking that layer to aggregate more than one page can
// drop the envelope fields used by the record projector, turning a successful
// read into an empty collection. Keep every remote request on one service page
// and aggregate only after each page has passed the CLI's shape validation.
const (
recordQueryServicePageSize = 20
recordQueryMaxConsecutiveEmptyPages = 3
)
type recordQueryWindow struct {
Records []map[string]any
HasMore bool
NextCursor string
Pages int
TotalCount any
HasTotalCount bool
}
func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit int) (recordQueryWindow, error) {
if limit <= 0 {
return recordQueryWindow{}, fmt.Errorf("record query limit must be positive, got %d", limit)
}
request := cloneAnyMap(params)
cursor, _ := request["cursor"].(string)
cursor = strings.TrimSpace(cursor)
seen := map[string]bool{}
if cursor != "" {
seen[cursor] = true
}
window := recordQueryWindow{Records: make([]map[string]any, 0, limit)}
consecutiveEmptyPages := 0
for len(window.Records) < limit {
pageSize := minInt(recordQueryServicePageSize, limit-len(window.Records))
request["limit"] = pageSize
if cursor == "" {
delete(request, "cursor")
} else {
request["cursor"] = cursor
}
data, err := rt.CallMCPData(serverMain, "query_records", request)
if err != nil {
return recordQueryWindow{}, err
}
if !window.HasTotalCount {
window.TotalCount, window.HasTotalCount = responseTotalCount(data)
}
records, found := findRecords(data)
if !found {
if explicitEmptyRecordQuery(data) {
window.Pages++
window.HasMore = false
window.NextCursor = ""
return window, nil
}
return recordQueryWindow{}, fmt.Errorf("query_records page %d is missing the records collection", window.Pages+1)
}
window.Records = append(window.Records, records...)
window.Pages++
if !responseHasMore(data) {
window.HasMore = false
window.NextCursor = ""
return window, nil
}
if len(records) == 0 {
consecutiveEmptyPages++
if consecutiveEmptyPages >= recordQueryMaxConsecutiveEmptyPages {
return recordQueryWindow{}, fmt.Errorf(
"query_records made no progress for %d consecutive pages",
consecutiveEmptyPages,
)
}
} else {
consecutiveEmptyPages = 0
}
next := responseCursor(data)
if next == "" {
return recordQueryWindow{}, fmt.Errorf("query_records page %d reports more data but no next cursor", window.Pages)
}
if seen[next] {
return recordQueryWindow{}, fmt.Errorf("query_records cursor cycle detected at %q", next)
}
seen[next] = true
cursor = next
window.HasMore = true
window.NextCursor = next
}
return window, nil
}
// explicitEmptyRecordQuery recognizes the service's reviewed zero-match wire
// shape. It is deliberately stricter than "records is absent": only a complete
// success envelope with an empty data object is accepted as an empty set.
func explicitEmptyRecordQuery(data map[string]any) bool {
if data == nil || data["success"] != true || data["status"] != "success" {
return false
}
payload, ok := data["data"].(map[string]any)
if !ok || len(payload) != 0 {
return false
}
if rawError, exists := data["error"]; exists && rawError != nil {
errorObject, ok := rawError.(map[string]any)
if !ok || len(errorObject) != 0 {
return false
}
}
return true
}
func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) error {
limit := 100
if rt.Changed("limit") {
limit = rt.Int("limit")
}
if limit < 1 || limit > recordBatchSize {
return fmt.Errorf("--limit must be in [1,%d], got %d", recordBatchSize, limit)
}
requestedIDs, exactIDQuery, err := recordQueryRequestedIDs(params)
if err != nil {
return err
}
if exactIDQuery {
if len(requestedIDs) > recordBatchSize {
return fmt.Errorf("--record-ids accepts at most %d unique IDs, got %d", recordBatchSize, len(requestedIDs))
}
params = cloneAnyMap(params)
params["recordIds"] = requestedIDs
limit = minInt(limit, len(requestedIDs))
}
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"tool": "query_records",
"arguments": params,
})
}
window, err := queryRecordWindow(rt, params, limit)
if err != nil {
return err
}
if exactIDQuery {
complete, err := validateExactRecordQuery(window.Records, requestedIDs)
if err != nil {
return err
}
if complete {
// The service may advertise unrelated continuation after every
// requested ID is already present. Exact-ID completion is stronger
// evidence than that residual pager state.
window.HasMore = false
window.NextCursor = ""
}
}
records := make([]any, 0, len(window.Records))
for _, record := range window.Records {
records = append(records, record)
}
data := map[string]any{
"records": records,
"hasMore": window.HasMore,
"page": window.Pages,
"size": len(window.Records),
}
if window.NextCursor != "" {
data["nextCursor"] = window.NextCursor
}
if window.HasTotalCount {
data["totalCount"] = window.TotalCount
}
return rt.Output(map[string]any{
"success": true,
"status": "success",
"data": data,
})
}
func responseTotalCount(data map[string]any) (any, bool) {
for {
if totalCount, exists := data["totalCount"]; exists {
return totalCount, true
}
nested, ok := data["data"].(map[string]any)
if !ok {
return nil, false
}
data = nested
}
}
func recordQueryRequestedIDs(params map[string]any) ([]string, bool, error) {
raw, exists := params["recordIds"]
if !exists {
return nil, false, nil
}
values, ok := raw.([]string)
if !ok {
return nil, false, fmt.Errorf("recordIds must be a string list, got %T", raw)
}
ids, err := parseRecordIDs(values)
if err != nil {
return nil, false, err
}
return ids, true, nil
}
func validateExactRecordQuery(records []map[string]any, requestedIDs []string) (bool, error) {
wanted := make(map[string]bool, len(requestedIDs))
for _, id := range requestedIDs {
wanted[id] = true
}
seen := make(map[string]bool, len(records))
for index, record := range records {
id := recordID(record)
if id == "" {
return false, fmt.Errorf("query_records exact-ID result %d is missing recordId", index)
}
if !wanted[id] {
return false, fmt.Errorf("query_records exact-ID result contains unexpected recordId %q", id)
}
if seen[id] {
return false, fmt.Errorf("query_records exact-ID result contains duplicate recordId %q", id)
}
seen[id] = true
}
return len(seen) == len(wanted), nil
}
func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) {
all := make([]map[string]any, 0)
cursor := ""
seen := map[string]bool{}
for page := 0; ; page++ {
request := cloneAnyMap(params)
request["limit"] = recordBatchSize
request["limit"] = recordQueryServicePageSize
if cursor != "" {
request["cursor"] = cursor
}
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"testing"
@@ -265,6 +266,446 @@ func recordListJSON(t *testing.T, records []map[string]any) string {
return string(raw)
}
func pagedRecordQueryResponse(t *testing.T, records []map[string]any, args map[string]any) string {
t.Helper()
limit, ok := args["limit"].(int)
if !ok || limit < 1 || limit > recordQueryServicePageSize {
t.Fatalf("query_records limit = %#v, want 1..%d", args["limit"], recordQueryServicePageSize)
}
offset := 0
if cursor, _ := args["cursor"].(string); cursor != "" {
if _, err := fmt.Sscanf(cursor, "offset-%d", &offset); err != nil {
t.Fatalf("invalid test cursor %q: %v", cursor, err)
}
}
end := minInt(offset+limit, len(records))
pageRecords := records[offset:end]
data := map[string]any{"records": pageRecords, "hasMore": end < len(records), "totalCount": len(records)}
if end < len(records) {
data["nextCursor"] = fmt.Sprintf("offset-%d", end)
}
raw, err := json.Marshal(map[string]any{
"success": true,
"hasMore": end < len(records),
"page": offset/recordQueryServicePageSize + 1,
"size": limit,
"data": data,
})
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func runRecordQueryShortcutCLI(t *testing.T, caller *upsertByKeyCaller, limit int, extra ...string) (map[string]any, 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", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
args := []string{"aitable", "+record-query", "--base-id", "base", "--table-id", "table", "--limit", fmt.Sprint(limit)}
root.SetArgs(append(args, extra...))
err := root.Execute()
if stdout.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode record query output %q: %v", stdout.String(), decodeErr)
}
return payload, err
}
func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) {
for _, size := range []int{20, 21, 22, 100} {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
records := updateFixtureRecords(0, size, "可见")
caller := &upsertByKeyCaller{}
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return pagedRecordQueryResponse(t, records, args), nil
}
payload, err := runRecordQueryShortcutCLI(t, caller, size)
if err != nil {
t.Fatalf("record query size %d: %v", size, err)
}
data, ok := payload["data"].(map[string]any)
if !ok || payload["success"] != true || data["hasMore"] != false || data["totalCount"] != float64(size) || len(data["records"].([]any)) != size {
t.Fatalf("record query size %d payload = %#v", size, payload)
}
wantCalls := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
if len(caller.calls) != wantCalls || data["page"] != float64(wantCalls) || data["size"] != float64(size) {
t.Fatalf("record query size %d calls=%d payload=%#v, want calls=%d", size, len(caller.calls), payload, wantCalls)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryDryRunStopsBeforeTransportE2E(t *testing.T) {
caller := &upsertByKeyCaller{}
payload, err := runRecordQueryShortcutCLI(t, caller, 5, "--query", "needle", "--dry-run")
if err != nil {
t.Fatalf("record query dry-run error = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("record query dry-run crossed transport: %#v", caller.calls)
}
arguments, _ := payload["arguments"].(map[string]any)
if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "query_records" ||
arguments["baseId"] != "base" || arguments["tableId"] != "table" || arguments["keyword"] != "needle" || arguments["limit"] != float64(5) {
t.Fatalf("record query dry-run payload = %#v", payload)
}
}
func TestCrossPlatformCoverageRecordQueryDryRunRejectsInvalidLocalPlanE2E(t *testing.T) {
for _, testCase := range []struct {
name string
limit int
extra []string
want string
}{
{name: "zero limit", limit: 0, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"},
{name: "excessive limit", limit: recordBatchSize + 1, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"},
{name: "empty record IDs", limit: 100, extra: []string{"--record-ids", " ", "--dry-run"}, want: "至少包含一个非空 recordId"},
{
name: "excessive record IDs",
limit: 100,
extra: []string{"--record-ids", strings.Join(recordIDFixtures(recordBatchSize+1), ","), "--dry-run"},
want: "at most 100 unique IDs",
},
} {
t.Run(testCase.name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
payload, err := runRecordQueryShortcutCLI(t, caller, testCase.limit, testCase.extra...)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("record query dry-run error = %v, want %q (payload=%#v)", err, testCase.want, payload)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid record query dry-run crossed transport: %#v", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryExactIDsIgnoreResidualContinuationE2E(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
if call != 0 {
return "", errors.New("exact-ID query followed residual continuation")
}
if args["limit"] != 2 {
t.Fatalf("exact-ID service limit = %#v, want 2", args["limit"])
}
ids, ok := args["recordIds"].([]string)
if !ok || !slices.Equal(ids, []string{"r1", "r2"}) {
t.Fatalf("exact-ID request IDs = %#v", args["recordIds"])
}
return `{"success":true,"data":{"records":[{"recordId":"r1"},{"recordId":"r2"}],"hasMore":true,"nextCursor":"residual"}}`, nil
}}
payload, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r2,r1,r2")
data, _ := payload["data"].(map[string]any)
if err != nil || len(caller.calls) != 1 || data["hasMore"] != false || data["size"] != float64(2) {
t.Fatalf("exact-ID payload=%#v calls=%d err=%v", payload, len(caller.calls), err)
}
if _, exists := data["nextCursor"]; exists {
t.Fatalf("exact-ID payload retained residual cursor: %#v", payload)
}
}
func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testing.T) {
t.Run("non-positive window limit", func(t *testing.T) {
caller := &upsertByKeyCaller{}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, nil, 0); err == nil {
t.Fatal("non-positive record query window limit accepted")
}
})
t.Run("initial cursor cycle", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":true,"nextCursor":"seed"}}`, nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, map[string]any{"cursor": " seed "}, 2); err == nil || !strings.Contains(err.Error(), "cursor cycle") {
t.Fatalf("initial cursor cycle error = %v", err)
}
})
t.Run("strict explicit empty shape", func(t *testing.T) {
for _, data := range []map[string]any{
{"success": true, "status": "success", "data": map[string]any{"unexpected": true}},
{"success": true, "status": "success", "data": map[string]any{}, "error": "bad"},
} {
if explicitEmptyRecordQuery(data) {
t.Fatalf("invalid empty-query envelope accepted: %#v", data)
}
}
})
t.Run("explicit empty page", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
return `{"success":true,"status":"success","error":{},"data":{}}`, nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
window, err := queryRecordWindow(rt, map[string]any{}, 1)
if err != nil || len(window.Records) != 0 || window.Pages != 1 || window.HasMore || window.NextCursor != "" {
t.Fatalf("explicit empty window=%#v err=%v", window, err)
}
})
t.Run("missing records collection", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
return `{}`, nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "missing the records collection") {
t.Fatalf("missing records error = %v", err)
}
})
t.Run("changing cursors cannot hide sustained empty pages", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return fmt.Sprintf(
`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"empty-%d"}}`,
call,
), nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "no progress") {
t.Fatalf("sustained empty pages error = %v", err)
}
if len(caller.calls) != recordQueryMaxConsecutiveEmptyPages {
t.Fatalf("query calls = %d, want %d", len(caller.calls), recordQueryMaxConsecutiveEmptyPages)
}
})
t.Run("invalid shortcut limit", func(t *testing.T) {
caller := &upsertByKeyCaller{}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "query"}
cmd.Flags().Int("limit", 0, "")
if err := cmd.Flags().Set("limit", "0"); err != nil {
t.Fatal(err)
}
rt := shortcut.RuntimeContextForTest(cmd, RecordQuery)
if err := executeRecordQuery(rt, map[string]any{}); err == nil {
t.Fatal("invalid shortcut limit accepted")
}
})
t.Run("invalid exact-ID request", func(t *testing.T) {
for _, testCase := range []struct {
name string
params map[string]any
want string
}{
{name: "wrong type", params: map[string]any{"recordIds": 1}, want: "string list"},
{name: "empty", params: map[string]any{"recordIds": []string{" "}}, want: "至少包含一个非空 recordId"},
{name: "over service limit", params: map[string]any{"recordIds": recordIDFixtures(recordBatchSize + 1)}, want: "at most 100 unique IDs"},
} {
t.Run(testCase.name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
err := executeRecordQuery(rt, testCase.params)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("exact-ID request error = %v, want %q", err, testCase.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid exact-ID request made %d service calls", len(caller.calls))
}
})
}
})
t.Run("invalid exact-ID response", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return `{"success":true,"data":{"records":[{"recordId":"other"}]}}`, nil
}}
if _, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r1"); err == nil || !strings.Contains(err.Error(), "unexpected recordId") {
t.Fatalf("unexpected exact-ID response error = %v", err)
}
if _, err := validateExactRecordQuery([]map[string]any{{}}, []string{"r1"}); err == nil || !strings.Contains(err.Error(), "missing recordId") {
t.Fatalf("missing exact-ID response error = %v", err)
}
duplicate := []map[string]any{{"recordId": "r1"}, {"recordId": "r1"}}
if _, err := validateExactRecordQuery(duplicate, []string{"r1", "r2"}); err == nil || !strings.Contains(err.Error(), "duplicate recordId") {
t.Fatalf("duplicate exact-ID response error = %v", err)
}
})
t.Run("shortcut query failure", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
return "", errors.New("query unavailable")
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if err := executeRecordQuery(rt, map[string]any{}); err == nil || !strings.Contains(err.Error(), "query unavailable") {
t.Fatalf("shortcut query failure = %v", err)
}
})
t.Run("bounded continuation is published", func(t *testing.T) {
records := updateFixtureRecords(0, 21, "visible")
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return pagedRecordQueryResponse(t, records, args), nil
}}
payload, err := runRecordQueryShortcutCLI(t, caller, 20)
data, _ := payload["data"].(map[string]any)
if err != nil || data["hasMore"] != true || data["nextCursor"] != "offset-20" {
t.Fatalf("bounded continuation payload=%#v err=%v", payload, err)
}
})
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return `{"deletedCount":1}`, nil
}
if call == 1 {
if _, exists := args["cursor"]; exists {
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
}
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
if args["cursor"] != "c1" {
t.Fatalf("continued deletion readback cursor = %#v", args["cursor"])
}
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || out == "" || len(caller.calls) != 3 {
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return `{"deletedCount":1}`, nil
}
if call == 1 {
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
t.Fatalf("delete continued remaining error = %#v", err)
}
})
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return `{"deletedCount":1}`, nil
}
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
t.Fatalf("delete stalled continuation error = %#v", err)
}
})
}
func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *testing.T) {
for _, operation := range []string{"update", "upsert", "delete"} {
for _, size := range []int{21, 22, 100} {
t.Run(fmt.Sprintf("%s_%d", operation, size), func(t *testing.T) {
records := updateFixtureRecords(0, size, "完成")
caller := &upsertByKeyCaller{}
queryCalls := 0
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return `{"success":true}`, nil
}
queryCalls++
if operation == "delete" {
ids := args["recordIds"].([]string)
empty := make([]map[string]any, len(ids))
response := pagedRecordQueryResponse(t, empty, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
page := payload["data"].(map[string]any)
page["records"] = []any{}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
response := pagedRecordQueryResponse(t, records, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
page := payload["data"].(map[string]any)
if len(page["records"].([]any))+recordQueryServicePageSize*(queryCalls-1) >= size {
// The live service can retain a continuation after it has
// returned every requested record ID. Exact-ID verification
// must use ID coverage, not this advisory bit.
page["hasMore"] = true
page["nextCursor"] = "ignored-after-complete-id-set"
payload["hasMore"] = true
}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
var out string
var err error
if operation == "delete" {
out, err = runRecordDeleteCLI(t, caller, recordIDs(records))
} else {
out, err = runRecordBatchCLI(t, caller, "+record-"+operation, records)
}
if err != nil || out == "" {
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
}
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
if queryCalls != wantQueries {
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
}
})
}
}
}
func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) {
records := updateFixtureRecords(0, 101, "完成")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
@@ -395,6 +836,10 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"deletedCount":1}`},
{text: `{"data":{"records":[]}}`},
}}
@@ -407,7 +852,7 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
t.Fatalf("delete output missing %s: %s", want, out)
}
}
if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" {
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
t.Fatalf("delete call sequence = %#v", caller.calls)
}
}
@@ -421,6 +866,17 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
}
})
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":1}`},
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || !strings.Contains(out, `"status": "verified_absent"`) {
t.Fatalf("delete explicit empty success = output:%q err:%v", out, err)
}
})
t.Run("missing records contract is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
@@ -450,6 +906,10 @@ func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
}}
@@ -0,0 +1,71 @@
// 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 TestCrossPlatformCoverageWikiSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_wiki.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "wiki" {
t.Fatalf("service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "wiki" {
if _, exists := registered[item.Command]; exists {
t.Fatalf("duplicate %s", item.Command)
}
registered[item.Command] = item
}
}
if len(registered) != 20 || len(source.Shortcuts) != 20 {
t.Fatalf("registered/catalog = %d/%d, want 20/20", 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 || item.Hidden || !record.Public {
t.Errorf("%s: not publicly reviewed", command)
}
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
t.Errorf("%s: semantic facts drifted", command)
}
if item.Risk != record.Risk {
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
}
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s: incomplete contract/safety/result/output", 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)
}
}
+10 -3
View File
@@ -1644,13 +1644,20 @@ var MessagesSendCard = shortcut.Shortcut{
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
}
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
payload["bizId"] = bizID
payload["flowStatus"] = status
payload["updated"] = updated
payload["updateAccepted"] = verification.Accepted
payload["updateVerified"] = verification.Verified
payload["updateVerificationEvidence"] = verification.Evidence
if verification.Accepted && !verification.Verified {
payload["updateWarning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新"
}
return rt.Output(payload)
},
}
@@ -1793,11 +1800,11 @@ var MessagesUpdateCard = shortcut.Shortcut{
if err != nil {
return err
}
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
if err != nil {
return cardUpdateVerificationError(bizID, err)
}
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, verification))
},
}
+27 -1
View File
@@ -1049,6 +1049,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
},
wantError: "biz-preserved",
},
{
name: "unverified update preserves id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
"im/update_streaming_card": `{"result":{"updated":false}}`,
}},
wantError: "biz-unverified",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
@@ -1073,6 +1081,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
@@ -1085,6 +1095,13 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["updateAccepted"] != true || payload["updateVerified"] != false || payload["updateWarning"] == "" {
t.Fatalf("card payload = %#v", payload)
}
})
for _, args := range [][]string{
@@ -1157,12 +1174,14 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
}
})
t.Run("success acknowledgement is verified", func(t *testing.T) {
t.Run("success acknowledgement is accepted but unverified", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "中文乱串",
@@ -1176,6 +1195,13 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("explicit update evidence succeeds", func(t *testing.T) {
+7 -3
View File
@@ -55,11 +55,15 @@ func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[strin
// ProjectStreamingCardUpdate preserves the lower response while making the
// verified target explicit for downstream consumers.
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
func ProjectStreamingCardUpdate(updated map[string]any, bizID string, verification CardUpdateVerification) map[string]any {
payload := cloneSendStatusMap(updated)
payload["contractVersion"] = StreamingCardContractVersion
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
payload["verified"] = true
payload["verificationEvidence"] = proof
payload["accepted"] = verification.Accepted
payload["verified"] = verification.Verified
payload["verificationEvidence"] = verification.Evidence
if verification.Accepted && !verification.Verified {
payload["warning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新"
}
return payload
}

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