Compare commits

..
Author SHA1 Message Date
chichuan 3519965285 ci: increase integration test timeouts 2026-07-30 20:52:18 +08:00
chichuan a7074bf53f Merge pull request #838 from DingTalk-Real-AI/codex/fix-scoped-coverage-timeout
fix: align scoped coverage and test timeout
2026-07-30 20:20:01 +08:00
chichuan 21144af79b fix: align scoped coverage and test timeout 2026-07-30 20:06:24 +08:00
github-actions[bot] 3bdc30badb Merge pull request #834 from wxianfeng/fix/event-subscription-retry-storm
fix(event): prevent subscription retry storms
2026-07-30 17:18:27 +08:00
wxianfeng b82e975429 test(app): preserve audit sink ownership in coverage gate 2026-07-30 16:25:56 +08:00
wxianfeng 2808e71cb6 fix(event): address retry storm review 2026-07-30 16:08:19 +08:00
wxianfeng ec99654854 fix(event): prevent subscription retry storms 2026-07-30 13:49:08 +08:00
github-actions[bot] 9aa76ea748 Merge pull request #806 from DingTalk-Real-AI/fix/param-hallucination
feat(param): 参数概念归一化治理与 IM 场景完善
2026-07-30 04:00:22 +00:00
克谨 885c3fe021 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:46:37 +08:00
github-actions[bot] d0d56cbaf5 Merge pull request #817 from DingTalk-Real-AI/codex/im-shortcut-gap-fill
feat(im): close shortcut capability gaps
2026-07-30 11:42:23 +08:00
克谨 dfba9546f4 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:06:02 +08:00
克谨 b3ba9fee97 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 10:43:51 +08:00
克谨 1e04e301ea Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 09:44:52 +08:00
克谨 5f038d440b test: reduce parameter alias race runtime 2026-07-30 09:44:30 +08:00
克谨 8936c20ef0 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 20:07:03 +08:00
克谨 e2abc70e84 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 17:35:07 +08:00
克谨 15a27a9f83 fix(cli): harden parameter preparse normalization 2026-07-29 17:33:44 +08:00
克谨 2e1cce8501 test(param): align category alias fixtures with title limits 2026-07-29 13:34:59 +08:00
克谨 870fba823b Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 13:18:47 +08:00
克谨 d083de5f84 fix(cli): normalize explicit boolean flag values safely 2026-07-29 13:18:27 +08:00
克谨 1fb966dbff fix(cli): centralize parameter alias generation entrypoint 2026-07-29 13:17:55 +08:00
克谨 7987fb3a35 chore(ci): retrigger pull request checks 2026-07-29 10:02:28 +08:00
克谨 3d6a9c6232 test(pipeline): cover shared flag matchers 2026-07-29 10:02:28 +08:00
克谨 195569ddfa fix(cli): harden parameter preparse integration 2026-07-29 10:02:28 +08:00
克谨 7827856876 fix(param): align aliases and bound exhaustive tests 2026-07-29 10:02:28 +08:00
克谨 f79f41e930 chore(param): exclude normalization specs from review 2026-07-29 10:02:27 +08:00
克谨 bfd53df9b2 feat(param): expand reviewed IM parameter normalization 2026-07-29 10:02:27 +08:00
克谨 4db117893e fix(param): freeze reviewed normalization baseline
Restore calendar helper behavior to main, finalize reviewed alias/guard decisions, cover payload and dry-run paths, and record the local migration freeze checkpoint.
2026-07-29 10:02:27 +08:00
克谨 b314749ef7 test(param): cover final alias payloads and guard errors 2026-07-29 10:02:27 +08:00
克谨 5133103a54 fix(param): harden command-scoped normalization safety 2026-07-29 10:02:27 +08:00
克谨 9faa332306 chore: ignore stray compiled param-aliases generator binary 2026-07-29 10:02:27 +08:00
克谨 17fa1e1b34 refactor(calendar): read canonical flags in event list after normalization
Now that alias spellings are normalized to canonical flags in the PreParse
pipeline, drop the redundant flagOrFallback tails in the event-list handler and
read --start/--end/--calendar-id/--cursor/--limit directly (keeping --count as a
deliberately separate flag). Behaviour is unchanged; the pilot test guards it.
2026-07-29 10:01:53 +08:00
克谨 af1f8ccd05 test(param): fixture regression through delivery path + co-occurrence gate
Add the ⑥ regression gate that replays every reviewed validation_fixture bad case
through the real embedded PreParse pipeline and asserts the canonical outcome
(accepting either semantic rewrite or native real-flag acceptance, failing only
on a genuine unknown-flag hallucination). Add check-param-concepts.sh (dictionary
schema/loader invariants) and check-param-alias-cooccurrence.sh (full-tree
co-occurrence scan), and wire all three into make policy.
2026-07-29 10:01:53 +08:00
克谨 c26cbbbbb8 feat(param): wire semantic alias table into PreParse; pilot calendar event list
Unify runtime morphology on pkg/cmdutil.Morph (same function the generator uses),
add a SemanticAliasHandler that looks up the embedded generated table after
morphological normalization and rewrites synonyms to the command's canonical flag
(leaving blocked/ambiguous synonyms untouched for the did-you-mean path), and
thread the command CLIPath through the pipeline Context. Pilot the mechanism on
'calendar event list' by removing its hand-written hidden spelling variants; a
behaviour-preservation test locks the outcome.
2026-07-29 10:01:53 +08:00
克谨 2733f510af feat(param): generate per-command alias table from concepts
Add internal/generator/cmd_param_aliases: reads the reviewed dictionary plus the
live Cobra tree, reduces each concept against a command's real flags (>=2 visible
real flags without a reviewed ambiguous entry fails generation), and emits the
committed internal/cli/param_aliases_generated.go table with lookup helpers.
Extend generate-schema and check-generated-drift.sh to treat the dictionary as a
reviewed input and byte-guard the generated table.
2026-07-29 10:01:53 +08:00
克谨 abc62622fb feat(param): add reviewed param-concept dictionary, closed schema, and loader
Introduce internal/cli/param_concepts.json as the single reviewed source of
parameter-normalization concepts and per-command overrides, guarded by a closed
JSON schema and a go:embed loader with contract tests. Add the design spec.
2026-07-29 10:00:41 +08:00
83 changed files with 14373 additions and 440 deletions
+9 -3
View File
@@ -438,7 +438,7 @@ jobs:
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: 10
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -483,7 +483,7 @@ jobs:
exit 0
fi
mapfile -t packages <<< "$package_output"
go test -v -race -count=1 -timeout=8m "${packages[@]}"
go test -v -race -count=1 -timeout=15m "${packages[@]}"
test-race:
name: "Test (race: ${{ matrix.shard }})"
@@ -523,7 +523,7 @@ jobs:
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -v -race -count=1 -timeout=10m "${packages[@]}"
go test -v -race -count=1 -timeout=12m "${packages[@]}"
test-release-scripts:
name: Test (workflow and release contracts)
@@ -1160,14 +1160,20 @@ jobs:
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
env:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
policy_profile=
fi
additional_profile=
if [ -f coverage-shortcut.txt ]; then
additional_profile=coverage-shortcut.txt
fi
COVERAGE_DIFF_PROFILE="$policy_profile" \
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
+1
View File
@@ -51,5 +51,6 @@ jobs:
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
include-hidden-files: true
if-no-files-found: ignore
retention-days: 3
+3
View File
@@ -66,3 +66,6 @@ dwsbin
# Local coverage artifacts
coverage-shortcut.txt
coverage-*.txt
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
/cmd_param_aliases
+6 -5
View File
@@ -95,11 +95,12 @@ The Schema system has two physically separated processes:
**Generation** (build-time, slow, reviewed, one-way):
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
not in business code).
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
(standalone Go mains).
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` +
`cmd_param_aliases` (standalone Go mains).
- Inputs: 7 authored source groups (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` +
`param_aliases_generated.go`.
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
endpoints, merges with previous data for cross-server interface_ref).
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
+24 -1
View File
@@ -87,6 +87,9 @@ policy: test-auth-legacy-compat
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
@@ -130,10 +133,14 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp -d); \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
@@ -141,6 +148,22 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
exit 1; \
+68 -1
View File
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the four **contract** items below. Reconnect
Scope of this branch: the five **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
@@ -92,6 +92,73 @@ Ownership-based cleanup:
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
### 5. Subscription-create retry orchestration and local guard
This policy covers all 16 public personal-event keys and every logical
subscription in a multi-event command. It applies only before the ready
marker; reconnecting an established Stream remains a separate mechanism.
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
process sends at most one subscription-create HTTP request for a logical
subscription and performs no in-process automatic retry. The CLI persists
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
not persist or enforce the Agent/host attempt count across invocations.
- ID resolution, `event consume`, and later `event status/stop` must use the
same `--profile`. A user or conversation ID resolved under another profile
must not be reused for the current subscription.
- A logical subscription is keyed by the current profile/identity, event key,
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
does not create a new logical operation or reset the Agent/host budget.
- For the Agent/host, `retryable=false` means
`max_additional_attempts=0`.
- For the Agent/host, `retryable=true` means
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
`next_retry_at` when present and must not retry early.
- For the Agent/host, an omitted retryable value
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
failure stops the operation.
- `in_flight` means the original logical request is still running.
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
it. These states must not recursively launch `event consume`, start a
parallel equivalent subscription, or bypass the guard with a new subId or
trace. The caller waits for the original request/guard or stops, while the
Agent/host keeps its own orchestration count.
- A multi-event command remains one original operation. A caller must not
split out a failed event, reorder events, or restart the command to bypass
a budget. Existing startup rollback cleans subscriptions created before a
later item fails.
#### Local guard state operations
- The default open-edition state file is
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
that edition's directory instead of `open`.
- The identity directory is mode `0700`; both
`personal_subscription_attempts.json` and
`personal_subscription_attempts.lock` are mode `0600`.
- A failure streak resets after 24h without another failure. A
`terminal_hold` lasts 1h. Prefer waiting until the reported
`next_retry_at`; do not clear the file as a normal retry mechanism.
- For emergency recovery, first ensure that no subscription-create process is
running for that identity. Delete only
`personal_subscription_attempts.json`, never the lock file. This clears
every protection record for that identity, not just one event.
**Verification**
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
contract and explicitly reject describing it as a CLI-persisted hard cap.
- T5b (CLI): one process issues at most one create request per logical
subscription; a changed subId/trace or process restart does not bypass the
persisted fingerprint guard.
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
and a partial startup still rolls back earlier subscriptions.
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
+8
View File
@@ -199,6 +199,14 @@ func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
sharedAuditSink = previousSink
loadTokenForProfile = previousLoader
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
// The process-wide sink was initialized by TestMain. Preserve that
// initialized state when restoring it: leaving auditSinkOnce unused
// lets a later runner overwrite the live sink without closing its
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
auditSinkOnce.Do(func() {})
if got := setupAuditSink(); got != previousSink {
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
}
resetAuditIdentityCache()
})
+228
View File
@@ -0,0 +1,228 @@
// 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"
"reflect"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func TestAllDistributionBooleanFlagTypesNormalizeDetachedLiterals(t *testing.T) {
root := NewSchemaSourceRootCommand()
unique := make(map[string]pipeline.FlagInfo)
var visit func(*cobra.Command)
visit = func(command *cobra.Command) {
for _, spec := range pipeline.FlagInfoFromCommand(command) {
if spec.Type != "bool" && spec.Type != "boolean" {
continue
}
key := strings.Join([]string{spec.Name, spec.Shorthand, spec.Type}, "\x00")
unique[key] = spec
}
for _, child := range command.Commands() {
visit(child)
}
}
visit(root)
keys := make([]string, 0, len(unique))
for key := range unique {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) < 80 {
t.Fatalf("boolean flag contract coverage is unexpectedly small: %d", len(keys))
}
for _, key := range keys {
spec := unique[key]
for _, value := range []string{"true", "false"} {
t.Run(spec.Name+"/"+value, func(t *testing.T) {
ctx := &pipeline.Context{
Command: "dws contract probe",
Args: []string{"--" + spec.Name, value},
FlagSpecs: []pipeline.FlagInfo{spec},
}
if err := (handlers.BoolValueHandler{}).Handle(ctx); err != nil {
t.Fatalf("BoolValueHandler.Handle() error = %v", err)
}
want := []string{"--" + spec.Name + "=" + value}
if !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("normalized args = %v, want %v", ctx.Args, want)
}
flags := pflag.NewFlagSet(spec.Name, pflag.ContinueOnError)
flags.Bool(spec.Name, false, "")
if err := flags.Parse(ctx.Args); err != nil {
t.Fatalf("pflag rejected normalized args %v: %v", ctx.Args, err)
}
got, err := flags.GetBool(spec.Name)
if err != nil || got != (value == "true") || !flags.Changed(spec.Name) {
t.Fatalf("parsed %s = %v, changed=%v, error=%v", spec.Name, got, flags.Changed(spec.Name), err)
}
})
}
}
t.Logf("verified detached boolean syntax for %d distinct distribution flag contracts", len(keys))
}
func TestBooleanSyntaxPreservesDefaultsRequiredAndChangedContracts(t *testing.T) {
tests := []struct {
name string
path string
flag string
value string
wantDefault string
wantValue string
}{
{name: "root default false", path: "chat bot find", flag: "dry-run", value: "false", wantDefault: "false", wantValue: "false"},
{name: "root mock default false", path: "chat bot find", flag: "mock", value: "true", wantDefault: "false", wantValue: "true"},
{name: "local force default false", path: "upgrade", flag: "force", value: "false", wantDefault: "false", wantValue: "false"},
{name: "local default true", path: "sheet find", flag: "match-case", value: "false", wantDefault: "true", wantValue: "false"},
{name: "required explicit false", path: "contact dept create", flag: "create-dept-group", value: "false", wantDefault: "false", wantValue: "false"},
{name: "changed false remains explicit", path: "sheet csv-put", flag: "allow-overwrite", value: "false", wantDefault: "false", wantValue: "false"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
leaf := resolveParamLeaf(root, test.path)
if leaf == nil {
t.Fatalf("command %q is not runnable", test.path)
}
flag := booleanContractFlag(leaf, test.flag)
if flag == nil || flag.DefValue != test.wantDefault || flag.Changed {
t.Fatalf("initial --%s contract = %#v, want default %q and unchanged", test.flag, flag, test.wantDefault)
}
pathArgs := strings.Fields(test.path)
rawArgs := append(append([]string(nil), pathArgs...), "--"+test.flag, test.value)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(pathArgs):]
if err := leaf.ParseFlags(flagArgs); err != nil {
t.Fatalf("ParseFlags(%v) error = %v", flagArgs, err)
}
flag = booleanContractFlag(leaf, test.flag)
if flag == nil || flag.Value.String() != test.wantValue || !flag.Changed {
t.Fatalf("final --%s contract = %#v, want value %q and changed", test.flag, flag, test.wantValue)
}
})
}
}
func TestDetachedDryRunValuesReachTheExpectedFinalDispatchBoundary(t *testing.T) {
base := []string{
"mail", "folder", "update",
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
}
bareArgs := append(append([]string(nil), base...), "--dry-run")
_, barePreview, bareAttempts, bareErr := executeParamAliasDryRunE2E(t, bareArgs...)
if bareErr != nil || !barePreview.DryRun || barePreview.Executed || len(bareAttempts) != 0 {
t.Fatalf("bare dry-run = preview:%#v attempts:%#v error:%v", barePreview, bareAttempts, bareErr)
}
trueArgs := append(append([]string(nil), base...), "--dry-run", "TRUE")
trueCtx, truePreview, trueAttempts, trueErr := executeParamAliasDryRunE2E(t, trueArgs...)
if trueErr != nil || !reflect.DeepEqual(truePreview, barePreview) || len(trueAttempts) != 0 {
t.Fatalf("detached true = context:%#v preview:%#v attempts:%#v error:%v", trueCtx, truePreview, trueAttempts, trueErr)
}
if !hasBooleanCorrection(trueCtx, "--dry-run TRUE", "--dry-run=true") {
t.Fatalf("detached true correction = %#v", trueCtx)
}
falseCases := []struct {
name string
args []string
}{
{name: "detached", args: append(append([]string(nil), base...), "--dry-run", "false")},
{name: "explicit", args: append(append([]string(nil), base...), "--dry-run=false")},
}
var wantAttempts []any
for _, test := range falseCases {
t.Run(test.name, func(t *testing.T) {
ctx, _, attempts, err := executeParamAliasDryRunE2E(t, test.args...)
if err == nil || !strings.Contains(err.Error(), "dry-run reached the injected command runner") {
t.Fatalf("dry-run=false dispatch error = %v", err)
}
if len(attempts) != 1 || attempts[0].DryRun {
t.Fatalf("dry-run=false attempts = %#v", attempts)
}
if test.name == "detached" && !hasBooleanCorrection(ctx, "--dry-run false", "--dry-run=false") {
t.Fatalf("detached false correction = %#v", ctx)
}
serialized := []any{attempts[0].CanonicalProduct, attempts[0].Tool, attempts[0].Params, attempts[0].DryRun}
if wantAttempts == nil {
wantAttempts = serialized
} else if !reflect.DeepEqual(serialized, wantAttempts) {
t.Fatalf("detached and explicit false dispatch differ\nwant=%#v\ngot=%#v", wantAttempts, serialized)
}
})
}
}
func TestContradictoryBooleanValuesFailBeforeDestructiveDispatch(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"mail", "thread", "trash",
"--email", "user@example.com", "--id", "conversation-1",
"--yes", "true", "--yes=false",
)
var conflict *pipeline.BoolValueConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("conflicting confirmation error = %v, want BoolValueConflictError (ctx=%#v)", err, ctx)
}
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
t.Fatalf("conflict = %#v", conflict)
}
if len(caller.calls) != 0 {
t.Fatalf("conflicting confirmation reached destructive dispatch: %#v", caller.calls)
}
}
func booleanContractFlag(command *cobra.Command, name string) *pflag.Flag {
if command == nil {
return nil
}
if flag := command.Flags().Lookup(name); flag != nil {
return flag
}
return command.InheritedFlags().Lookup(name)
}
func hasBooleanCorrection(ctx *pipeline.Context, original, corrected string) bool {
if ctx == nil {
return false
}
for _, correction := range ctx.Corrections {
if correction.Handler == "boolvalue" && correction.Original == original && correction.Corrected == corrected {
return true
}
}
return false
}
+4 -3
View File
@@ -1657,12 +1657,13 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
CorpID: "corp", UserID: "user", ClientID: "client",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
var cancelCount int
var subscribeCount, cancelCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/event/sublist":
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
case "/subscription/user":
subscribeCount++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
case "/subscription/cancel":
cancelCount++
@@ -1697,8 +1698,8 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
t.Fatal("invalid foreground consume succeeded")
}
if cancelCount == 0 {
t.Fatal("failed foreground consume did not clean up subscription")
if subscribeCount != 0 || cancelCount != 0 {
t.Fatalf("invalid local configuration reached subscription control: subscribe=%d cancel=%d", subscribeCount, cancelCount)
}
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
+1 -1
View File
@@ -161,7 +161,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"subscribe-id", "rule", "event-types", "filter",
"foreground", "force", "debug-raw-events",
); err != nil {
return fmt.Errorf("event consume: %w", err)
return fmt.Errorf("event consume: %w", personalSubscriptionValidationError(err))
}
}
personalOpts.Common = commonConsumeOptions{
+552
View File
@@ -0,0 +1,552 @@
// 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 (
"context"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const personalSubscriptionAttemptOperation = "event.consume.personal.subscribe"
type personalSubscriptionAttemptStore interface {
Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error)
CompleteSuccess(*personal.AttemptClaim) error
CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error)
Release(*personal.AttemptClaim) error
}
var (
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
return personal.NewAttemptStore(workDir)
}
personalSubscriptionAttemptNow = time.Now
)
type personalSubscriptionAttemptItem struct {
eventKey string
fingerprint string
}
type personalSubscriptionAttemptReservation struct {
store personalSubscriptionAttemptStore
claim *personal.AttemptClaim
items []personalSubscriptionAttemptItem
}
type personalSubscriptionFailureClass struct {
retryability personal.Retryability
retryAfter time.Duration
code string
traceID string
reason string
auth bool
}
func reservePersonalSubscriptionAttempts(
workDir string,
client *personal.Client,
identity personal.Identity,
profileSelector string,
plans []personalConsumeOptions,
) (*personalSubscriptionAttemptReservation, error) {
if len(plans) == 0 {
return nil, personalSubscriptionGuardError(
errors.New("personal event: no subscription attempts to reserve"),
)
}
if client == nil {
return nil, personalSubscriptionGuardError(
errors.New("personal event: nil subscription control client"),
)
}
if err := validatePersonalSubscriptionEndpoint(client.BaseURL); err != nil {
return nil, personalSubscriptionValidationError(err)
}
items := make([]personalSubscriptionAttemptItem, 0, len(plans))
specs := make([]personal.AttemptSpec, 0, len(plans))
for _, plan := range plans {
prepared, err := preparePersonalSubscription(identity, plan)
if err != nil {
return nil, personalSubscriptionValidationError(err)
}
fingerprint := personal.Fingerprint(
client.BaseURL,
prepared.Request.IdempotencyKey,
profileSelector,
)
items = append(items, personalSubscriptionAttemptItem{
eventKey: prepared.EventKey,
fingerprint: fingerprint,
})
specs = append(specs, personal.AttemptSpec{
Fingerprint: fingerprint,
EventKey: prepared.EventKey,
})
}
store := personalNewSubscriptionAttemptStore(workDir)
if store == nil {
return nil, personalSubscriptionGuardError(
errors.New("personal event: subscription attempt store is unavailable"),
)
}
claim, err := store.Claim(specs, personalSubscriptionAttemptLease(client, len(specs)))
if err != nil {
var blocked *personal.AttemptBlockedError
if errors.As(err, &blocked) {
return nil, personalSubscriptionBlockedError(blocked)
}
return nil, personalSubscriptionGuardError(err)
}
return &personalSubscriptionAttemptReservation{
store: store,
claim: claim,
items: items,
}, nil
}
func validatePersonalSubscriptionEndpoint(raw string) error {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" ||
(!strings.EqualFold(parsed.Scheme, "http") &&
!strings.EqualFold(parsed.Scheme, "https")) {
if err == nil {
err = errors.New("an absolute http(s) URL is required")
}
return fmt.Errorf("personal event: invalid subscription control endpoint %q: %w", raw, err)
}
return nil
}
func personalSubscriptionAttemptLease(client *personal.Client, batchSize int) time.Duration {
const (
leaseOverhead = 30 * time.Second
minLease = time.Minute
maxLease = 10 * time.Minute
)
if batchSize < 1 {
batchSize = 1
}
timeout := config.HTTPTimeout
if client != nil && client.HTTPClient != nil && client.HTTPClient.Timeout > 0 {
timeout = client.HTTPClient.Timeout
}
maxRequestBudget := maxLease - leaseOverhead
if timeout <= 0 || timeout > maxRequestBudget/time.Duration(batchSize) {
return maxLease
}
lease := timeout*time.Duration(batchSize) + leaseOverhead
if lease < minLease {
return minLease
}
return lease
}
func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
if r == nil {
return nil
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(
errors.New("personal event: subscription attempt reservation is incomplete"),
)
}
if err := r.store.CompleteSuccess(r.claim); err != nil {
return personalSubscriptionGuardError(err)
}
return nil
}
func (r *personalSubscriptionAttemptReservation) completeFailure(
ctx context.Context,
failedIndex int,
succeededCount int,
cause error,
override *personalSubscriptionFailureClass,
) error {
if r == nil {
return cause
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(errors.Join(
cause,
errors.New("personal event: subscription attempt reservation is incomplete"),
))
}
if failedIndex < 0 || failedIndex >= len(r.items) ||
succeededCount < 0 || succeededCount > failedIndex {
return personalSubscriptionGuardError(errors.Join(
cause,
errors.New("personal event: invalid subscription attempt completion indexes"),
))
}
if personalSubscriptionCanceled(ctx, cause) {
// Cancellation is not a failed attempt. Restoring the claim normally
// completes immediately; if the lock cannot be acquired, leaving the
// finite lease behind is still safer than recording a false failure.
_ = r.store.Release(r.claim)
return cause
}
classification := classifyPersonalSubscriptionFailure(cause, personalSubscriptionAttemptNow())
if override != nil {
classification = *override
}
succeeded := make([]string, 0, succeededCount)
for i := 0; i < succeededCount; i++ {
succeeded = append(succeeded, r.items[i].fingerprint)
}
hold, err := r.store.CompleteFailure(r.claim, succeeded, personal.AttemptFailure{
Fingerprint: r.items[failedIndex].fingerprint,
Retryability: classification.retryability,
RetryAfter: classification.retryAfter,
ErrorCode: classification.code,
TraceID: classification.traceID,
})
if err != nil {
return personalSubscriptionGuardError(errors.Join(cause, err))
}
return personalSubscriptionFailureError(cause, classification, hold)
}
func personalSubscriptionCanceled(ctx context.Context, err error) bool {
if errors.Is(err, context.Canceled) {
return true
}
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
}
func classifyPersonalSubscriptionFailure(err error, now time.Time) personalSubscriptionFailureClass {
classification := personalSubscriptionFailureClass{
retryability: personal.RetryabilityUnknown,
reason: "personal_subscription_unknown",
}
var apiErr *personal.APIError
if errors.As(err, &apiErr) {
classification.code = strings.TrimSpace(apiErr.Code)
classification.traceID = strings.TrimSpace(apiErr.TraceID)
classification.retryAfter = personalAPIRetryDelay(apiErr, now)
classification.auth = personalSubscriptionAuthFailure(apiErr.HTTPStatus, apiErr.Code)
switch {
case apiErr.Retryable != nil && *apiErr.Retryable:
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_server_retryable"
case apiErr.Retryable != nil:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_server_non_retryable"
case apiErr.HTTPStatus == http.StatusRequestTimeout ||
apiErr.HTTPStatus == http.StatusTooEarly ||
apiErr.HTTPStatus == http.StatusTooManyRequests ||
apiErr.HTTPStatus >= http.StatusInternalServerError:
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_transient_http"
case apiErr.HTTPStatus == http.StatusUnauthorized ||
apiErr.HTTPStatus == http.StatusForbidden:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_auth"
case personalSubscriptionTerminalBusinessCode(apiErr.Code):
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_business_rejected"
case personalSubscriptionErrorHasSubscribeID(apiErr):
// A few legacy/proxy error shapes include an existing subscription
// ID without a stable server contract. Keep the response as an
// error, but do not turn that unverified shape into a one-hour hold.
classification.reason = "personal_subscription_unverified_existing_id"
case apiErr.HTTPStatus >= http.StatusBadRequest:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_http_rejected"
}
return classification
}
if errors.Is(err, context.DeadlineExceeded) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_timeout"
return classification
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
if strings.EqualFold(strings.TrimSpace(urlErr.Op), "parse") {
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_invalid"
return classification
}
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
var netErr net.Error
if errors.As(err, &netErr) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
lower := strings.ToLower(err.Error())
if strings.Contains(lower, "access token") || strings.Contains(lower, "oauth") {
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_auth"
classification.auth = true
}
return classification
}
func personalSubscriptionErrorHasSubscribeID(apiErr *personal.APIError) bool {
if apiErr == nil {
return false
}
subscribeID, ok := apiErr.Details["subscribe_id"].(string)
return ok && strings.TrimSpace(subscribeID) != ""
}
func personalAPIRetryDelay(apiErr *personal.APIError, now time.Time) time.Duration {
if apiErr == nil {
return 0
}
var delay time.Duration
if apiErr.RetryAfterSeconds != nil {
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(*apiErr.RetryAfterSeconds))
}
if apiErr.NextRetryAt != nil {
delay = maxPersonalRetryDelay(delay, apiErr.NextRetryAt.Sub(now))
}
if raw, ok := apiErr.Details["retry_after"].(string); ok {
raw = strings.TrimSpace(raw)
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(seconds))
} else if next, err := http.ParseTime(raw); err == nil {
delay = maxPersonalRetryDelay(delay, next.Sub(now))
}
}
return delay
}
func personalRetrySeconds(seconds int64) time.Duration {
if seconds <= 0 {
return 0
}
if seconds > math.MaxInt64/int64(time.Second) {
return time.Duration(math.MaxInt64)
}
return time.Duration(seconds) * time.Second
}
func maxPersonalRetryDelay(left, right time.Duration) time.Duration {
if right > left {
return right
}
return left
}
func personalSubscriptionTerminalBusinessCode(raw string) bool {
code := strings.ToUpper(strings.TrimSpace(raw))
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
code = replacer.Replace(code)
// Keep this list deliberately conservative. Unknown server codes must stay
// unknown so a newly introduced transient condition cannot accidentally be
// converted into a one-hour terminal hold.
switch code {
case "INVALID_PARAM", "INVALID_PARAMS", "INVALID_PARAMETER", "INVALID_PARAMETERS",
"ILLEGAL_PARAM", "ILLEGAL_PARAMS", "ILLEGAL_PARAMETER", "ILLEGAL_PARAMETERS",
"PARAM_ERROR", "PARAMETER_ERROR",
"CLIENT_ID_REQUIRED", "SOURCE_ID_REQUIRED", "EVENT_KEY_REQUIRED", "RULE_TYPE_REQUIRED",
"NO_AUTH", "NO_PERMISSION", "PERMISSION_DENIED", "ACCESS_DENIED",
"FORBIDDEN", "UNAUTHORIZED",
"NOT_FOUND", "NOT_EXIST", "NOT_SUPPORTED", "UNSUPPORTED",
"UNIFIED_APP_ID_NOT_FOUND":
return true
}
// Resource-qualified variants are stable business-rejection shapes. Avoid
// broad substring matching (for example, RETRY_REQUIRED must remain
// unknown).
for _, suffix := range []string{
"_NOT_BELONG_TO_ORG",
"_DOES_NOT_BELONG_TO_ORG",
"_NOT_FOUND",
"_NOT_EXIST",
"_NOT_SUPPORTED",
"_UNSUPPORTED",
"_NO_PERMISSION",
"_PERMISSION_DENIED",
"_ACCESS_DENIED",
} {
if strings.HasSuffix(code, suffix) {
return true
}
}
return false
}
func personalSubscriptionAuthFailure(status int, rawCode string) bool {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true
}
code := strings.ToUpper(strings.TrimSpace(rawCode))
for _, marker := range []string{
"NO_AUTH", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION", "ACCESS_DENIED",
} {
if strings.Contains(code, marker) {
return true
}
}
return false
}
func personalSubscriptionFailureError(
cause error,
classification personalSubscriptionFailureClass,
hold personal.AttemptHold,
) error {
options := personalSubscriptionErrorOptions(
classification.retryability,
hold.RetryAfter,
hold.NextAllowedAt,
classification.code,
classification.traceID,
classification.reason,
cause,
)
message := cause.Error()
if classification.retryability == personal.RetryabilityNonRetryable {
if classification.auth {
return apperrors.NewAuth(message, options...)
}
return apperrors.NewValidation(message, options...)
}
return apperrors.NewAPI(message, options...)
}
func personalSubscriptionBlockedError(blocked *personal.AttemptBlockedError) error {
if blocked == nil {
return personalSubscriptionGuardError(
errors.New("personal event: nil blocked subscription attempt"),
)
}
reason := "personal_subscription_" + string(blocked.State)
options := personalSubscriptionErrorOptions(
blocked.Retryability,
blocked.RetryAfter,
blocked.NextAllowedAt,
blocked.ErrorCode,
blocked.TraceID,
reason,
blocked,
)
if blocked.Retryability == personal.RetryabilityNonRetryable {
if personalSubscriptionAuthFailure(0, blocked.ErrorCode) {
return apperrors.NewAuth(blocked.Error(), options...)
}
return apperrors.NewValidation(blocked.Error(), options...)
}
return apperrors.NewAPI(blocked.Error(), options...)
}
func personalSubscriptionErrorOptions(
retryability personal.Retryability,
retryAfter time.Duration,
nextRetryAt time.Time,
code string,
traceID string,
reason string,
cause error,
) []apperrors.Option {
options := []apperrors.Option{
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason(reason),
apperrors.WithCause(cause),
}
if retryable, known := retryability.Value(); known {
options = append(options, apperrors.WithRetryable(retryable))
}
if retryAfter > 0 {
options = append(options, apperrors.WithRetryAfterSeconds(ceilPersonalRetrySeconds(retryAfter)))
}
if !nextRetryAt.IsZero() {
options = append(options, apperrors.WithNextRetryAt(nextRetryAt))
}
if code != "" || traceID != "" {
options = append(options, apperrors.WithServerDiag(apperrors.ServerDiagnostics{
TraceID: strings.TrimSpace(traceID),
ServerErrorCode: strings.TrimSpace(code),
}))
}
return options
}
func ceilPersonalRetrySeconds(delay time.Duration) int64 {
if delay <= 0 {
return 0
}
seconds := int64(delay / time.Second)
if delay%time.Second != 0 {
seconds++
}
return seconds
}
func personalSubscriptionGuardError(cause error) error {
if cause == nil {
cause = errors.New("personal event: subscription attempt guard failed")
}
return apperrors.NewInternal(
fmt.Sprintf("personal subscription attempt guard failed: %v", cause),
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason("personal_subscription_guard_failed"),
apperrors.WithRetryable(false),
apperrors.WithCause(cause),
)
}
func personalSubscriptionValidationError(cause error) error {
if cause == nil {
cause = errors.New("personal event: invalid subscription parameters")
}
return apperrors.NewValidation(
cause.Error(),
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason("personal_subscription_invalid"),
apperrors.WithRetryable(false),
apperrors.WithCause(cause),
)
}
func personalSubscriptionLocalFailure() personalSubscriptionFailureClass {
return personalSubscriptionFailureClass{
retryability: personal.RetryabilityUnknown,
reason: "personal_subscription_local_failure",
}
}
File diff suppressed because it is too large Load Diff
+229 -113
View File
@@ -227,7 +227,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
return personalSubscriptionValidationError(err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
@@ -238,7 +238,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
@@ -255,12 +255,12 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
}
cfg := consume.Config{
@@ -284,16 +284,100 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return personalConsumeRun(ctx, cfg)
if err := personalConsumeRun(ctx, cfg); err != nil {
return personalSubscriptionValidationError(err)
}
return nil
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Complete all local validation before creating a remote subscription.
// Otherwise an invalid output mode can repeatedly create and roll back a
// valid subscription when an outer agent relaunches the command.
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(cfg); err != nil {
return personalSubscriptionValidationError(err)
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return personalSubscriptionValidationError(err)
}
}
var foregroundSource *source.PersonalSource
if opts.Common.Foreground {
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
return personalSubscriptionValidationError(err)
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
workDir,
client,
identity,
spawnProfileSelector,
[]personalConsumeOptions{opts},
)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
}
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
err = attempt.completeFailure(ctx, 0, 0, err, nil)
return fmt.Errorf("event consume --as user: %w", err)
}
if sub.SubscribeID == "" {
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
if sub == nil {
err = attempt.completeFailure(
ctx,
0,
0,
errors.New("personal event: server returned an empty subscription"),
nil,
)
return fmt.Errorf("event consume --as user: %w", err)
}
if strings.TrimSpace(sub.SubscribeID) == "" {
err = attempt.completeFailure(
ctx,
0,
0,
errors.New("personal event: server returned empty subscribe_id"),
nil,
)
return fmt.Errorf("event consume --as user: %w", err)
}
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -303,11 +387,21 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
return fmt.Errorf("event consume --as user: save run state: %w", err)
wrapped := fmt.Errorf("save run state: %w", err)
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
return fmt.Errorf("event consume --as user: %w", wrapped)
}
cleanup := func() {
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
// unsubscribed on exit
@@ -317,59 +411,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup()
defer cleanup(context.Background())
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
ReadySubscribeID: sub.SubscribeID,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
// run (see shouldWatchStdinEOF).
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
cfg.EventKey = eventKey
cfg.ReadySubscribeID = sub.SubscribeID
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
if opts.DebugRawEvents && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
workDir, filepath.Join(workDir, "bus.log"))
}
if err := personalValidateConsumeConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
if !opts.Ephemeral {
cleanup()
}
return err
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
@@ -378,18 +430,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
cleanup(context.Background())
}
return err
}
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
cleanup(context.Background())
}
return err
}
@@ -403,7 +455,7 @@ type personalMultiSubscription struct {
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
plans, err := preparePersonalMultiOptions(opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
@@ -414,7 +466,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
projector := personalEventProjector(false, opts.Flatten)
@@ -428,15 +480,16 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
baseCfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -451,11 +504,11 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(baseCfg); err != nil {
return err
return personalSubscriptionValidationError(err)
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
return err
return personalSubscriptionValidationError(err)
}
}
if opts.Common.DryRun {
@@ -464,13 +517,23 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
attempt, err := reservePersonalSubscriptionAttempts(
workDir,
client,
identity,
spawnProfileSelector,
plans,
)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
created := make([]personalMultiSubscription, 0, len(plans))
cleanup := func() {
cleanup := func(cleanupCtx context.Context) {
ids := make([]string, 0, len(created))
for i := len(created) - 1; i >= 0; i-- {
id := strings.TrimSpace(created[i].Sub.SubscribeID)
ids = append(ids, id)
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
}
}
@@ -480,26 +543,45 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
}
}
failAndCleanup := func(
failedIndex int,
succeededCount int,
cause error,
override *personalSubscriptionFailureClass,
) error {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, cause) {
cleanupCtx = ctx
}
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
// Persist the hold (or release a canceled claim) before any potentially
// slow remote rollback. Otherwise the attempt lease can expire while
// deleting earlier subscriptions and admit a duplicate create batch.
cleanup(cleanupCtx)
return completed
}
seenSubscribeIDs := make(map[string]struct{}, len(plans))
for _, plan := range plans {
for i, plan := range plans {
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
if err != nil {
cleanup()
err = failAndCleanup(i, len(created), err, nil)
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
}
if sub == nil {
cleanup()
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
id := strings.TrimSpace(sub.SubscribeID)
if id == "" {
cleanup()
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
if _, exists := seenSubscribeIDs[id]; exists {
_ = personalDeleteSubscription(client, context.Background(), id)
cleanup()
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
seenSubscribeIDs[id] = struct{}{}
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
@@ -512,11 +594,17 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
cleanup()
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
cause := fmt.Errorf("save run state for %s: %w", eventKey, err)
classification := personalSubscriptionLocalFailure()
cause = failAndCleanup(i, len(created)-1, cause, &classification)
return fmt.Errorf("event consume --as user: %w", cause)
}
}
defer cleanup()
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
defer cleanup(context.Background())
specs := make([]consume.ConsumerSpec, 0, len(created))
for _, item := range created {
@@ -705,6 +793,59 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
Request personal.CreateSubscriptionRequest
}
func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) {
if strings.TrimSpace(opts.EventKey) == "" {
return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
if err != nil {
return personalPreparedSubscription{}, err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return personalPreparedSubscription{}, err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
return personalPreparedSubscription{
EventKey: opts.EventKey,
RuleType: ruleType,
Request: req,
}, nil
}
func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) {
sub, err := personalCreateSubscription(client, ctx, plan.Request)
if err != nil {
return nil, "", "", err
}
return sub, plan.EventKey, plan.RuleType, nil
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
@@ -727,42 +868,11 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
return sub, eventKey, ruleType, nil
}
if strings.TrimSpace(opts.EventKey) == "" {
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
plan, err := preparePersonalSubscription(identity, opts)
if err != nil {
return nil, "", "", err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return nil, "", "", err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
sub, err := personalCreateSubscription(client, ctx, req)
if err != nil {
return nil, "", "", err
}
return sub, opts.EventKey, ruleType, nil
return createPreparedPersonalSubscription(ctx, client, plan)
}
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
@@ -829,7 +939,7 @@ func ensurePublicPersonalEvent(eventKey string) error {
if eventKey == "" {
return nil
}
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
if def, ok := personalLookupDefinition(eventKey); ok && !def.Public {
return personal.PublicAvailabilityError(eventKey)
}
return nil
@@ -1091,6 +1201,12 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
version := strings.TrimSpace(RawVersion())
if version == "" {
version = "unknown"
}
client.ClientVersion = version
client.UserAgent = "dws-cli/" + version
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
@@ -77,6 +77,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldEnsure := personalEnsureSubscription
oldAttemptStore := personalNewSubscriptionAttemptStore
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
@@ -88,6 +89,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalEnsureSubscription = oldEnsure
personalNewSubscriptionAttemptStore = oldAttemptStore
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
@@ -97,6 +99,9 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
})
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return personalNoopAttemptStore{}
}
wantErr := errors.New("consume")
cmd := newPersonalCoverageCommand()
@@ -154,11 +159,11 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, wantErr
}
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != 0 {
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
}
before := deletes
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != before {
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
+224 -2
View File
@@ -12,6 +12,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
@@ -251,7 +252,7 @@ func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
}
}
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
@@ -379,7 +380,158 @@ func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
}
}
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
calls := 0
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", errors.New("second subscription failed")
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
order = append(order, "delete")
return nil
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil {
t.Fatal("partial creation unexpectedly succeeded")
}
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
t.Fatalf("failure/rollback order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error {
return errors.New("state disk failed")
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
order = append(order, "delete")
return nil
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
})
if err == nil {
t.Fatal("run-state failure unexpectedly succeeded")
}
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
t.Fatalf("failure/rollback order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
calls := 0
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", context.Canceled
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
if cleanupCtx.Err() == nil {
t.Fatal("cancellation cleanup received a live context")
}
order = append(order, "delete")
return cleanupCtx.Err()
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cmd := newPersonalCoverageCommand()
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v", err)
}
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
t.Fatalf("release/canceled-cleanup order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
for _, test := range []struct {
name string
ensure func(int, personalConsumeOptions) *personal.Subscription
@@ -641,16 +793,21 @@ func installPersonalManySeams(t *testing.T) func() {
oldIdentity := personalResolveEventIdentity
oldLookup := personalLookupDefinition
oldEnsure := personalEnsureSubscription
oldAttemptStore := personalNewSubscriptionAttemptStore
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldRunMany := personalConsumeRunMany
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return personalNoopAttemptStore{}
}
return func() {
personalResolveEventIdentity = oldIdentity
personalLookupDefinition = oldLookup
personalEnsureSubscription = oldEnsure
personalNewSubscriptionAttemptStore = oldAttemptStore
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
@@ -659,3 +816,68 @@ func installPersonalManySeams(t *testing.T) func() {
personalValidateNoOutputConflict = oldConflict
}
}
type personalNoopAttemptStore struct{}
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
fingerprints := make([]string, 0, len(specs))
for _, spec := range specs {
fingerprints = append(fingerprints, spec.Fingerprint)
}
return &personal.AttemptClaim{
AttemptID: "test-attempt",
Fingerprints: fingerprints,
}, nil
}
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
return nil
}
func (personalNoopAttemptStore) CompleteFailure(
_ *personal.AttemptClaim,
_ []string,
failure personal.AttemptFailure,
) (personal.AttemptHold, error) {
return personal.AttemptHold{
Fingerprint: failure.Fingerprint,
Retryability: failure.Retryability,
}, nil
}
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
return nil
}
type personalOrderingAttemptStore struct {
order *[]string
}
func (s *personalOrderingAttemptStore) Claim(
specs []personal.AttemptSpec,
lease time.Duration,
) (*personal.AttemptClaim, error) {
return personalNoopAttemptStore{}.Claim(specs, lease)
}
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
*s.order = append(*s.order, "complete_success")
return nil
}
func (s *personalOrderingAttemptStore) CompleteFailure(
_ *personal.AttemptClaim,
_ []string,
failure personal.AttemptFailure,
) (personal.AttemptHold, error) {
*s.order = append(*s.order, "complete_failure")
return personal.AttemptHold{
Fingerprint: failure.Fingerprint,
Retryability: failure.Retryability,
}, nil
}
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
*s.order = append(*s.order, "release")
return nil
}
+62
View File
@@ -103,3 +103,65 @@ func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
}
}
func TestFlagErrorWithSuggestionsReviewedProtectionRoutes(t *testing.T) {
root := NewRootCommand()
for _, tc := range []struct {
path []string
flag string
wantReason string
wantHint string
}{
{path: []string{"chat", "message", "list-by-sender"}, flag: "time", wantReason: "blocked_flag", wantHint: "blocked"},
{path: []string{"drive", "list"}, flag: "space", wantReason: "ambiguous_flag", wantHint: "ambiguous"},
} {
t.Run(strings.Join(tc.path, "/"), func(t *testing.T) {
cmd := mustFindCommand(t, root, tc.path...)
err := flagErrorWithSuggestions(cmd, fmt.Errorf("unknown flag: --%s", tc.flag))
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != tc.wantReason || !strings.Contains(ae.Hint, tc.wantHint) || !strings.Contains(ae.Hint, "--help") {
t.Fatalf("protected error = reason %q hint %q", ae.Reason, ae.Hint)
}
})
}
}
func TestReviewedFlagProtectionAndInstallerEdges(t *testing.T) {
if flag, protection, ok := reviewedFlagProtection(nil, "unknown flag: --time"); ok || flag != "" || protection != "" {
t.Fatalf("nil command protection = %q, %q, %v", flag, protection, ok)
}
installReviewedFlagProtectionHandlers(nil)
root := NewRootCommand()
cmd := mustFindCommand(t, root, "chat", "message", "list-by-sender")
flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --time=value")
if !ok || flag != "time" || protection != "blocked" {
t.Fatalf("delimited protected flag = %q, %q, %v", flag, protection, ok)
}
if flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --not-reviewed"); ok || flag != "" || protection != "" {
t.Fatalf("unreviewed flag protection = %q, %q, %v", flag, protection, ok)
}
}
func TestReviewedFlagProtectionInstallerPreservesLocalHandler(t *testing.T) {
root := NewRootCommand()
cmd := mustFindCommand(t, root, "contact", "dept", "list-children")
handler := cmd.FlagErrorFunc()
unreviewed := handler(cmd, fmt.Errorf("unknown flag: --not-reviewed"))
var structured *apperrors.Error
if stderrors.As(unreviewed, &structured) {
t.Fatalf("unreviewed error bypassed the command's local handler: %#v", structured)
}
if !strings.HasSuffix(unreviewed.Error(), "See 'dws contact dept list-children --help' for usage.") {
t.Fatalf("local handler output = %q", unreviewed)
}
guarded := handler(cmd, fmt.Errorf("unknown flag: --name"))
if !stderrors.As(guarded, &structured) || structured.Reason != "blocked_flag" {
t.Fatalf("reviewed guard did not use the central handler: %#v", guarded)
}
}
+826
View File
@@ -0,0 +1,826 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
stderrors "errors"
"io"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type paramAliasToolCall struct {
server string
tool string
args map[string]any
}
type paramAliasCaptureCaller struct {
calls []paramAliasToolCall
}
func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copyArgs := make(map[string]any, len(args))
for key, value := range args {
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
// paramAliasResponseForTool supplies deterministic, business-shape-valid
// responses for the complete-command equivalence matrix. Most commands only
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
case "search_mail_users":
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
case "search_dept_by_keyword":
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
default:
return `{}`
}
}
func (*paramAliasCaptureCaller) Format() string { return "json" }
func (*paramAliasCaptureCaller) DryRun() bool { return false }
func (*paramAliasCaptureCaller) Fields() string { return "" }
func (*paramAliasCaptureCaller) JQ() string { return "" }
// paramAliasCaptureRunner covers helpers (currently dev app) that dispatch
// through executor.Runner instead of edition.ToolCaller. Keeping both capture
// boundaries in one call list lets the matrix compare the final request shape
// without knowing which transport adapter a command uses.
type paramAliasCaptureRunner struct {
caller *paramAliasCaptureCaller
}
func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
copyArgs := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
copyArgs[key] = value
}
r.caller.calls = append(r.caller.calls, paramAliasToolCall{
server: invocation.CanonicalProduct,
tool: invocation.Tool,
args: copyArgs,
})
invocation.Implemented = true
return executor.Result{Invocation: invocation, Response: map[string]any{}}, nil
}
type paramAliasDryRunRejectRunner struct {
attempts []executor.Invocation
}
func (r *paramAliasDryRunRejectRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.attempts = append(r.attempts, invocation)
return executor.Result{}, stderrors.New("dry-run reached the injected command runner")
}
type paramAliasDryRunPreview struct {
DryRun bool `json:"dry_run"`
Executed bool `json:"executed"`
Tool string `json:"tool"`
Arguments map[string]any `json:"arguments"`
}
// executeParamAliasDryRunE2E uses the existing root --dry-run barrier as a
// parameter-normalization probe. These commands do not publish command-owned
// dry-run capabilities in Schema; the test deliberately makes no such claim.
// A reject runner proves the preview stops before endpoint resolution,
// authentication, or transport execution.
func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context, paramAliasDryRunPreview, []executor.Invocation, error) {
t.Helper()
originalArgs := os.Args
os.Args = append([]string{"dws"}, args...)
defer func() { os.Args = originalArgs }()
captureFile, err := os.CreateTemp(t.TempDir(), "param-alias-dry-run-*.json")
if err != nil {
t.Fatalf("create dry-run output capture: %v", err)
}
defer captureFile.Close()
originalStdout := os.Stdout
originalCaller := helpers.GetCaller()
os.Stdout = captureFile
defer func() {
os.Stdout = originalStdout
helpers.InitDeps(originalCaller)
}()
rejectRunner := &paramAliasDryRunRejectRunner{}
originalRunnerFactory := rootNewCommandRunnerWithFlags
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
return rejectRunner
}
root := NewRootCommand()
rootNewCommandRunnerWithFlags = originalRunnerFactory
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, executeErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if executeErr == nil {
executeErr = root.Execute()
}
if err := captureFile.Sync(); err != nil {
t.Fatalf("sync dry-run output capture: %v", err)
}
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind dry-run output capture: %v", err)
}
output, err := io.ReadAll(captureFile)
if err != nil {
t.Fatalf("read dry-run output capture: %v", err)
}
var preview paramAliasDryRunPreview
if executeErr == nil {
if err := json.Unmarshal(output, &preview); err != nil {
t.Fatalf("decode dry-run preview: %v\noutput=%s", err, output)
}
}
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
}
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
originalArgs := os.Args
os.Args = append([]string{"dws"}, args...)
defer func() { os.Args = originalArgs }()
originalRunnerFactory := rootNewCommandRunnerWithFlags
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
return &paramAliasCaptureRunner{caller: caller}
}
root := NewRootCommand()
rootNewCommandRunnerWithFlags = originalRunnerFactory
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
originalCaller := helpers.GetCaller()
helpers.InitDeps(caller)
defer helpers.InitDeps(originalCaller)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
return ctx, err
}
return ctx, root.Execute()
}
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
wantError string
wantCalls int
wantOriginal string
wantCorrection string
}{
{name: "bare yes confirms", confirmation: []string{"--yes"}, wantCalls: 1},
{name: "glued false stays unconfirmed", confirmation: []string{"--yesfalse"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yesfalse", wantCorrection: "--yes=false"},
{name: "glued true confirms", confirmation: []string{"--yestrue"}, wantCalls: 1, wantOriginal: "--yestrue", wantCorrection: "--yes=true"},
{name: "detached false stays unconfirmed", confirmation: []string{"--yes", "false"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes false", wantCorrection: "--yes=false"},
{name: "detached no stays unconfirmed", confirmation: []string{"--yes", "no"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes no", wantCorrection: "--yes=false"},
{name: "detached zero stays unconfirmed", confirmation: []string{"--yes", "0"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes 0", wantCorrection: "--yes=false"},
{name: "detached true confirms", confirmation: []string{"--yes", "true"}, wantCalls: 1, wantOriginal: "--yes true", wantCorrection: "--yes=true"},
{name: "detached yes confirms", confirmation: []string{"--yes", "yes"}, wantCalls: 1, wantOriginal: "--yes yes", wantCorrection: "--yes=true"},
{name: "detached one confirms", confirmation: []string{"--yes", "1"}, wantCalls: 1, wantOriginal: "--yes 1", wantCorrection: "--yes=true"},
{name: "explicit false remains unconfirmed", confirmation: []string{"--yes=false"}, wantError: "请添加 --yes 确认执行"},
{name: "explicit true confirms", confirmation: []string{"--yes=true"}, wantCalls: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := []string{
"mail", "thread", "trash",
"--email", "user@example.com",
"--id", "conversation-1",
}
args = append(args, test.confirmation...)
ctx, err := executeParamAliasE2E(t, caller, args...)
if test.wantError == "" {
if err != nil {
t.Fatalf("confirmed command error = %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("command error = %v, want substring %q", err, test.wantError)
}
if test.wantCorrection == "" {
if ctx != nil && len(ctx.Corrections) != 0 {
t.Fatalf("confirmation spelling received corrections: %#v", ctx.Corrections)
}
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != test.wantOriginal || ctx.Corrections[0].Corrected != test.wantCorrection {
t.Fatalf("confirmation corrections = %#v, want %q -> %q", ctx, test.wantOriginal, test.wantCorrection)
}
if len(caller.calls) != test.wantCalls {
t.Fatalf("destructive calls = %#v, want %d", caller.calls, test.wantCalls)
}
})
}
}
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
ctx, err := executeParamAliasE2E(t, caller,
"calendar", "event", "list",
"--date", start,
"--end-time", end,
"--calendar", "primary",
"--max-results", "7",
"--next-cursor", "cursor-1",
)
if err != nil {
t.Fatalf("calendar alias E2E error = %v", err)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--date" || ctx.Corrections[0].Corrected != "--start" {
t.Fatalf("calendar corrections = %#v, want only --date to be normalized centrally", ctx.Corrections)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_calendar_events" {
t.Fatalf("calendar calls = %#v", caller.calls)
}
startMS, _ := cmdutil.ParseISOTimeToMillis("start", start)
endMS, _ := cmdutil.ParseISOTimeToMillis("end", end)
want := map[string]any{
"startTime": startMS,
"endTime": endMS,
"calendarId": "primary",
"limit": 7,
"cursor": "cursor-1",
}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("calendar payload = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--text", "hello alias",
"--uuid", "alias-e2e",
)
if err != nil {
t.Fatalf("chat write alias E2E error = %v", err)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--to-user" || ctx.Corrections[0].Corrected != "--user" {
t.Fatalf("chat corrections = %#v", ctx.Corrections)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "hello alias") {
t.Fatalf("chat payload content = %q", content)
}
for _, forbidden := range []string{"user", "to-user", "userId"} {
if _, exists := payload[forbidden]; exists {
t.Fatalf("chat payload leaked pre-normalization field %q: %#v", forbidden, payload)
}
}
}
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
tool string
required []string
}{
{
name: "add emoji",
command: []string{"chat", "message", "add-emoji"},
tool: "add_emoji_reaction",
required: []string{"--msg-id", "message-1", "--emoji", "like"},
},
{
name: "remove emoji",
command: []string{"chat", "message", "remove-emoji"},
tool: "remove_emoji_reaction",
required: []string{"--msg-id", "message-1", "--emoji", "like"},
},
{
name: "add text emotion",
command: []string{"chat", "message", "add-text-emotion"},
tool: "add_text_emotion",
required: []string{
"--msg-id", "message-1", "--emotion-id", "emotion-1",
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
},
},
{
name: "remove text emotion",
command: []string{"chat", "message", "remove-text-emotion"},
tool: "remove_text_emotion",
required: []string{
"--msg-id", "message-1", "--emotion-id", "emotion-1",
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
canonicalArgs := append([]string(nil), test.command...)
canonicalArgs = append(canonicalArgs, "--conversation-id", "conversation-1")
canonicalArgs = append(canonicalArgs, test.required...)
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("canonical execution failed: %v", err)
}
if len(canonicalCaller.calls) != 1 || canonicalCaller.calls[0].tool != test.tool {
t.Fatalf("canonical calls = %#v, want one %s call", canonicalCaller.calls, test.tool)
}
if canonicalCaller.calls[0].args["openConversationId"] != "conversation-1" {
t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args)
}
// Numeric --group-id is a different identifier domain and is covered
// by TestAllReviewedParamAliasGuardsReachRuntimeContract.
for _, alias := range []string{"chat-id", "open-conversation-id"} {
t.Run(alias, func(t *testing.T) {
aliasArgs := append([]string(nil), test.command...)
aliasArgs = append(aliasArgs, "--"+alias, "conversation-1")
aliasArgs = append(aliasArgs, test.required...)
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, aliasCaller, aliasArgs...)
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" {
t.Fatalf("alias corrections = %#v", ctx)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final calls differ\ncanonical=%#v\nalias=%#v", canonicalCaller.calls, aliasCaller.calls)
}
})
}
})
}
}
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
if err != nil {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
chatEntries := 0
aliasCases := 0
guardCases := map[pipeline.FlagProtection]int{}
for _, entry := range entries {
if !strings.HasPrefix(entry.CLIPath, "chat ") {
continue
}
chatEntries++
leaf := resolveParamLeaf(root, entry.CLIPath)
if leaf == nil {
t.Fatalf("generated chat parameter path %q is not runnable", entry.CLIPath)
}
aliases := make([]string, 0, len(entry.Aliases))
for emitted := range entry.Aliases {
aliases = append(aliases, emitted)
}
sort.Strings(aliases)
for _, emitted := range aliases {
emitted := emitted
canonical := entry.Aliases[emitted]
aliasCases++
t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) {
value := paramFixtureValue(leaf, emitted, canonical)
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value {
t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args)
}
if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil {
t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr)
}
})
}
for _, guard := range []struct {
protection pipeline.FlagProtection
emitted []string
}{
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
} {
for _, emitted := range guard.emitted {
emitted := emitted
protection := guard.protection
guardCases[protection]++
t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) {
value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection))
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
}
morphed := cmdutil.Morph(emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != protection {
t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection)
}
assertLeftUnchanged(t, ctx, emitted, value)
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
}
})
}
}
}
if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 {
t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
}{
// These paths are reduced by the reviewed user_id concept.
{command: "chat +chat-role-query-user", want: "user"},
{command: "chat +chat-role-set-user", want: "user"},
{command: "chat +messages-list-direct", want: "user"},
{command: "chat chmod", want: "user"},
{command: "chat message list", want: "user"},
{command: "chat message send", want: "user"},
// These commands already own a hidden --userId compatibility flag.
// The format/spelling handler rewrites --user-id to that real flag, and
// the command's existing flagOrFallback wiring preserves its semantics.
{command: "chat conversation-info", want: "userId"},
{command: "chat group transfer-owner", want: "userId"},
{command: "chat group-role query-user", want: "userId"},
{command: "chat group-role remove-user", want: "userId"},
{command: "chat group-role set-user", want: "userId"},
{command: "chat group set-admin", want: "userId"},
{command: "chat group-mute-member", want: "userId"},
{command: "chat message read-status", want: "userId"},
{command: "chat message search-advanced", want: "userId"},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
root := NewRootCommand()
leaf := resolveParamLeaf(root, test.command)
if leaf == nil {
t.Fatalf("IM command %q is not runnable", test.command)
}
rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user")
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(test.command)):]
if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" {
t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want)
}
if err := leaf.ParseFlags(flagArgs); err != nil {
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
}
})
}
}
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
}
root := NewRootCommand()
leaf := resolveParamLeaf(root, command)
if leaf == nil || !leaf.Hidden {
t.Fatalf("%q must remain a live hidden compatibility command", command)
}
rawArgs := append(strings.Fields(command), "--user-id", "fixture-user")
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(command)):]
if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err)
}
}
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
canonicalArgs []string
aliasArgs []string
wantCorrections int
wantArgKeys []string
}{
{
name: "calendar read with multiple aliases",
tool: "list_calendar_events",
canonicalArgs: []string{
"--dry-run", "calendar", "event", "list",
"--start", "2026-03-10T14:00:00+08:00",
"--end", "2026-03-10T18:00:00+08:00",
"--calendar-id", "primary", "--limit", "7", "--cursor", "cursor-1",
},
aliasArgs: []string{
"--dry-run", "calendar", "event", "list",
"--date", "2026-03-10T14:00:00+08:00",
"--end-time", "2026-03-10T18:00:00+08:00",
"--calendar", "primary", "--max-results", "7", "--next-cursor", "cursor-1",
},
wantCorrections: 1,
wantArgKeys: []string{"calendarId", "cursor", "endTime", "limit", "startTime"},
},
{
name: "chat write scoped recipient alias",
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
},
{
name: "mail write folder id concept alias",
tool: "update_mail_folder",
canonicalArgs: []string{
"--dry-run", "mail", "folder", "update",
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
},
aliasArgs: []string{
"--dry-run", "mail", "folder", "update",
"--email", "fixture@example.com", "--folder-id", "folder-1", "--name", "Fixture Folder",
},
wantCorrections: 1,
wantArgKeys: []string{"email", "id", "name"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, canonical, canonicalAttempts, canonicalErr := executeParamAliasDryRunE2E(t, test.canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("canonical dry-run failed: %v", canonicalErr)
}
ctx, alias, aliasAttempts, aliasErr := executeParamAliasDryRunE2E(t, test.aliasArgs...)
if aliasErr != nil {
t.Fatalf("alias dry-run failed: %v\ncontext=%#v", aliasErr, ctx)
}
if ctx == nil || len(ctx.Corrections) != test.wantCorrections {
t.Fatalf("alias dry-run corrections = %#v, want %d", ctx, test.wantCorrections)
}
if len(canonicalAttempts) != 0 || len(aliasAttempts) != 0 {
t.Fatalf("dry-run reached command runner\ncanonical=%#v\nalias=%#v", canonicalAttempts, aliasAttempts)
}
for label, preview := range map[string]paramAliasDryRunPreview{"canonical": canonical, "alias": alias} {
if !preview.DryRun || preview.Executed {
t.Fatalf("%s preview execution state = %#v", label, preview)
}
if preview.Tool != test.tool {
t.Fatalf("%s preview tool = %q, want %q", label, preview.Tool, test.tool)
}
keys := make([]string, 0, len(preview.Arguments))
for key := range preview.Arguments {
keys = append(keys, key)
}
sort.Strings(keys)
if !reflect.DeepEqual(keys, test.wantArgKeys) {
t.Fatalf("%s preview argument keys = %v, want %v", label, keys, test.wantArgKeys)
}
}
if !reflect.DeepEqual(alias, canonical) {
t.Fatalf("dry-run previews differ\ncanonical=%#v\nalias=%#v", canonical, alias)
}
})
}
}
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
{"calendar", "event", "list", "--start", "2026-03-11", "--date", "2026-03-10"},
} {
root := NewRootCommand()
root.SetArgs(args)
originalCaller := helpers.GetCaller()
helpers.InitDeps(caller)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
helpers.InitDeps(originalCaller)
var conflict *pipeline.FlagConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("RunPreParseArgs(%v) error = %v, want FlagConflictError (ctx=%#v)", args, err, ctx)
}
if conflict.Canonical != "start" || !reflect.DeepEqual(conflict.Spellings, []string{"date", "start"}) {
t.Fatalf("conflict = %#v", conflict)
}
}
if len(caller.calls) != 0 {
t.Fatalf("conflicting argv reached RunE/tool dispatch: %#v", caller.calls)
}
}
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
paths := make(map[string]bool)
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
paths[path] = true
}
}
sourceGuards := make(map[string]pipeline.FlagProtection)
for _, override := range concepts.Overrides {
paths[override.CommandPath] = true
for _, emitted := range override.Block {
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionBlocked
}
for _, emitted := range override.Ambiguous {
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionAmbiguous
}
}
orderedPaths := make([]string, 0, len(paths))
for path := range paths {
orderedPaths = append(orderedPaths, path)
}
sort.Strings(orderedPaths)
root := NewRootCommand()
engine := newPipelineEngine()
guardCounts := map[pipeline.FlagProtection]int{}
testedGuards := make(map[string]pipeline.FlagProtection)
for _, path := range orderedPaths {
entry, ok := cli.LookupParamAlias(path)
if !ok {
continue
}
leaf := resolveParamLeaf(root, path)
if leaf == nil {
t.Fatalf("generated guard path %q is not runnable", path)
}
for _, protectionCase := range []struct {
protection pipeline.FlagProtection
emitted []string
}{
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
} {
for _, emitted := range protectionCase.emitted {
protectionCase := protectionCase
emitted := emitted
key := path + "\x00" + cmdutil.Morph(emitted)
if previous, duplicate := testedGuards[key]; duplicate {
t.Fatalf("generated guard %q/%q is classified twice: %s and %s", path, emitted, previous, protectionCase.protection)
}
testedGuards[key] = protectionCase.protection
guardCounts[protectionCase.protection]++
t.Run(path+"/"+emitted, func(t *testing.T) {
value := "FIXTURE_VALUE"
pathArgs := strings.Fields(path)
args := append(append([]string(nil), pathArgs...), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, args)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, runErr)
}
morphed := cmdutil.Morph(emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != protectionCase.protection {
t.Fatalf("guard protection = %#v, want %s for %q", ctx, protectionCase.protection, morphed)
}
assertLeftUnchanged(t, ctx, emitted, value)
flagArgs := ctx.Args[len(pathArgs):]
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
}
})
}
}
}
for key, want := range sourceGuards {
if got, ok := testedGuards[key]; !ok || got != want {
t.Fatalf("reviewed source guard %q delivered as %s (present=%t), want %s", key, got, ok, want)
}
}
if guardCounts[pipeline.FlagProtectionBlocked] == 0 || guardCounts[pipeline.FlagProtectionAmbiguous] == 0 {
t.Fatalf("reviewed guard coverage is vacuous: blocked %d ambiguous %d", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous])
}
}
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
protection pipeline.FlagProtection
reason string
}{
{path: "chat message list-by-sender", emitted: "time", protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag"},
{path: "drive list", emitted: "space", protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag"},
} {
test := test
t.Run(test.path+"/"+test.emitted, func(t *testing.T) {
value := "FIXTURE_VALUE"
args := append(strings.Fields(test.path), "--"+test.emitted, value)
caller := &paramAliasCaptureCaller{}
ctx, executeErr := executeParamAliasE2E(t, caller, args...)
morphed := cmdutil.Morph(test.emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != test.protection {
t.Fatalf("guard protection = %#v, want %s for %q", ctx, test.protection, morphed)
}
assertLeftUnchanged(t, ctx, test.emitted, value)
var appErr *apperrors.Error
if !stderrors.As(executeErr, &appErr) {
t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != test.reason || apperrors.ExitCode(executeErr) != 3 {
t.Fatalf("final error contract = category %q reason %q exit %d, want validation/%s/3", appErr.Category, appErr.Reason, apperrors.ExitCode(executeErr), test.reason)
}
if !strings.Contains(appErr.Message, "unknown flag: --"+test.emitted) || !strings.Contains(appErr.Message, "See 'dws "+test.path+" --help' for usage.") {
t.Fatalf("final error message = %q", appErr.Message)
}
if !strings.Contains(appErr.Hint, "--"+test.emitted) || !strings.Contains(appErr.Hint, "--help") {
t.Fatalf("final error hint = %q", appErr.Hint)
}
wantAction := "Run 'dws " + test.path + " --help' for valid flags"
if !reflect.DeepEqual(appErr.Actions, []string{wantAction}) || len(appErr.AvailableFlags) == 0 || appErr.Cause == nil {
t.Fatalf("final recovery fields = actions %v flags %v cause %v", appErr.Actions, appErr.AvailableFlags, appErr.Cause)
}
if len(caller.calls) != 0 {
t.Fatalf("guarded flag reached RunE/tool dispatch: %#v", caller.calls)
}
})
}
}
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
t.Fatalf("FlagConflictError = %q, want %q", err, want)
}
}
+220
View File
@@ -0,0 +1,220 @@
// 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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestParamAliasFixtureThroughEmbeddedDeliveryPath is the ⑥ regression gate.
// It reads the reviewed validation_fixture straight from the embedded concept
// dictionary and asserts every reviewed bad case through the REAL delivery
// path — not a generator unit test and not a reimplementation of the reduction
// logic:
//
// - the runtime PreParse engine built by newPipelineEngine() (the exact
// handler chain root.go installs, whose SemanticAliasHandler is wired to
// cli.LookupParamAlias over the embedded generated table),
// - one distribution-owned Cobra tree, reused because PreParse reads command
// and flag metadata but does not parse or mutate individual flag values,
// and
// - the embedded cli.LookupParamAlias query used to prove that a
// did-you-mean case is an intentional block/ambiguous guard rather than a
// name that merely happens to be absent from the table.
//
// Fixture expect semantics (see spec §⑥):
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if len(concepts.Fixture) == 0 {
t.Fatal("validation_fixture declares no cases; ⑥ gate would be vacuous")
}
// The exact runtime handler chain (alias → semantic → sticky →
// paramname), with the semantic table sourced from the embedded generated
// snapshot. Build the distribution-owned tree once: constructing the full
// 800+ command tree for every fixture made the macOS race package exceed its
// 10-minute budget, while RunPreParseArgs itself only reads this tree.
engine := newPipelineEngine()
root := NewSchemaSourceRootCommand()
for _, c := range concepts.Fixture {
t.Run(c.Command+"/"+c.Emitted, func(t *testing.T) {
leaf := resolveParamLeaf(root, c.Command)
if leaf == nil {
t.Fatalf("fixture command %q is not a live Cobra command", c.Command)
}
// Fixture command paths carry no "dws" prefix; LookupParamAlias
// normalizes to the same key the generator used, so the runtime
// lookup is byte-identical to the build-time key.
entry, hasEntry := cli.LookupParamAlias(c.Command)
fixtureValue := paramFixtureValue(leaf, c.Emitted, c.Expect)
rawArgs := append(strings.Fields(c.Command), "--"+c.Emitted, fixtureValue)
root.SetArgs(rawArgs)
ctx, err := pipeline.RunPreParseArgs(root, engine, rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs error = %v", err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs skipped a fixture command with real flags")
}
morphed := cmdutil.Morph(c.Emitted)
switch c.Expect {
case "did-you-mean:ambiguous":
if !hasEntry || !entry.IsAmbiguous(morphed) {
t.Fatalf("%q on %q: expected co-occurrence guard (ambiguous) but embedded entry does not classify it; ambiguous=%v", c.Emitted, c.Command, entry.Ambiguous)
}
if commandHasRealFlagByMorph(leaf, morphed) {
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
}
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
case "did-you-mean:blocked":
if !hasEntry || !entry.IsBlocked(morphed) {
t.Fatalf("%q on %q: expected block guard but embedded entry does not classify it; blocked=%v", c.Emitted, c.Command, entry.Blocked)
}
if commandHasRealFlagByMorph(leaf, morphed) {
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
}
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
default:
// Real-flag expect: the reviewed canonical outcome is delivered
// one of two equally valid ways, and the gate accepts either
// (failing only on a genuine unknown-flag hallucination):
// 1. semantic rewrite — the emitted synonym is not a real flag,
// so the embedded table rewrites it to the canonical flag; or
// 2. native acceptance — the emitted synonym is still a genuine
// (usually hidden) real flag the command accepts directly and
// maps to the same entity via its fallback wiring. Native
// compatibility flags intentionally remain command-owned.
if !commandHasRealFlagByMorph(leaf, cmdutil.Morph(c.Expect)) {
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
switch {
case got == "--"+c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
}
if canon, hit := entry.ResolveAlias(morphed); !hit || canon != c.Expect {
t.Fatalf("embedded table ResolveAlias(%q) on %q = %q (hit=%v), want %q", morphed, c.Command, canon, hit, c.Expect)
}
case cmdutil.Morph(gotBare) == morphed && commandHasRealFlagByMorph(leaf, morphed):
// (2) not rewritten — only valid if the command natively
// accepts the emitted synonym as a real flag.
default:
t.Fatalf("%q on %q reduced to unexpected %q, want --%s or native --%s (args=%v)", c.Emitted, c.Command, got, c.Expect, c.Emitted, ctx.Args)
}
}
})
}
}
// assertLeftUnchanged verifies a guarded (blocked/ambiguous) synonym is never
// silently rewritten: the flag token and its value survive verbatim so the
// unknown-flag did-you-mean path can surface the reviewed candidates.
func assertLeftUnchanged(t *testing.T, ctx *pipeline.Context, emitted, value string) {
t.Helper()
flagIndex := -1
for i, arg := range ctx.Args {
if arg == "--"+emitted || strings.HasPrefix(arg, "--"+emitted+"=") {
flagIndex = i
break
}
}
if flagIndex < 0 {
t.Fatalf("guarded synonym --%s disappeared: args=%v", emitted, ctx.Args)
}
if got := ctx.Args[flagIndex]; got != "--"+emitted {
t.Fatalf("guarded synonym --%s was rewritten to %q (must be left for did-you-mean): args=%v", emitted, got, ctx.Args)
}
if len(ctx.Args) <= flagIndex+1 || ctx.Args[flagIndex+1] != value {
t.Fatalf("guarded synonym --%s lost its value: args=%v", emitted, ctx.Args)
}
for _, corr := range ctx.Corrections {
if corr.Handler == "semantic-alias" && corr.Original == "--"+emitted {
t.Fatalf("guarded synonym --%s was corrected by %s (must not be): %+v", emitted, corr.Handler, corr)
}
}
}
func paramFixtureValue(cmd *cobra.Command, emitted, expect string) string {
if cmd == nil {
return "FIXTURE_VALUE"
}
wanted := []string{emitted}
if !strings.HasPrefix(expect, "did-you-mean:") {
wanted = append(wanted, expect)
}
for _, name := range wanted {
var found *pflag.Flag
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if found == nil && cmdutil.Morph(flag.Name) == cmdutil.Morph(name) {
found = flag
}
})
if found == nil {
continue
}
switch found.Value.Type() {
case "bool":
return "true"
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64":
return "1"
}
}
return "FIXTURE_VALUE"
}
// resolveParamLeaf resolves a fixture command path (no "dws" prefix, e.g.
// "chat message search-advanced") to its live Cobra command, or nil.
func resolveParamLeaf(root *cobra.Command, path string) *cobra.Command {
cmd, _, err := root.Find(strings.Fields(path))
if err != nil || cmd == nil || cmd == root {
return nil
}
return cmd
}
// commandHasRealFlagByMorph reports whether the command has any real flag
// (local or inherited, including hidden) whose Morph matches morphed — the same
// notion of "real flag" the build-time reducer uses to absorb legacy synonyms.
func commandHasRealFlagByMorph(cmd *cobra.Command, morphed string) bool {
found := false
check := func(f *pflag.Flag) {
if f.Name != "help" && cmdutil.Morph(f.Name) == morphed {
found = true
}
}
cmd.Flags().VisitAll(check)
cmd.InheritedFlags().VisitAll(check)
return found
}
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
// write commands use the capture caller rather than a real transport. The
// target canonical flag must occur exactly once so the test can replace only
// its spelling while holding every other input constant.
var paramAliasCompleteCommands = map[string][]string{
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat category create": {"chat", "category", "create", "--title", "Fixture Cat", "--yes"},
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
"chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"},
"chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"},
"chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"},
"chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"},
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
// optimization. The dedicated gate below requires every one to remain active
// in the embedded generated table and equivalent at the final transport.
var paramAliasNewIMCases = []struct {
command string
emitted string
canonical string
}{
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
{command: "chat +bot-search", emitted: "current-page", canonical: "page"},
{command: "chat +category-create", emitted: "name", canonical: "title"},
{command: "chat +category-rename", emitted: "name", canonical: "title"},
{command: "chat +messages-list-direct", emitted: "start", canonical: "time"},
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
{command: "chat bot search", emitted: "current-page", canonical: "page"},
{command: "chat category create", emitted: "name", canonical: "title"},
{command: "chat category create-smart", emitted: "title", canonical: "name"},
{command: "chat category rename", emitted: "name", canonical: "title"},
{command: "chat message list", emitted: "start", canonical: "time"},
{command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"},
{command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"},
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
// checked through the embedded PreParse delivery path and against a complete
// business-valid command template. The separate IM gate below continues to
// execute every alias introduced by the current IM optimization.
//
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
// executing the complete 800+ command Root twice per spelling under -race.
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
activeCommands := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
activeCommands[fixture.Command] = true
activeCases++
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
continue
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("complete command for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, canonicalArgs)
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
}
}
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed IM alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active {
return
}
if target != test.canonical {
t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeAliases != len(paramAliasNewIMCases) {
t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases))
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
if variant, exists := variants[canonical]; exists {
return variant, true
}
}
return complete, ok
}
func paramAliasPayloadCaseKey(command, emitted string) string {
return command + "\x00" + emitted
}
func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
return executeParamAliasE2E(t, caller, args...)
}
func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
out := append([]string(nil), args...)
replacements := 0
for index, arg := range out {
if arg == "--"+canonical {
out[index] = "--" + emitted
replacements++
continue
}
if strings.HasPrefix(arg, "--"+canonical+"=") {
out[index] = "--" + emitted + strings.TrimPrefix(arg, "--"+canonical)
replacements++
}
}
return out, replacements
}
@@ -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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist locks the
// boundary between the command's original hidden compatibility flags and the
// new central semantic normalizer. Existing real flags stay untouched and are
// handled by calendar.go's flagOrFallback chain; only spellings that are not
// real flags (for example --date, --from, and --since) are rewritten centrally.
func TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist(t *testing.T) {
engine := newPipelineEngine()
cases := []struct {
emitted string
value string
canonical string
isInt bool
native bool
}{
// Existing Calendar compatibility flags remain native.
{"start-time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"startTime", "2026-03-10T14:00:00+08:00", "start", false, true},
{"start_time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"start-date", "2026-03-10T14:00:00+08:00", "start", false, true},
{"min-time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"time-min", "2026-03-10T14:00:00+08:00", "start", false, true},
{"end-time", "2026-03-10T18:00:00+08:00", "end", false, true},
{"endTime", "2026-03-10T18:00:00+08:00", "end", false, true},
{"end-date", "2026-03-10T18:00:00+08:00", "end", false, true},
{"max-time", "2026-03-10T18:00:00+08:00", "end", false, true},
{"time-max", "2026-03-10T18:00:00+08:00", "end", false, true},
{"max-results", "50", "limit", true, true},
{"maxResults", "50", "limit", true, true},
{"page-size", "50", "limit", true, true},
{"size", "50", "limit", true, true},
{"next-cursor", "TOKEN123", "cursor", false, true},
{"nextCursor", "TOKEN123", "cursor", false, true},
{"page-token", "TOKEN123", "cursor", false, true},
{"next-token", "TOKEN123", "cursor", false, true},
{"calendar", "primary", "calendar-id", false, true},
{"calendarId", "primary", "calendar-id", false, true},
// These spellings have no native Calendar flag and remain central aliases.
{"from", "2026-03-10T14:00:00+08:00", "start", false, false},
{"since", "2026-03-10T14:00:00+08:00", "start", false, false},
{"date", "2026-03-10T14:00:00+08:00", "start", false, false},
}
for _, tc := range cases {
t.Run(tc.emitted, func(t *testing.T) {
// Fresh command tree per case: ParseFlags mutates flag state.
root := NewRootCommand()
target := mustFindCommand(t, root, "calendar", "event", "list")
ctx := &pipeline.Context{
Args: []string{"calendar", "event", "list", "--" + tc.emitted, tc.value},
Command: target.CommandPath(),
FlagSpecs: pipeline.FlagInfoFromCommand(target),
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error = %v", err)
}
parsedFlag := tc.canonical
if tc.native {
parsedFlag = tc.emitted
if len(ctx.Corrections) != 0 {
t.Fatalf("native --%s triggered central corrections: %#v", tc.emitted, ctx.Corrections)
}
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.emitted+" "+tc.value) {
t.Fatalf("native --%s did not survive unchanged: args = %v", tc.emitted, ctx.Args)
}
} else {
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) {
t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args)
}
if len(ctx.Corrections) != 1 {
t.Fatalf("central --%s corrections = %#v, want one", tc.emitted, ctx.Corrections)
}
}
flagArgs := ctx.Args[3:]
if err := target.ParseFlags(flagArgs); err != nil {
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
}
if tc.isInt {
got, err := target.Flags().GetInt(parsedFlag)
if err != nil || got != 50 {
t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err)
}
} else {
got, err := target.Flags().GetString(parsedFlag)
if err != nil || got != tc.value {
t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value)
}
}
})
}
}
// TestCalendarEventListKeepsCountExclusion pins the reviewed decision that
// pagination_size deliberately excludes --count (count != limit). The kept
// hidden --count flag must be left untouched by the pipeline: it is a real
// flag, not a concept member, so it must not be rewritten to --limit.
func TestCalendarEventListKeepsCountExclusion(t *testing.T) {
engine := newPipelineEngine()
root := NewRootCommand()
target := mustFindCommand(t, root, "calendar", "event", "list")
ctx := &pipeline.Context{
Args: []string{"calendar", "event", "list", "--count", "5"},
Command: target.CommandPath(),
FlagSpecs: pipeline.FlagInfoFromCommand(target),
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error = %v", err)
}
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--count 5") {
t.Fatalf("--count must not be rewritten: args = %v", ctx.Args)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("--count triggered corrections %#v, want none", ctx.Corrections)
}
if err := target.ParseFlags(ctx.Args[3:]); err != nil {
t.Fatalf("Cobra ParseFlags error = %v", err)
}
if got, _ := target.Flags().GetInt("count"); got != 5 {
t.Fatalf("flag --count = %d, want 5", got)
}
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
stderrors "errors"
"io"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "camel case", args: []string{"--dryRun", "chat", "bot", "find", "--help"}},
{name: "fuzzy boolean", args: []string{"--dry-rnu", "chat", "bot", "find", "--help"}},
{name: "fuzzy value", args: []string{"--profle", "corp:user", "chat", "bot", "find", "--help"}},
{name: "sticky value", args: []string{"--timeout30", "chat", "bot", "find", "--help"}},
{name: "sticky boolean value", args: []string{"--verbosefalse", "chat", "bot", "find", "--help"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", test.args, err)
}
if ctx == nil || ctx.Command != "dws chat bot find" || len(ctx.Corrections) == 0 {
t.Fatalf("RunPreParseArgs(%v) context = %#v", test.args, ctx)
}
if err := root.Execute(); err != nil {
t.Fatalf("corrected leading persistent flag failed: %v", err)
}
})
}
}
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
"--user-id", "123", "--user", "456", "--text", "hi",
"--format", "table", "--debug",
}
_, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err == nil {
t.Fatal("alias/canonical conflict unexpectedly succeeded")
}
if wantsJSONErrors(root) {
t.Fatal("--format table was not applied before rendering the PreParse error")
}
if got := resolveVerbosity(root); got != apperrors.VerbosityDebug {
t.Fatalf("PreParse error verbosity = %v, want debug", got)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("PreParse validation error = %T, want *errors.Error", err)
}
if strings.Contains(structured.Message, "pipeline") || strings.Contains(structured.Message, "semantic-alias") ||
strings.Contains(structured.Cause.Error(), "pipeline") || strings.Contains(structured.Cause.Error(), "semantic-alias") {
t.Fatalf("internal pipeline identity leaked to user error: message=%q cause=%q", structured.Message, structured.Cause)
}
var conflict *pipeline.FlagConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("PreParse validation error lost FlagConflictError: %v", err)
}
var output bytes.Buffer
if printErr := printExecutionError(root, &output, &output, err); printErr != nil {
t.Fatalf("printExecutionError() error = %v", printErr)
}
rendered := output.String()
if strings.HasPrefix(strings.TrimSpace(rendered), "{") {
t.Fatalf("--format table rendered JSON:\n%s", rendered)
}
if !strings.Contains(rendered, "Reason: parameter_conflict") || !strings.Contains(rendered, "Cause:") {
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
+111 -6
View File
@@ -76,6 +76,7 @@ var (
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
)
// Execute runs the root command and returns the process exit code.
@@ -114,7 +115,11 @@ func Execute() (exitCode int) {
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
rootRunPreParse(root, engine)
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
executed, err := rootExecuteCommand(root)
if err != nil {
@@ -136,6 +141,22 @@ func Execute() (exitCode int) {
return 0
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
userErr = handlerErr.Unwrap()
}
return apperrors.NewValidation(
userErr.Error(),
apperrors.WithReason("parameter_conflict"),
apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."),
apperrors.WithCause(userErr),
)
}
func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
@@ -183,6 +204,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, 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"
if protection == pipeline.FlagProtectionAmbiguous {
hint = fmt.Sprintf("Parameter --%s is ambiguous on %q and cannot be normalized safely; choose the intended explicit flag from --help.", flag, cmd.CommandPath())
reason = "ambiguous_flag"
}
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint(hint),
apperrors.WithReason(reason),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
// Common flag aliases and suggestions
suggestions := map[string]string{
@@ -231,6 +268,33 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
return fmt.Errorf("%s%s", errMsg, tail)
}
func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline.FlagProtection, bool) {
if cmd == nil {
return "", "", false
}
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]
}
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
if !ok {
return "", "", false
}
morphed := cmdutil.Morph(flag)
if entry.IsBlocked(morphed) {
return flag, pipeline.FlagProtectionBlocked, true
}
if entry.IsAmbiguous(morphed) {
return flag, pipeline.FlagProtectionAmbiguous, true
}
return "", "", false
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
var raw apperrors.RawStderrError
if stderrors.As(err, &raw) {
@@ -339,7 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
}
runner := newCommandRunnerWithFlags(loader, flags)
runner := rootNewCommandRunnerWithFlags(loader, flags)
root := &cobra.Command{
Use: "dws",
@@ -453,11 +517,38 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
installReviewedFlagProtectionHandlers(root)
root.SetContext(rootCtx)
return root
}
// installReviewedFlagProtectionHandlers makes reviewed blocked/ambiguous
// parameters authoritative even when an older command subtree has installed a
// local FlagErrorFunc. Commands without a reviewed guard keep their existing
// handler or inherit the root handler as before.
func installReviewedFlagProtectionHandlers(root *cobra.Command) {
if root == nil {
return
}
var visit func(*cobra.Command)
visit = func(cmd *cobra.Command) {
if entry, ok := cli.LookupParamAlias(cmd.CommandPath()); ok && (len(entry.Blocked) > 0 || len(entry.Ambiguous) > 0) {
previous := cmd.FlagErrorFunc()
cmd.SetFlagErrorFunc(func(current *cobra.Command, err error) error {
if _, _, guarded := reviewedFlagProtection(current, err.Error()); guarded {
return flagErrorWithSuggestions(current, err)
}
return previous(current, err)
})
}
for _, child := range cmd.Commands() {
visit(child)
}
}
visit(root)
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
@@ -1252,13 +1343,27 @@ func newPipelineEngine() *pipeline.Engine {
// Register handler runs during command tree building.
handlers.RegisterHandler{},
// PreParse handlers run in order: alias → sticky → paramname.
// Alias normalises case first (--userId → --user-id), then
// sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit).
// PreParse handlers run in order: alias → semantic → sticky → paramname
// → boolvalue.
// Alias normalises case first (--userId → --user-id), then semantic
// resolves reviewed synonyms to the real flag (--keyword → --query),
// then sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit). Boolvalue runs
// last so detached values for every real boolean flag (for example
// `--dry-run false`) become explicit `--flag=false` tokens before pflag
// can interpret the bare flag as true.
handlers.AliasHandler{},
handlers.SemanticAliasHandler{
// Inject the build-time reduced alias table with native types so
// the handler package stays decoupled from cli.
Lookup: func(rawCommandPath string) (map[string]string, []string, []string, bool) {
e, ok := cli.LookupParamAlias(rawCommandPath)
return e.Aliases, e.Blocked, e.Ambiguous, ok
},
},
handlers.StickyHandler{},
handlers.ParamNameHandler{},
handlers.BoolValueHandler{},
// PostParse handlers normalise structured values.
handlers.ParamValueHandler{},
+7 -1
View File
@@ -40,7 +40,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
@@ -52,6 +52,12 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
t.Fatalf("successful Execute code = %d", code)
}
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")
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
+6 -3
View File
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// gen.go is the single entry point for schema metadata generation. It isolates
// gen.go is the single entry point for reviewed CLI asset generation. It isolates
// all //go:generate pragmas from business code so that:
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
// - Generation is a standalone process (make generate-schema triggers this).
// - The 6-input → 1-output contract is documented in one place.
// - The authored-input → generated-output contract is documented in one place.
//
// Generation inputs (authored, reviewed):
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
@@ -23,11 +23,13 @@
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
// 4. schema_mcp_metadata.json MCP server tool definitions
// 5. schema_parameter_bindings.json parameter type/property mappings
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
// 6. param_concepts.json + schema reviewed parameter synonym policy
// 7. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// Generation outputs (embedded at build):
// - schema_agent_metadata/*.json per-product agent metadata
// - schema_catalog/ per-product catalog shards
// - param_aliases_generated.go per-command parameter normalization
package cli
@@ -36,3 +38,4 @@ package cli
// package cached by the preceding metadata generator with the old embedded
// JSON files.
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
@@ -0,0 +1,59 @@
// 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 cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read internal/cli: %v", err)
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || filepath.Ext(name) != ".go" || name == "gen.go" {
continue
}
content, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, line := range bytes.Split(content, []byte("\n")) {
if strings.HasPrefix(strings.TrimSpace(string(line)), "//go:generate") {
t.Errorf("%s contains //go:generate; all directives must stay in gen.go", name)
}
}
}
content, err := os.ReadFile("gen.go")
if err != nil {
t.Fatalf("read gen.go: %v", err)
}
for _, generator := range []string{
"cmd_schema_agent_metadata",
"cmd_schema_catalog",
"cmd_param_aliases",
} {
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
t.Errorf("gen.go does not register %s", generator)
}
}
}
+427
View File
@@ -0,0 +1,427 @@
// 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 cli
import (
"fmt"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// ParamAliasEntry is the reduced parameter-alias table for one runnable Cobra
// leaf. It is the typed value the build-time generator serializes into
// param_aliases_generated.go and that the runtime normalizer (P2) consumes.
//
// Aliases maps an already-morphed emitted name to the command's canonical real
// flag; the runtime looks up Morph(emitted) here to resolve a synonym. Blocked
// lists morphed names that must never be reduced (they route to did-you-mean),
// and Ambiguous lists morphed names that a reviewed co-occurrence guard leaves
// unresolved on purpose.
type ParamAliasEntry struct {
CLIPath string `json:"cli_path"`
Aliases map[string]string `json:"aliases,omitempty"`
Blocked []string `json:"blocked,omitempty"`
Ambiguous []string `json:"ambiguous,omitempty"`
}
// ReduceParamAliases resolves the reviewed concept dictionary against every
// runnable leaf's real flags and returns the per-command alias table. It is the
// single source of the reduction algorithm, shared by the generator and tests
// so the build-time (intersection) and generated views can never disagree.
//
// The reduction is deliberately mechanical (no NLU): for each concept it morphs
// the concept members (plus any command-bound generic flag) and intersects them
// with the command's morphed real flags. An intersection of exactly one real
// flag yields aliases onto it; two or more real flags is a co-occurrence that
// must be an explicitly reviewed `ambiguous` entry or generation fails. Command
// scoped aliases override, blocks are removed and recorded, and every override
// path and target is validated against the live tree.
func ReduceParamAliases(root *cobra.Command) ([]ParamAliasEntry, error) {
concepts, err := LoadParamConcepts()
if err != nil {
return nil, fmt.Errorf("load reviewed parameter concepts: %w", err)
}
if root == nil {
return nil, fmt.Errorf("parameter alias source root is nil")
}
overrideByPath := make(map[string]CommandOverride, len(concepts.Overrides))
for _, ov := range concepts.Overrides {
overrideByPath[ov.CommandPath] = ov
}
usedOverride := make(map[string]bool, len(overrideByPath))
conceptsByPath := make(map[string][]Concept)
usedConceptScope := make(map[string]bool)
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
conceptsByPath[path] = append(conceptsByPath[path], concept)
}
}
var problems []string
var entries []ParamAliasEntry
walkRunnableParamCommands(root, func(leaf *cobra.Command) {
path := normalizeSchemaCLIPath(leaf.CommandPath())
realByMorph := realFlagsByMorph(leaf)
ov, hasOverride := overrideByPath[path]
if hasOverride {
usedOverride[path] = true
}
scopedConcepts := conceptsByPath[path]
for _, concept := range scopedConcepts {
usedConceptScope[concept.ID+"\x00"+path] = true
if !conceptHasRealFlag(concept, ov, realByMorph) {
problems = append(problems, fmt.Sprintf("concept %q reviewed command %q has no matching real flag or reviewed bind", concept.ID, path))
}
}
entry, entryProblems := reduceLeafParamAliases(path, realByMorph, scopedConcepts, ov)
problems = append(problems, entryProblems...)
if entry != nil {
entries = append(entries, *entry)
}
})
for path := range overrideByPath {
if !usedOverride[path] {
problems = append(problems, fmt.Sprintf("command_override %q does not match any runnable Cobra leaf", path))
}
}
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
if !usedConceptScope[concept.ID+"\x00"+path] {
problems = append(problems, fmt.Sprintf("concept %q command scope %q does not match any runnable Cobra command", concept.ID, path))
}
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("parameter alias reduction failed:\n - %s", strings.Join(problems, "\n - "))
}
sort.Slice(entries, func(i, j int) bool { return entries[i].CLIPath < entries[j].CLIPath })
return entries, nil
}
// walkRunnableParamCommands invokes fn for every runnable command in the tree,
// including runnable parents such as `chat group members` that expose their own
// flags while also owning subcommands. Parameter aliasing applies to any command
// that accepts flags, which is broader than the schema's leaf-only traversal.
func walkRunnableParamCommands(root *cobra.Command, fn func(*cobra.Command)) {
if root == nil {
return
}
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
if cmd.Runnable() {
fn(cmd)
}
for _, sub := range cmd.Commands() {
if sub.Name() == "help" {
continue
}
if !sub.IsAvailableCommand() && !hasRuntimeSchemaCommand(sub) {
continue
}
walk(sub)
}
}
walk(root)
}
// realFlag is one of a leaf's real flags, remembering whether it is hidden so
// the reduction can treat a hidden legacy alias flag (for example a hand-written
// --base living next to the visible --base-id) as an absorbable synonym rather
// than a genuine co-occurrence.
type realFlag struct {
name string
hidden bool
}
// realFlagsByMorph maps each of a leaf's real flags (local + inherited) by its
// morphed name to the real flags that share that morph.
func realFlagsByMorph(leaf *cobra.Command) map[string][]realFlag {
byMorph := make(map[string][]realFlag)
visitManualAgentCommandFlags(leaf, func(flag *pflag.Flag) {
if flag == nil || flag.Name == "help" {
return
}
key := cmdutil.Morph(flag.Name)
byMorph[key] = appendRealFlag(byMorph[key], realFlag{name: flag.Name, hidden: flag.Hidden})
})
return byMorph
}
// reduceLeafParamAliases computes one leaf's alias entry and returns any
// contract problems. A nil entry means the leaf produced no aliases, blocks, or
// ambiguous guards.
func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, concepts []Concept, ov CommandOverride) (*ParamAliasEntry, []string) {
var problems []string
aliasMap := make(map[string]string)
blockedSet := make(map[string]bool)
excludedSet := make(map[string]bool)
pendingReview := ov.Confirm || ov.Investigate
for boundFlag, conceptID := range ov.Bind {
if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; !ok {
problems = append(problems, fmt.Sprintf("command_override %q binds %q to concept %q but %q is not a real flag", path, boundFlag, conceptID, boundFlag))
}
}
// (a) Concept auto-reduction. The caller has already admitted only the
// concepts whose reviewed command scope contains this exact leaf.
for _, concept := range concepts {
eff := make(map[string]bool, len(concept.Members)+2)
for _, member := range concept.Members {
eff[cmdutil.Morph(member)] = true
}
for boundFlag, conceptID := range ov.Bind {
if conceptID == concept.ID {
if pendingReview {
for _, member := range concept.Members {
morphed := cmdutil.Morph(member)
if _, isReal := realByMorph[morphed]; !isReal {
blockedSet[morphed] = true
}
}
} else {
eff[cmdutil.Morph(boundFlag)] = true
}
}
}
// Gather the concept's candidate real flags on this command, then
// choose a canonical. A single visible real flag wins and absorbs the
// rest (including hidden legacy alias flags). Two or more visible real
// flags is a genuine co-occurrence that must be reviewed.
var candidates []realFlag
for key := range eff {
candidates = append(candidates, realByMorph[key]...)
}
if len(candidates) == 0 {
continue
}
visible := distinctRealNames(candidates, true)
var canon string
switch len(visible) {
case 1:
canon = visible[0]
case 0:
names := distinctRealNames(candidates, false)
if len(names) != 1 {
continue
}
canon = names[0]
default:
// Genuine co-occurrence: this concept intersects two or more
// visible real flags, so it cannot be auto-reduced. Require that
// every emittable synonym of THIS concept (a concept member that is
// not itself a real flag on the command) is acknowledged in the
// reviewed ambiguous whitelist. Checking only that the command has
// some ambiguous entry would let one concept's whitelist silently
// vouch for a different concept's unreviewed co-occurrence.
ambiguousSet := make(map[string]bool, len(ov.Ambiguous))
for _, a := range ov.Ambiguous {
ambiguousSet[cmdutil.Morph(a)] = true
}
var unreviewed []string
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
continue
}
if !ambiguousSet[m] {
unreviewed = append(unreviewed, m)
}
}
if len(unreviewed) > 0 {
sort.Strings(visible)
sort.Strings(unreviewed)
problems = append(problems, fmt.Sprintf("command %q concept %q intersects visible real flags %s; unreviewed emittable members %s must be listed in the ambiguous whitelist", path, concept.ID, strings.Join(visible, ","), strings.Join(unreviewed, ",")))
}
continue
}
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
continue
}
if prev, ok := aliasMap[m]; ok && prev != canon {
problems = append(problems, fmt.Sprintf("command %q emitted %q reduces to both %q and %q", path, m, prev, canon))
continue
}
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
}
}
// (b) Command scoped aliases override concept reductions.
for emitted, target := range ov.ScopedAliases {
morphedEmitted := cmdutil.Morph(emitted)
reals, ok := realByMorph[cmdutil.Morph(target)]
if !ok {
problems = append(problems, fmt.Sprintf("command_override %q scoped alias %q->%q targets %q which is not a real flag", path, emitted, target, target))
continue
}
if _, sourceIsReal := realByMorph[morphedEmitted]; sourceIsReal {
problems = append(problems, fmt.Sprintf("command_override %q scoped alias source %q is already a real flag; keep its native compatibility path or remove it before enabling semantic rewrite", path, emitted))
continue
}
if pendingReview {
delete(aliasMap, morphedEmitted)
blockedSet[morphedEmitted] = true
continue
}
delete(blockedSet, morphedEmitted)
aliasMap[morphedEmitted] = canonicalRealName(reals)
}
// (c) Blocks are removed from the alias map and recorded for did-you-mean.
for _, b := range ov.Block {
mb := cmdutil.Morph(b)
if _, isReal := realByMorph[mb]; isReal {
problems = append(problems, fmt.Sprintf("command_override %q blocks %q but it is already a real flag; blocking must not disable a canonical/native parameter", path, b))
continue
}
delete(aliasMap, mb)
blockedSet[mb] = true
}
ambiguous := make([]string, 0, len(ov.Ambiguous))
for _, a := range ov.Ambiguous {
ma := cmdutil.Morph(a)
if _, isReal := realByMorph[ma]; isReal {
problems = append(problems, fmt.Sprintf("command_override %q marks %q ambiguous but it is already a real flag", path, a))
continue
}
if canon, ok := aliasMap[ma]; ok {
problems = append(problems, fmt.Sprintf("command %q name %q is both auto-reduced to %q and marked ambiguous; a name cannot be aliased and ambiguous at once", path, a, canon))
}
delete(aliasMap, ma)
delete(blockedSet, ma)
ambiguous = append(ambiguous, ma)
}
blocked := make([]string, 0, len(blockedSet))
for b := range blockedSet {
blocked = append(blocked, b)
}
if len(aliasMap) == 0 && len(blocked) == 0 && len(ambiguous) == 0 {
return nil, problems
}
return &ParamAliasEntry{
CLIPath: path,
Aliases: aliasMap,
Blocked: sortedUnique(blocked),
Ambiguous: sortedUnique(ambiguous),
}, problems
}
func conceptHasRealFlag(concept Concept, ov CommandOverride, realByMorph map[string][]realFlag) bool {
for _, member := range concept.Members {
if _, ok := realByMorph[cmdutil.Morph(member)]; ok {
return true
}
}
for boundFlag, conceptID := range ov.Bind {
if conceptID == concept.ID {
if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; ok {
return true
}
}
}
return false
}
func appendRealFlag(list []realFlag, value realFlag) []realFlag {
for i, existing := range list {
if existing.name == value.name {
// Prefer the visible record if any registration is visible.
if existing.hidden && !value.hidden {
list[i] = value
}
return list
}
}
list = append(list, value)
sort.Slice(list, func(i, j int) bool { return list[i].name < list[j].name })
return list
}
// distinctRealNames returns the sorted unique flag names among candidates,
// optionally restricted to visible (non-hidden) flags.
func distinctRealNames(candidates []realFlag, visibleOnly bool) []string {
seen := make(map[string]bool, len(candidates))
out := make([]string, 0, len(candidates))
for _, c := range candidates {
if visibleOnly && c.hidden {
continue
}
if seen[c.name] {
continue
}
seen[c.name] = true
out = append(out, c.name)
}
sort.Strings(out)
return out
}
// canonicalRealName chooses the canonical target among real flags sharing a
// morph key, preferring a visible flag over a hidden legacy alias.
func canonicalRealName(reals []realFlag) string {
for _, r := range reals {
if !r.hidden {
return r.name
}
}
if len(reals) > 0 {
return reals[0].name
}
return ""
}
func sortedUnique(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
sort.Strings(out)
return out
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
// 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 cli
import "sync"
// paramAliasIndex is the lazily built per-command view of the generated
// parameter-alias table, keyed by the same normalized CLI path the generator
// used. It is populated once; the generated slice never changes at runtime.
var (
paramAliasIndexOnce sync.Once
paramAliasIndex map[string]ParamAliasEntry
)
func buildParamAliasIndex() {
entries := loadGeneratedParamAliases()
paramAliasIndex = make(map[string]ParamAliasEntry, len(entries))
for _, e := range entries {
paramAliasIndex[e.CLIPath] = e
}
}
// LookupParamAlias resolves the reduced parameter-alias entry for a command.
//
// rawCommandPath is Cobra's CommandPath() (it still carries the "dws" prefix).
// It is normalized through normalizeSchemaCLIPath — the exact function the
// build-time generator used to key each entry — so the runtime lookup key is
// byte-identical to the generation key and there is zero mapping drift.
func LookupParamAlias(rawCommandPath string) (ParamAliasEntry, bool) {
paramAliasIndexOnce.Do(buildParamAliasIndex)
e, ok := paramAliasIndex[normalizeSchemaCLIPath(rawCommandPath)]
return e, ok
}
// ResolveAlias returns the canonical real flag a morphed emitted name reduces
// to, if this command aliases it. The caller is expected to pass an
// already-morphed name (cmdutil.Morph), matching how the table is keyed.
func (e ParamAliasEntry) ResolveAlias(morphed string) (string, bool) {
canon, ok := e.Aliases[morphed]
return canon, ok
}
// IsBlocked reports whether a morphed emitted name is on this command's block
// list: it must never be auto-rewritten and instead routes to did-you-mean.
func (e ParamAliasEntry) IsBlocked(morphed string) bool {
return containsParamAlias(e.Blocked, morphed)
}
// IsAmbiguous reports whether a morphed emitted name is on this command's
// reviewed co-occurrence whitelist: it is intentionally left unresolved so the
// runtime asks instead of guessing between two real flags.
func (e ParamAliasEntry) IsAmbiguous(morphed string) bool {
return containsParamAlias(e.Ambiguous, morphed)
}
func containsParamAlias(list []string, target string) bool {
for _, v := range list {
if v == target {
return true
}
}
return false
}
+518
View File
@@ -0,0 +1,518 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package cli
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// realMap builds a per-leaf real-flag table keyed by the shared Morph so the
// tests exercise exactly the same intersection the generator performs.
func realMap(flags ...realFlag) map[string][]realFlag {
m := make(map[string][]realFlag)
for _, f := range flags {
k := cmdutil.Morph(f.name)
m[k] = appendRealFlag(m[k], f)
}
return m
}
// conceptFixture is a small synthetic concept set; reduceLeafParamAliases is
// deliberately pure so it can be tested without the whole Cobra tree.
func conceptFixture() []Concept {
return []Concept{
{ID: "pagination_size", CanonicalHint: "limit", Members: []string{"limit", "size", "page-size", "max-results"}},
{ID: "base_id", CanonicalHint: "base-id", Members: []string{"base", "base-id", "base-token"}},
{ID: "user_id", CanonicalHint: "user-id", Members: []string{"user", "users", "user-id", "uid"}},
}
}
func useParamConceptLoader(t *testing.T, concepts ParamConcepts, err error) {
t.Helper()
previous := loadReviewedParamConcepts
loadReviewedParamConcepts = func() (ParamConcepts, error) { return concepts, err }
t.Cleanup(func() { loadReviewedParamConcepts = previous })
}
func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) {
t.Run("load failure", func(t *testing.T) {
useParamConceptLoader(t, ParamConcepts{}, errors.New("fixture load"))
if _, err := ReduceParamAliases(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "fixture load") {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
})
t.Run("nil root", func(t *testing.T) {
useParamConceptLoader(t, ParamConcepts{Version: 1}, nil)
if _, err := ReduceParamAliases(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
t.Fatalf("ReduceParamAliases(nil) error = %v", err)
}
})
t.Run("real tree", func(t *testing.T) {
concepts := ParamConcepts{
Version: 1,
Concepts: []Concept{
{ID: "query", Members: []string{"query", "keyword"}, Commands: []string{"demo run"}},
{ID: "user_id", Members: []string{"user-id", "uid"}, Commands: []string{"demo run"}},
},
Overrides: []CommandOverride{
{CommandPath: "alpha", Block: []string{"unsafe"}},
{CommandPath: "demo run", Bind: map[string]string{"id": "user_id"}},
},
}
useParamConceptLoader(t, concepts, nil)
root := &cobra.Command{Use: "dws"}
alpha := &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}}
alpha.Flags().String("name", "", "name")
demo := &cobra.Command{Use: "demo", Run: func(*cobra.Command, []string) {}}
run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
run.Flags().String("query", "", "query")
run.Flags().String("id", "", "id")
demo.AddCommand(run)
root.AddCommand(alpha, demo)
root.AddCommand(&cobra.Command{Use: "help", Run: func(*cobra.Command, []string) {}})
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, Run: func(*cobra.Command, []string) {}})
entries, err := ReduceParamAliases(root)
if err != nil {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
if len(entries) != 2 || entries[0].CLIPath != "alpha" || entries[1].CLIPath != "demo run" {
t.Fatalf("entries = %#v", entries)
}
if entries[1].Aliases["keyword"] != "query" || entries[1].Aliases["uid"] != "id" {
t.Fatalf("demo aliases = %#v", entries[1].Aliases)
}
})
t.Run("stale and unbound review inputs", func(t *testing.T) {
concepts := ParamConcepts{
Version: 1,
Concepts: []Concept{
{ID: "missing", Members: []string{"missing"}, Commands: []string{"demo run"}},
{ID: "stale", Members: []string{"stale"}, Commands: []string{"ghost run"}},
},
Overrides: []CommandOverride{{CommandPath: "ghost run", Block: []string{"unsafe"}}},
}
useParamConceptLoader(t, concepts, nil)
root := &cobra.Command{Use: "dws"}
demo := &cobra.Command{Use: "demo"}
run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
run.Flags().String("query", "", "query")
demo.AddCommand(run)
root.AddCommand(demo)
_, err := ReduceParamAliases(root)
for _, want := range []string{"has no matching real flag", "does not match any runnable Cobra leaf", "does not match any runnable Cobra command"} {
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("ReduceParamAliases() error = %v, want %q", err, want)
}
}
})
}
func TestParamAliasHelperEdges(t *testing.T) {
walkRunnableParamCommands(nil, func(*cobra.Command) { t.Fatal("nil root was visited") })
helpOnly := &cobra.Command{Use: "demo"}
helpOnly.Flags().String("help", "", "help")
if got := realFlagsByMorph(helpOnly); len(got) != 0 {
t.Fatalf("help flag entered the real parameter table: %#v", got)
}
flags := []realFlag{{name: "same", hidden: true}}
flags = appendRealFlag(flags, realFlag{name: "same"})
if len(flags) != 1 || flags[0].hidden {
t.Fatalf("visible duplicate did not replace hidden registration: %#v", flags)
}
flags = appendRealFlag(flags, realFlag{name: "same", hidden: true})
if len(flags) != 1 || flags[0].hidden {
t.Fatalf("hidden duplicate replaced visible registration: %#v", flags)
}
flags = appendRealFlag(flags, realFlag{name: "alpha"})
if !reflect.DeepEqual([]string{flags[0].name, flags[1].name}, []string{"alpha", "same"}) {
t.Fatalf("new real flags are not sorted: %#v", flags)
}
candidates := []realFlag{{name: "hidden", hidden: true}, {name: "visible"}, {name: "visible"}}
if got := distinctRealNames(candidates, true); !reflect.DeepEqual(got, []string{"visible"}) {
t.Fatalf("visible names = %v", got)
}
if got := canonicalRealName([]realFlag{{name: "hidden", hidden: true}}); got != "hidden" {
t.Fatalf("hidden-only canonical = %q", got)
}
if got := canonicalRealName(nil); got != "" {
t.Fatalf("empty canonical = %q", got)
}
if got := sortedUnique(nil); got != nil {
t.Fatalf("sortedUnique(nil) = %#v", got)
}
if got := sortedUnique([]string{"b", "a", "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("sortedUnique() = %v", got)
}
real := realMap(realFlag{name: "query"}, realFlag{name: "id"})
if !conceptHasRealFlag(Concept{ID: "query", Members: []string{"query"}}, CommandOverride{}, real) {
t.Fatal("concept member did not match a real flag")
}
if !conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"id": "user"}}, real) {
t.Fatal("reviewed bind did not match a real flag")
}
if conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"missing": "user"}}, real) {
t.Fatal("missing reviewed bind matched a real flag")
}
}
func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
t.Run("pending reviewed bind blocks non-real members", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "id"}),
[]Concept{{ID: "user_id", Members: []string{"user-id", "uid"}}},
CommandOverride{Bind: map[string]string{"id": "user_id"}, Investigate: true},
)
if len(problems) != 0 || entry == nil ||
!containsParamAlias(entry.Blocked, "user-id") || !containsParamAlias(entry.Blocked, "uid") {
t.Fatalf("pending bind entry = %#v, problems = %v", entry, problems)
}
})
t.Run("hidden-only canonical", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "query", hidden: true}),
[]Concept{{ID: "query", Members: []string{"query", "keyword"}}},
CommandOverride{},
)
if len(problems) != 0 || entry == nil || entry.Aliases["keyword"] != "query" {
t.Fatalf("hidden-only entry = %#v, problems = %v", entry, problems)
}
})
t.Run("multiple hidden candidates stay unresolved", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "first", hidden: true}, realFlag{name: "second", hidden: true}),
[]Concept{{ID: "choice", Members: []string{"first", "second", "choice"}}},
CommandOverride{},
)
if len(problems) != 0 || entry != nil {
t.Fatalf("multiple hidden candidates entry = %#v, problems = %v", entry, problems)
}
})
t.Run("two concepts cannot claim one emitted spelling", func(t *testing.T) {
_, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "first"}, realFlag{name: "second"}),
[]Concept{
{ID: "first", Members: []string{"first", "shared"}},
{ID: "second", Members: []string{"second", "shared"}},
},
CommandOverride{},
)
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "reduces to both") {
t.Fatalf("alias collision problems = %v", problems)
}
})
t.Run("unclaimed exclude becomes blocked", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "query"}),
[]Concept{{ID: "query", Members: []string{"query", "keyword"}, Excludes: []string{"name"}}},
CommandOverride{},
)
if len(problems) != 0 || entry == nil || !containsParamAlias(entry.Blocked, "name") {
t.Fatalf("exclude entry = %#v, problems = %v", entry, problems)
}
})
}
func TestParamAliasEntryLookupMethods(t *testing.T) {
entry := ParamAliasEntry{
Aliases: map[string]string{"uid": "user"},
Blocked: []string{"count"},
Ambiguous: []string{"user-id"},
}
if got, ok := entry.ResolveAlias("uid"); !ok || got != "user" {
t.Fatalf("ResolveAlias(uid) = %q, %v", got, ok)
}
if _, ok := entry.ResolveAlias("missing"); ok {
t.Fatal("ResolveAlias(missing) unexpectedly matched")
}
if !entry.IsBlocked("count") || entry.IsBlocked("missing") {
t.Fatalf("blocked lookup mismatch: %#v", entry.Blocked)
}
if !entry.IsAmbiguous("user-id") || entry.IsAmbiguous("missing") {
t.Fatalf("ambiguous lookup mismatch: %#v", entry.Ambiguous)
}
}
func TestReduceLeafParamAliasesAutoReduction(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(), CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
for _, emitted := range []string{"size", "page-size", "max-results"} {
if entry.Aliases[emitted] != "limit" {
t.Fatalf("alias %q = %q, want limit", emitted, entry.Aliases[emitted])
}
}
if _, ok := entry.Aliases["limit"]; ok {
t.Fatal("the real flag limit must never be an alias key")
}
}
func TestReduceLeafParamAliasesCoOccurrenceRequiresReview(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(), CommandOverride{})
if len(problems) == 0 {
t.Fatal("two visible real flags for one concept must fail without a reviewed ambiguous whitelist")
}
}
func TestReduceLeafParamAliasesAmbiguousWhitelist(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if _, ok := entry.Aliases["user-id"]; ok {
t.Fatal("a reviewed co-occurrence must not auto-reduce its concept members")
}
if len(entry.Ambiguous) != 2 || entry.Ambiguous[0] != "uid" || entry.Ambiguous[1] != "user-id" {
t.Fatalf("ambiguous = %v, want sorted [uid user-id]", entry.Ambiguous)
}
}
// TestReduceLeafParamAliasesCoOccurrencePerConcept locks the per-concept guard:
// reviewing one concept's co-occurrence must not silently vouch for a second,
// unreviewed co-occurring concept on the same command. Here user_id (user +
// users) is whitelisted while base_id (base + base-id) is not, so base_id's
// unreviewed emittable member base-token must still fail generation.
func TestReduceLeafParamAliasesCoOccurrencePerConcept(t *testing.T) {
real := realMap(
realFlag{name: "user"}, realFlag{name: "users"},
realFlag{name: "base"}, realFlag{name: "base-id"},
)
_, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}})
if len(problems) == 0 {
t.Fatal("an unreviewed second co-occurring concept must fail even when another concept is whitelisted")
}
found := false
for _, p := range problems {
if strings.Contains(p, `concept "base_id"`) {
found = true
}
}
if !found {
t.Fatalf("expected a problem naming the unreviewed base_id concept, got: %v", problems)
}
}
// TestReduceLeafParamAliasesCoOccurrenceBothReviewed confirms the per-concept
// guard passes once every co-occurring concept's emittable members are listed.
func TestReduceLeafParamAliasesCoOccurrenceBothReviewed(t *testing.T) {
real := realMap(
realFlag{name: "user"}, realFlag{name: "users"},
realFlag{name: "base"}, realFlag{name: "base-id"},
)
_, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid", "base-token"}})
if len(problems) != 0 {
t.Fatalf("both concepts reviewed should pass: %v", problems)
}
}
// TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap locks the guard that a
// single name cannot be both auto-reduced and marked ambiguous. Here only
// --users is real, so user_id auto-reduces user-id to users; hand-listing
// user-id as ambiguous would produce a self-contradictory entry.
func TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "users"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id"}})
if len(problems) == 0 {
t.Fatal("a name that both auto-reduces and is listed ambiguous must fail")
}
}
func TestReduceLeafParamAliasesAbsorbsHiddenLegacyAlias(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "base-id"}, realFlag{name: "base", hidden: true}), conceptFixture(), CommandOverride{})
if len(problems) != 0 {
t.Fatalf("a hidden legacy alias flag must not be a co-occurrence: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if entry.Aliases["base-token"] != "base-id" {
t.Fatalf("base-token = %q, want base-id", entry.Aliases["base-token"])
}
if _, ok := entry.Aliases["base"]; ok {
t.Fatal("a real (hidden) flag must never be an alias key")
}
}
func TestReduceLeafParamAliasesBindGenericFlag(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"id": "base_id"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry.Aliases["base"] != "id" || entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
t.Fatalf("bind reduction wrong: %#v", entry.Aliases)
}
}
func TestReduceLeafParamAliasesBindRejectsNonRealFlag(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"missing": "base_id"}})
if len(problems) == 0 {
t.Fatal("binding a non-real flag must fail")
}
}
func TestReduceLeafParamAliasesScopedAlias(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"ding-id": "id"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil || entry.Aliases[cmdutil.Morph("ding-id")] != "id" {
t.Fatalf("scoped alias not applied: %#v", entry)
}
}
func TestReduceLeafParamAliasesScopedAliasRejectsNonRealTarget(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"foo": "nonexistent"}})
if len(problems) == 0 {
t.Fatal("a scoped alias onto a non-real flag must fail")
}
}
func TestReduceLeafParamAliasesBlockRemovesAndRecords(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Block: []string{"size"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if _, ok := entry.Aliases["size"]; ok {
t.Fatal("a blocked emitted name must be removed from the alias map")
}
found := false
for _, b := range entry.Blocked {
if b == "size" {
found = true
}
}
if !found {
t.Fatalf("size not recorded in blocked: %v", entry.Blocked)
}
}
func TestReduceLeafParamAliasesPendingReviewDoesNotEmit(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "query"}), nil,
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"keyword": "query"}, Confirm: true})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil || entry.Aliases["keyword"] != "" || !containsParamAlias(entry.Blocked, "keyword") {
t.Fatalf("pending mapping entered automatic aliases: %#v", entry)
}
}
func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcept(t *testing.T) {
concepts := []Concept{
{ID: "page_number", Members: []string{"page", "page-no"}, Excludes: []string{"page-size"}},
{ID: "page_size", Members: []string{"limit", "page-size"}},
}
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "page"}, realFlag{name: "limit"}), concepts, CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry.Aliases["page-size"] != "limit" || containsParamAlias(entry.Blocked, "page-size") {
t.Fatalf("another reviewed concept alias was overridden by an exclude: %#v", entry)
}
}
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
for name, override := range map[string]CommandOverride{
"block": {CommandPath: "demo cmd", Block: []string{"user-id"}},
"ambiguous": {CommandPath: "demo cmd", Ambiguous: []string{"user-id"}},
"scoped": {CommandPath: "demo cmd", ScopedAliases: map[string]string{"user-id": "user"}},
} {
t.Run(name, func(t *testing.T) {
if _, problems := reduceLeafParamAliases("demo cmd", real, nil, override); len(problems) == 0 {
t.Fatal("real native flag was allowed to be reclassified")
}
})
}
}
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
// at the Go level, complementing the byte-identity drift gate.
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
if len(generatedParamAliases) == 0 {
t.Fatal("generated parameter alias table is empty")
}
seen := make(map[string]bool, len(generatedParamAliases))
for _, e := range generatedParamAliases {
if e.CLIPath == "" {
t.Fatal("generated entry has an empty CLIPath")
}
if seen[e.CLIPath] {
t.Fatalf("duplicate CLIPath %q in generated table", e.CLIPath)
}
seen[e.CLIPath] = true
for emitted, canon := range e.Aliases {
if emitted != cmdutil.Morph(emitted) {
t.Fatalf("%s: alias key %q is not morph-normalized", e.CLIPath, emitted)
}
if canon == "" {
t.Fatalf("%s: alias %q has an empty target", e.CLIPath, emitted)
}
if emitted == canon {
t.Fatalf("%s: alias %q maps to itself", e.CLIPath, emitted)
}
}
classified := make(map[string]string, len(e.Aliases)+len(e.Blocked)+len(e.Ambiguous))
for emitted := range e.Aliases {
classified[emitted] = "alias"
}
for kind, values := range map[string][]string{"blocked": e.Blocked, "ambiguous": e.Ambiguous} {
for _, name := range values {
if name != cmdutil.Morph(name) {
t.Fatalf("%s: %s name %q is not morph-normalized", e.CLIPath, kind, name)
}
if previous := classified[name]; previous != "" {
t.Fatalf("%s: %q is classified as both %s and %s", e.CLIPath, name, previous, kind)
}
classified[name] = kind
}
}
}
}
+488
View File
@@ -0,0 +1,488 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
"sync"
)
const paramConceptsSchemaRef = "./param_concepts.schema.json"
// param_concepts.json is the reviewed, typed parameter concept dictionary and
// the sole source of equivalent flag spellings ("concepts") plus per-command
// overrides. Build-time generators reduce these concepts against each command's
// real Cobra flags; generated alias tables are downstream views and must never
// be read back here.
//go:embed param_concepts.json
var embeddedParamConceptsJSON []byte
//go:embed param_concepts.schema.json
var embeddedParamConceptsSchemaJSON []byte
var (
paramConceptIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
paramFlagTokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
paramCommandPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*$`)
)
// didYouMean sentinels are the only non-flag values a fixture case may expect.
const (
paramDidYouMeanAmbiguous = "did-you-mean:ambiguous"
paramDidYouMeanBlocked = "did-you-mean:blocked"
)
type paramConceptsSnapshot struct {
Schema string `json:"$schema"`
Version int `json:"version"`
MorphRules map[string]ParamMorphRule `json:"morphological_rules,omitempty"`
Concepts map[string]paramConceptSpec `json:"concepts"`
Overrides map[string]paramCommandOverride `json:"command_overrides,omitempty"`
Fixture *paramValidationFixtureSpec `json:"validation_fixture,omitempty"`
}
type paramConceptSpec struct {
Denotes string `json:"denotes"`
CanonicalHint string `json:"canonical_hint"`
Members []string `json:"members"`
Excludes []string `json:"excludes,omitempty"`
Commands []string `json:"commands"`
Risk string `json:"risk"`
}
type paramCommandOverride struct {
Bind map[string]string `json:"bind,omitempty"`
ScopedAliases map[string]string `json:"scoped_aliases,omitempty"`
Block []string `json:"block,omitempty"`
Ambiguous []string `json:"ambiguous,omitempty"`
Confirm bool `json:"confirm,omitempty"`
ScopeStrict bool `json:"scope_strict,omitempty"`
Investigate bool `json:"investigate,omitempty"`
Note string `json:"note,omitempty"`
}
type paramValidationFixtureSpec struct {
Cases []paramFixtureCaseSpec `json:"cases"`
}
type paramFixtureCaseSpec struct {
Command string `json:"command"`
Emitted string `json:"emitted"`
Expect string `json:"expect"`
Via string `json:"via,omitempty"`
Occ int `json:"occ,omitempty"`
}
// ParamMorphRule documents one table-free name normalization behavior. It is
// evidence for the shared Morph function; it is not a per-command alias.
type ParamMorphRule struct {
Desc string `json:"desc"`
Enabled bool `json:"enabled"`
Guard string `json:"guard,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Concept is one reviewed set of equivalent flag spellings that all denote a
// single entity. Members reduce onto the command's real flag; Excludes lists
// spellings that denote a different entity and must never be reduced in.
type Concept struct {
ID string
Denotes string
CanonicalHint string
Members []string
Excludes []string
Commands []string
Risk string
}
// CommandOverride is one reviewed per-command adjustment: binding a generic
// real flag to a concept, command-scoped aliases, blocks, and the reviewed
// co-occurrence whitelist.
type CommandOverride struct {
CommandPath string
Bind map[string]string
ScopedAliases map[string]string
Block []string
Ambiguous []string
Confirm bool
ScopeStrict bool
Investigate bool
Note string
}
// ParamFixtureCase is one reviewed regression assertion derived from evaluation
// bad cases: the emitted name on Command must reduce to Expect (a real flag) or
// route to a did-you-mean sentinel.
type ParamFixtureCase struct {
Command string
Emitted string
Expect string
Via string
Occ int
}
// ParamConcepts is the decoded, validated reviewed concept dictionary.
type ParamConcepts struct {
Version int
Morph map[string]ParamMorphRule
Concepts []Concept
ByConcept map[string]Concept
Overrides []CommandOverride
Fixture []ParamFixtureCase
}
var (
embeddedParamConceptsOnce sync.Once
embeddedParamConceptsData ParamConcepts
embeddedParamConceptsErr error
loadReviewedParamConcepts = loadEmbeddedParamConcepts
)
// LoadParamConcepts decodes and validates the embedded reviewed concept
// dictionary exactly once.
func LoadParamConcepts() (ParamConcepts, error) {
return loadReviewedParamConcepts()
}
func loadEmbeddedParamConcepts() (ParamConcepts, error) {
embeddedParamConceptsOnce.Do(func() {
embeddedParamConceptsData, embeddedParamConceptsErr = decodeParamConcepts(embeddedParamConceptsJSON)
})
return cloneParamConcepts(embeddedParamConceptsData), embeddedParamConceptsErr
}
func decodeParamConcepts(data []byte) (ParamConcepts, error) {
var snapshot paramConceptsSnapshot
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("multiple JSON values")
}
return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err)
}
if snapshot.Version != 1 {
return ParamConcepts{}, fmt.Errorf("unsupported parameter concepts version %d", snapshot.Version)
}
if strings.TrimSpace(snapshot.Schema) != paramConceptsSchemaRef {
return ParamConcepts{}, fmt.Errorf("parameter concepts must declare $schema=%q", paramConceptsSchemaRef)
}
if len(snapshot.Concepts) == 0 {
return ParamConcepts{}, fmt.Errorf("parameter concepts declares no concepts")
}
concepts, byConcept, err := decodeParamConceptSpecs(snapshot.Concepts)
if err != nil {
return ParamConcepts{}, err
}
overrides, err := decodeParamCommandOverrides(snapshot.Overrides, byConcept)
if err != nil {
return ParamConcepts{}, err
}
fixture, err := decodeParamFixtureCases(snapshot.Fixture)
if err != nil {
return ParamConcepts{}, err
}
morph := make(map[string]ParamMorphRule, len(snapshot.MorphRules))
for name, rule := range snapshot.MorphRules {
if strings.TrimSpace(rule.Desc) == "" {
return ParamConcepts{}, fmt.Errorf("parameter concepts morph rule %q has empty desc", name)
}
morph[name] = rule
}
return ParamConcepts{
Version: snapshot.Version,
Morph: morph,
Concepts: concepts,
ByConcept: byConcept,
Overrides: overrides,
Fixture: fixture,
}, nil
}
// decodeParamConceptSpecs validates every concept and enforces two purity
// invariants: members are unique across all concepts, and no member appears in
// its own excludes list.
func decodeParamConceptSpecs(specs map[string]paramConceptSpec) ([]Concept, map[string]Concept, error) {
ids := make([]string, 0, len(specs))
for id := range specs {
ids = append(ids, id)
}
sort.Strings(ids)
concepts := make([]Concept, 0, len(ids))
byConcept := make(map[string]Concept, len(ids))
memberOwner := make(map[string]string)
for _, id := range ids {
if !paramConceptIDPattern.MatchString(id) {
return nil, nil, fmt.Errorf("parameter concepts contains invalid concept id %q", id)
}
spec := specs[id]
if strings.TrimSpace(spec.Denotes) == "" {
return nil, nil, fmt.Errorf("concept %s has empty denotes", id)
}
if !paramFlagTokenPattern.MatchString(spec.CanonicalHint) {
return nil, nil, fmt.Errorf("concept %s has invalid canonical_hint %q", id, spec.CanonicalHint)
}
switch spec.Risk {
case "green", "yellow":
default:
return nil, nil, fmt.Errorf("concept %s has invalid risk %q", id, spec.Risk)
}
if len(spec.Members) == 0 {
return nil, nil, fmt.Errorf("concept %s has no members", id)
}
if len(spec.Commands) == 0 {
return nil, nil, fmt.Errorf("concept %s has no reviewed command scope", id)
}
members := make([]string, 0, len(spec.Members))
memberSet := make(map[string]bool, len(spec.Members))
for _, member := range spec.Members {
if !paramFlagTokenPattern.MatchString(member) {
return nil, nil, fmt.Errorf("concept %s has invalid member %q", id, member)
}
if memberSet[member] {
return nil, nil, fmt.Errorf("concept %s repeats member %q", id, member)
}
memberSet[member] = true
if owner, exists := memberOwner[member]; exists {
return nil, nil, fmt.Errorf("member %q belongs to both concept %s and %s", member, owner, id)
}
memberOwner[member] = id
members = append(members, member)
}
excludes := make([]string, 0, len(spec.Excludes))
excludeSet := make(map[string]bool, len(spec.Excludes))
for _, exclude := range spec.Excludes {
if !paramFlagTokenPattern.MatchString(exclude) {
return nil, nil, fmt.Errorf("concept %s has invalid exclude %q", id, exclude)
}
if excludeSet[exclude] {
return nil, nil, fmt.Errorf("concept %s repeats exclude %q", id, exclude)
}
excludeSet[exclude] = true
if memberSet[exclude] {
return nil, nil, fmt.Errorf("concept %s lists %q as both member and exclude", id, exclude)
}
excludes = append(excludes, exclude)
}
commands := make([]string, 0, len(spec.Commands))
commandSet := make(map[string]bool, len(spec.Commands))
for _, command := range spec.Commands {
if !validParamCommandPath(command) {
return nil, nil, fmt.Errorf("concept %s has invalid command scope %q", id, command)
}
if commandSet[command] {
return nil, nil, fmt.Errorf("concept %s repeats command scope %q", id, command)
}
commandSet[command] = true
commands = append(commands, command)
}
sort.Strings(commands)
concept := Concept{
ID: id,
Denotes: strings.TrimSpace(spec.Denotes),
CanonicalHint: spec.CanonicalHint,
Members: members,
Excludes: excludes,
Commands: commands,
Risk: spec.Risk,
}
concepts = append(concepts, concept)
byConcept[id] = concept
}
return concepts, byConcept, nil
}
func decodeParamCommandOverrides(specs map[string]paramCommandOverride, byConcept map[string]Concept) ([]CommandOverride, error) {
paths := make([]string, 0, len(specs))
for path := range specs {
paths = append(paths, path)
}
sort.Strings(paths)
overrides := make([]CommandOverride, 0, len(paths))
for _, path := range paths {
if !validParamCommandPath(path) {
return nil, fmt.Errorf("command_overrides contains invalid command path %q", path)
}
spec := specs[path]
if len(spec.Bind) == 0 && len(spec.ScopedAliases) == 0 && len(spec.Block) == 0 && len(spec.Ambiguous) == 0 {
return nil, fmt.Errorf("command_override %q declares no bind/scoped_aliases/block/ambiguous", path)
}
for flag, conceptID := range spec.Bind {
if !paramFlagTokenPattern.MatchString(flag) {
return nil, fmt.Errorf("command_override %q bind has invalid flag %q", path, flag)
}
if _, ok := byConcept[conceptID]; !ok {
return nil, fmt.Errorf("command_override %q binds %q to undeclared concept %q", path, flag, conceptID)
}
}
for emitted, realFlag := range spec.ScopedAliases {
if !paramFlagTokenPattern.MatchString(emitted) {
return nil, fmt.Errorf("command_override %q scoped_aliases has invalid emitted %q", path, emitted)
}
if !paramFlagTokenPattern.MatchString(realFlag) {
return nil, fmt.Errorf("command_override %q scoped_aliases has invalid target %q", path, realFlag)
}
}
if err := validParamTokenList(path, "block", spec.Block); err != nil {
return nil, err
}
if err := validParamTokenList(path, "ambiguous", spec.Ambiguous); err != nil {
return nil, err
}
overrides = append(overrides, CommandOverride{
CommandPath: path,
Bind: cloneStringMap(spec.Bind),
ScopedAliases: cloneStringMap(spec.ScopedAliases),
Block: append([]string(nil), spec.Block...),
Ambiguous: append([]string(nil), spec.Ambiguous...),
Confirm: spec.Confirm,
ScopeStrict: spec.ScopeStrict,
Investigate: spec.Investigate,
Note: strings.TrimSpace(spec.Note),
})
}
return overrides, nil
}
func decodeParamFixtureCases(spec *paramValidationFixtureSpec) ([]ParamFixtureCase, error) {
if spec == nil {
return nil, nil
}
if len(spec.Cases) == 0 {
return nil, fmt.Errorf("validation_fixture declares no cases")
}
cases := make([]ParamFixtureCase, 0, len(spec.Cases))
for i, c := range spec.Cases {
if !validParamCommandPath(c.Command) {
return nil, fmt.Errorf("validation_fixture case %d has invalid command %q", i, c.Command)
}
if !paramFlagTokenPattern.MatchString(c.Emitted) {
return nil, fmt.Errorf("validation_fixture case %d has invalid emitted %q", i, c.Emitted)
}
expect := strings.TrimSpace(c.Expect)
if expect == "" {
return nil, fmt.Errorf("validation_fixture case %d has empty expect", i)
}
if strings.HasPrefix(expect, "did-you-mean:") {
if expect != paramDidYouMeanAmbiguous && expect != paramDidYouMeanBlocked {
return nil, fmt.Errorf("validation_fixture case %d has unknown did-you-mean sentinel %q", i, expect)
}
} else if !paramFlagTokenPattern.MatchString(expect) {
return nil, fmt.Errorf("validation_fixture case %d has invalid expect %q", i, expect)
}
if c.Occ < 0 {
return nil, fmt.Errorf("validation_fixture case %d has negative occ %d", i, c.Occ)
}
cases = append(cases, ParamFixtureCase{
Command: c.Command,
Emitted: c.Emitted,
Expect: expect,
Via: strings.TrimSpace(c.Via),
Occ: c.Occ,
})
}
return cases, nil
}
func validParamCommandPath(path string) bool {
if strings.TrimSpace(path) != path || path == "" {
return false
}
for _, token := range strings.Split(path, " ") {
if !paramCommandPathPattern.MatchString(token) {
return false
}
}
return true
}
func validParamTokenList(path, field string, tokens []string) error {
seen := make(map[string]bool, len(tokens))
for _, token := range tokens {
if !paramFlagTokenPattern.MatchString(token) {
return fmt.Errorf("command_override %q %s has invalid token %q", path, field, token)
}
if seen[token] {
return fmt.Errorf("command_override %q %s repeats token %q", path, field, token)
}
seen[token] = true
}
return nil
}
func cloneParamConcepts(src ParamConcepts) ParamConcepts {
dst := ParamConcepts{Version: src.Version}
if src.Morph != nil {
dst.Morph = make(map[string]ParamMorphRule, len(src.Morph))
for k, v := range src.Morph {
dst.Morph[k] = v
}
}
if src.Concepts != nil {
dst.Concepts = make([]Concept, 0, len(src.Concepts))
for _, c := range src.Concepts {
dst.Concepts = append(dst.Concepts, cloneConcept(c))
}
}
if src.ByConcept != nil {
dst.ByConcept = make(map[string]Concept, len(src.ByConcept))
for k, v := range src.ByConcept {
dst.ByConcept[k] = cloneConcept(v)
}
}
if src.Overrides != nil {
dst.Overrides = make([]CommandOverride, 0, len(src.Overrides))
for _, o := range src.Overrides {
dst.Overrides = append(dst.Overrides, cloneCommandOverride(o))
}
}
if src.Fixture != nil {
dst.Fixture = append([]ParamFixtureCase(nil), src.Fixture...)
}
return dst
}
func cloneConcept(c Concept) Concept {
c.Members = append([]string(nil), c.Members...)
c.Excludes = append([]string(nil), c.Excludes...)
c.Commands = append([]string(nil), c.Commands...)
return c
}
func cloneCommandOverride(o CommandOverride) CommandOverride {
o.Bind = cloneStringMap(o.Bind)
o.ScopedAliases = cloneStringMap(o.ScopedAliases)
o.Block = append([]string(nil), o.Block...)
o.Ambiguous = append([]string(nil), o.Ambiguous...)
return o
}
func cloneStringMap(src map[string]string) map[string]string {
if src == nil {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
+261
View File
@@ -0,0 +1,261 @@
{
"$schema": "./param_concepts.schema.json",
"version": 1,
"morphological_rules": {
"kebab_camel_equivalence": {"desc": "--page-size == --pageSize", "enabled": true},
"separator_normalization": {"desc": "-, _, . are equivalent separators", "enabled": true},
"trailing_id_tolerance": {"desc": "--base tolerates --base-id when only one is a real flag on the command", "enabled": true, "guard": "the two must not both be real flags with different semantics"},
"pluralization": {"desc": "--id<->--ids, --user<->--users", "enabled": false, "reason": "singular/list semantics can differ; handled by concept+intersection or command override instead"}
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
},
"command_overrides": {
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
"chat 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."},
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
"mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"},
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat message list-favorites": {"scoped_aliases": {"limit": "size"}, "scope_strict": true, "note": "On this exact command, both names denote the same bounded result count; the numeric value is unchanged."},
"chat +messages-list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "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-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 +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
"chat +category-rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."},
"chat category rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."},
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category add-conv": {"block": ["category-id"], "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 +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
"chat group members remove-bot": {"bind": {"id": "open_conversation_id"}},
"chat +send-to-group": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
"chat group share-invite": {"scoped_aliases": {"source-conversation-id": "source", "target-conversation-id": "target"}, "block": ["group-id", "group-ids", "user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."},
"chat message combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."},
"chat message forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."},
"chat message forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Conversation and message source/destination roles are preserved; role-free identifiers are rejected."},
"chat +conversation-info": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."},
"chat group create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed identifier domains."},
"chat +chat-set-admin": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."},
"chat +messages-read-status": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."},
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
},
"validation_fixture": {
"cases": [
{"command": "oa +search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 28},
{"command": "aitable +list-tables", "emitted": "base-id", "expect": "base", "via": "concept:base_id+morph", "occ": 26},
{"command": "mail +find-mail-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 18},
{"command": "aitable +field-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
{"command": "aitable +record-query", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
{"command": "aitable +table-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
{"command": "doc block update", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 6},
{"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "concept:search_query+bind", "occ": 4},
{"command": "devdoc article search", "emitted": "limit", "expect": "size", "via": "concept:pagination_size", "occ": 2},
{"command": "devdoc article search", "emitted": "page-size", "expect": "size", "via": "concept:pagination_size", "occ": 2},
{"command": "devdoc article search", "emitted": "current-page", "expect": "page", "via": "concept:page_number", "occ": 2},
{"command": "mail message search", "emitted": "subject", "expect": "query", "via": "override:scoped_strict", "occ": 4},
{"command": "aitable +record-share-url", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 3},
{"command": "aitable record query", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "calendar event list", "emitted": "date", "expect": "start", "via": "override:scoped(reviewed+payload)", "occ": 2},
{"command": "calendar event list", "emitted": "start-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "min-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "time-min", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "end-time", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "time-max", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "max-results", "expect": "limit", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "next-cursor", "expect": "cursor", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "calendar", "expect": "calendar-id", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "chat message list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "chat message list-by-sender", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-format-boundary", "occ": 2},
{"command": "doc +template-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
{"command": "doc block insert", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 2},
{"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2},
{"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2},
{"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
{"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2},
{"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
{"command": "contact +search-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
{"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 31},
{"command": "ding +receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 24},
{"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 8},
{"command": "contact +list-sub-depts", "emitted": "dept-id", "expect": "dept", "via": "concept:dept_id+morph", "occ": 4},
{"command": "contact +list-sub-depts", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:name-vs-id", "occ": 2},
{"command": "contact +list-sub-depts", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:query-vs-id", "occ": 2},
{"command": "contact user profile get", "emitted": "user-id", "expect": "staff-id", "via": "concept:user_id", "occ": 2},
{"command": "contact user profile get", "emitted": "id", "expect": "staff-id", "via": "override:scoped", "occ": 2},
{"command": "contact user profile get", "emitted": "ids", "expect": "staff-id", "via": "override:scoped", "occ": 2},
{"command": "chat message send-by-bot", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 4},
{"command": "chat message send-by-bot", "emitted": "to-user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 1},
{"command": "dev app get", "emitted": "app-id", "expect": "unified-app-id", "via": "concept:app_id", "occ": 5},
{"command": "chat message list-all", "emitted": "from", "expect": "start", "via": "concept:time_start", "occ": 2},
{"command": "chat message list-all", "emitted": "start-time", "expect": "start", "via": "concept:time_start", "occ": 2},
{"command": "chat message search-advanced", "emitted": "group", "expect": "conversation-ids", "via": "native:reviewed-single-to-list", "occ": 4},
{"command": "chat message send", "emitted": "to-user", "expect": "user", "via": "override:scoped(reviewed+payload)", "occ": 4},
{"command": "contact +dept-members", "emitted": "name", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2},
{"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "concept:search_query+bind", "occ": 2},
{"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id", "occ": 2},
{"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "concept:dept_id", "occ": 2},
{"command": "ding message receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 2},
{"command": "ding message receiver-status", "emitted": "open-ding-id", "expect": "ding-id", "via": "concept:ding_id", "occ": 2},
{"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 3},
{"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "concept:user_ids+exclude", "occ": 2},
{"command": "attendance check result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids", "occ": 2},
{"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 2},
{"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2},
{"command": "ding message send", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code", "occ": 2},
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
{"command": "chat group get-by-group-id", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-group-id"},
{"command": "chat group get-by-group-id", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-vs-group-id"},
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
{"command": "chat message search-advanced", "emitted": "open-conversation-ids", "expect": "conversation-ids", "via": "concept:open_conversation_ids"},
{"command": "chat message search-advanced", "emitted": "group-ids", "expect": "did-you-mean:blocked", "via": "guard:group-id-list-vs-open-conversation-id-list"},
{"command": "chat message recall", "emitted": "message-id", "expect": "msg-id", "via": "concept:open_message_id"},
{"command": "chat message add-favorite", "emitted": "msg-id", "expect": "open-message-id", "via": "concept:open_message_id"},
{"command": "chat message list-by-ids", "emitted": "message-ids", "expect": "msg-ids", "via": "concept:open_message_ids"},
{"command": "chat message list-by-ids", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat message reply", "emitted": "ref-message-id", "expect": "ref-msg-id", "via": "concept:referenced_open_message_id"},
{"command": "chat message reply", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"},
{"command": "chat message forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-role"},
{"command": "chat message forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"},
{"command": "chat message combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-role"},
{"command": "chat message forward-topic", "emitted": "dest-open-conversation-id", "expect": "dest-conversation-id", "via": "override:scoped-role"},
{"command": "chat message forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat group share-invite", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-target-role"},
{"command": "chat message send", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
{"command": "chat message list", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
{"command": "chat +messages-list-direct", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
{"command": "attendance +check-result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"},
{"command": "contact +list-sub-depts", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id"},
{"command": "chat +conversation-info", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-vs-open-dingtalk-id"},
{"command": "chat group members remove", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"},
{"command": "chat group members remove", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat group create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-set-admin", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +messages-read-status", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat group members list-by-ids", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
{"command": "chat group members remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
{"command": "chat group members remove-bot", "emitted": "robot-code", "expect": "did-you-mean:blocked", "via": "guard:robot-code-vs-open-bot-id"},
{"command": "chat group members add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
{"command": "chat +bot-find", "emitted": "name", "expect": "query", "via": "override:scoped"},
{"command": "chat bot find", "emitted": "name", "expect": "query", "via": "override:scoped"},
{"command": "chat +bot-search", "emitted": "query", "expect": "name", "via": "override:scoped"},
{"command": "chat bot search", "emitted": "query", "expect": "name", "via": "override:scoped"},
{"command": "chat message list-favorites", "emitted": "limit", "expect": "size", "via": "override:scoped"},
{"command": "chat bot search", "emitted": "current-page", "expect": "page", "via": "override:scoped"},
{"command": "chat bot search", "emitted": "cursor", "expect": "did-you-mean:blocked", "via": "guard:page-number-vs-cursor"},
{"command": "chat message list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +messages-list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat message list", "emitted": "start", "expect": "time", "via": "override:scoped"},
{"command": "chat message list", "emitted": "end", "expect": "did-you-mean:blocked", "via": "guard:single-time-vs-range"},
{"command": "chat message list-all", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-range-required"},
{"command": "chat message list-by-sender", "emitted": "user-id", "expect": "sender-user-id", "via": "override:scoped-role"},
{"command": "chat message list-by-sender", "emitted": "open-dingtalk-id", "expect": "sender-open-dingtalk-id", "via": "override:scoped-role"},
{"command": "chat category create-smart", "emitted": "title", "expect": "name", "via": "override:scoped"},
{"command": "chat category create", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message send", "emitted": "file", "expect": "file-path", "via": "override:scoped"},
{"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
{"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"},
{"command": "chat message send-by-bot", "emitted": "at-ids", "expect": "did-you-mean:ambiguous", "via": "guard:user-id-vs-open-dingtalk-id"},
{"command": "chat +bot-search", "emitted": "current-page", "expect": "page", "via": "override:scoped"},
{"command": "chat +category-create", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat +category-rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat +messages-list-direct", "emitted": "start", "expect": "time", "via": "override:scoped"},
{"command": "chat +messages-list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +messages-send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
]
}
}
+269
View File
@@ -0,0 +1,269 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/param_concepts.schema.json",
"title": "DWS Reviewed Parameter Concept Dictionary",
"description": "Human-reviewed source of parameter concepts (equivalent flag spellings for the same entity) and per-command overrides. Build-time generators reduce these concepts against each command's real Cobra flags; they must never rewrite this file.",
"type": "object",
"additionalProperties": false,
"required": [
"$schema",
"version",
"concepts"
],
"properties": {
"$schema": {
"const": "./param_concepts.schema.json",
"description": "Relative editor contract. Keep this value unchanged so agents can validate the dictionary without network access."
},
"version": {
"const": 1,
"description": "ParamConcepts source format version."
},
"morphological_rules": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/morphRule"
},
"description": "Table-free, global name normalization behaviors realized by pflag SetNormalizeFunc. Documentation of morph behavior, not per-command aliases."
},
"concepts": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/conceptId"
},
"additionalProperties": {
"$ref": "#/$defs/concept"
},
"description": "Global concepts keyed by stable concept id. Go validation additionally rejects members that overlap across concepts and members that intersect their own excludes."
},
"command_overrides": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/commandPath"
},
"additionalProperties": {
"$ref": "#/$defs/commandOverride"
},
"description": "Per-command overrides keyed by the command path (without leading 'dws'). Only needed for generic-name binding, command-scoped aliases, co-occurrence whitelisting, or reject."
},
"validation_fixture": {
"$ref": "#/$defs/validationFixture"
}
},
"$defs": {
"conceptId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Stable concept id in lower snake_case."
},
"flagToken": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
"description": "A flag spelling token without leading dashes."
},
"commandPath": {
"type": "string",
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
"description": "Exact command path without the leading 'dws' or flags. A leading '+' segment marks a shortcut subcommand. Existence against the real Cobra tree is validated by the generator, not this schema."
},
"risk": {
"type": "string",
"enum": [
"green",
"yellow"
],
"description": "Reviewed risk band. green concepts reduce freely; yellow concepts need extra reviewer attention on excludes boundaries."
},
"morphRule": {
"type": "object",
"additionalProperties": false,
"required": [
"desc",
"enabled"
],
"properties": {
"desc": {
"type": "string",
"minLength": 1
},
"enabled": {
"type": "boolean"
},
"guard": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
}
},
"concept": {
"type": "object",
"additionalProperties": false,
"required": [
"denotes",
"canonical_hint",
"members",
"commands",
"risk"
],
"properties": {
"denotes": {
"type": "string",
"minLength": 1,
"description": "Human description of the single entity this concept denotes."
},
"canonical_hint": {
"$ref": "#/$defs/flagToken",
"description": "Governance hint only; the runtime reduces to whichever real flag the command exposes, not to this value."
},
"members": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Equivalent spellings that all denote the concept's entity."
},
"excludes": {
"type": "array",
"default": [],
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Spellings that denote a DIFFERENT entity and must never be reduced into this concept."
},
"commands": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/commandPath"
},
"description": "Exact reviewed runnable command paths on which this concept may participate in reduction. A concept never activates on an unlisted command merely because a real flag has the same spelling."
},
"risk": {
"$ref": "#/$defs/risk"
}
}
},
"commandOverride": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"bind": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/flagToken"
},
"additionalProperties": {
"$ref": "#/$defs/conceptId"
},
"description": "Maps a generic real flag (e.g. id) to a concept id so concept members can reduce onto it. Go validation requires every value to be a declared concept id."
},
"scoped_aliases": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/flagToken"
},
"additionalProperties": {
"$ref": "#/$defs/flagToken"
},
"description": "Command-scoped emitted->realFlag aliases. Never promoted to a global concept member."
},
"block": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Emitted names that must never be reduced on this command; they route to did-you-mean instead."
},
"ambiguous": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Reviewed co-occurrence whitelist: when a concept intersects two or more real flags on this command, the emitted names listed here are expected to route to did-you-mean rather than fail generation."
},
"confirm": {
"type": "boolean",
"description": "Reviewer flagged this override as needing user confirmation of the mapping semantics."
},
"scope_strict": {
"type": "boolean",
"description": "This alias must stay strictly command-local; another command has a real and different flag with the same spelling."
},
"investigate": {
"type": "boolean",
"description": "Reviewer flagged this override as needing source-case investigation before landing."
},
"note": {
"type": "string",
"minLength": 1,
"description": "Reviewer note explaining the override."
}
}
},
"validationFixture": {
"type": "object",
"additionalProperties": false,
"required": [
"cases"
],
"properties": {
"cases": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/fixtureCase"
}
}
}
},
"fixtureCase": {
"type": "object",
"additionalProperties": false,
"required": [
"command",
"emitted",
"expect"
],
"properties": {
"command": {
"$ref": "#/$defs/commandPath"
},
"emitted": {
"$ref": "#/$defs/flagToken",
"description": "The name the model produced."
},
"expect": {
"type": "string",
"minLength": 1,
"description": "Either the real flag the emitted name must reduce to, or one of the did-you-mean sentinels 'did-you-mean:ambiguous' / 'did-you-mean:blocked'."
},
"via": {
"type": "string",
"minLength": 1,
"description": "Reviewer annotation of the reduction path; documentation only."
},
"occ": {
"type": "integer",
"minimum": 0,
"description": "Occurrence count in the evaluation batch; frequency evidence only."
}
}
}
}
}
@@ -0,0 +1,312 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import (
"encoding/json"
"strings"
"testing"
)
func TestParamConceptsJSONSchemaDocumentsClosedShape(t *testing.T) {
var schema map[string]any
if err := json.Unmarshal(embeddedParamConceptsSchemaJSON, &schema); err != nil {
t.Fatalf("decode param_concepts.schema.json: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
t.Fatalf("param concepts root schema is not closed: %#v", schema)
}
definitions := schema["$defs"].(map[string]any)
concept := definitions["concept"].(map[string]any)
if concept["additionalProperties"] != false {
t.Fatalf("concept schema allows unknown fields: %#v", concept)
}
properties := concept["properties"].(map[string]any)
for _, field := range []string{"denotes", "canonical_hint", "members", "excludes", "commands", "risk"} {
if _, ok := properties[field]; !ok {
t.Fatalf("concept schema is missing %s", field)
}
}
override := definitions["commandOverride"].(map[string]any)
if override["additionalProperties"] != false {
t.Fatalf("commandOverride schema allows unknown fields: %#v", override)
}
var source map[string]any
if err := json.Unmarshal(embeddedParamConceptsJSON, &source); err != nil {
t.Fatalf("decode param_concepts.json: %v", err)
}
if source["$schema"] != paramConceptsSchemaRef {
t.Fatalf("param concepts source $schema = %#v, want %q", source["$schema"], paramConceptsSchemaRef)
}
}
func TestEmbeddedParamConceptsLoadsAndSatisfiesInvariants(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if len(concepts.Concepts) == 0 {
t.Fatal("embedded param concepts declares no concepts")
}
// Members must be globally unique and disjoint from their own excludes.
memberOwner := make(map[string]string)
for _, concept := range concepts.Concepts {
if len(concept.Commands) == 0 {
t.Fatalf("concept %s has no reviewed command scope", concept.ID)
}
excludeSet := make(map[string]bool, len(concept.Excludes))
for _, exclude := range concept.Excludes {
excludeSet[exclude] = true
}
for _, member := range concept.Members {
if owner, exists := memberOwner[member]; exists {
t.Fatalf("member %q belongs to both concept %s and %s", member, owner, concept.ID)
}
memberOwner[member] = concept.ID
if excludeSet[member] {
t.Fatalf("concept %s lists %q as both member and exclude", concept.ID, member)
}
}
}
// Every bind target must reference a declared concept.
for _, override := range concepts.Overrides {
if override.Confirm || override.Investigate {
t.Fatalf("current override %q remains unresolved (confirm=%v investigate=%v)", override.CommandPath, override.Confirm, override.Investigate)
}
for flag, conceptID := range override.Bind {
if _, ok := concepts.ByConcept[conceptID]; !ok {
t.Fatalf("command_override %q binds %q to undeclared concept %q", override.CommandPath, flag, conceptID)
}
}
}
// Fixture sentinels are limited to the two known did-you-mean forms.
for _, c := range concepts.Fixture {
if strings.HasPrefix(c.Expect, "did-you-mean:") &&
c.Expect != paramDidYouMeanAmbiguous && c.Expect != paramDidYouMeanBlocked {
t.Fatalf("fixture %q/%q has unknown sentinel %q", c.Command, c.Emitted, c.Expect)
}
}
}
func TestParamConceptRiskAuditBoundaries(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
assertMembers := func(id string, forbidden ...string) {
t.Helper()
concept, ok := concepts.ByConcept[id]
if !ok {
t.Fatalf("missing audited concept %q", id)
}
members := make(map[string]bool, len(concept.Members))
for _, member := range concept.Members {
members[member] = true
}
for _, name := range forbidden {
if members[name] {
t.Fatalf("audited concept %s still contains forbidden cross-semantics member %q", id, name)
}
}
}
assertMembers("user_id", "users", "user-ids")
assertMembers("user_ids", "user", "user-id")
assertMembers("dept_id", "depts", "dept-ids")
assertMembers("dept_ids", "dept", "dept-id")
assertMembers("group_id", "conversation-ids", "group-ids")
assertMembers("page_number", "page-index")
assertMembers("robot_code", "robot-id")
}
func TestDecodeParamConceptsRejectsUnknownFieldsAtEveryLevel(t *testing.T) {
valid := `{"$schema":"./param_concepts.schema.json","version":1,` +
`"concepts":{"search_query":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo cmd"],"risk":"green"}},` +
`"command_overrides":{"chat group rename":{"bind":{"id":"search_query"}}}}`
for name, input := range map[string]string{
"root": strings.Replace(valid, `"version":1`, `"version":1,"unknown":true`, 1),
"concept": strings.Replace(valid, `"risk":"green"`, `"risk":"green","unknown":true`, 1),
"override": strings.Replace(valid, `"bind":{"id":"search_query"}`, `"bind":{"id":"search_query"},"unknown":true`, 1),
} {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamConcepts([]byte(input)); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("decodeParamConcepts() error = %v, want unknown field", err)
}
})
}
}
func TestDecodeParamConceptsEnforcesReviewedConstraints(t *testing.T) {
wrap := func(body string) string {
return `{"$schema":"./param_concepts.schema.json","version":1,` + body + `}`
}
concept := func(members, excludes, risk string) string {
return `"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":` + members + `,"excludes":` + excludes + `,"commands":["demo cmd"],"risk":"` + risk + `"}}`
}
tests := map[string]string{
"missing schema ref": `{"version":1,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`,
"wrong version": `{"$schema":"./param_concepts.schema.json","version":2,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`,
"no concepts": wrap(`"concepts":{}`),
"invalid concept id": wrap(`"concepts":{"BadID":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`),
"empty denotes": wrap(`"concepts":{"c_one":{"denotes":"","canonical_hint":"query","members":["query"],"risk":"green"}}`),
"invalid risk": wrap(concept(`["query"]`, `[]`, "red")),
"no members": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":[],"risk":"green"}}`),
"no command scope": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":[],"risk":"green"}}`),
"member equals exclude": wrap(concept(`["query"]`, `["query"]`, "green")),
"member overlaps concepts": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"},"c_two":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`),
"bind undeclared concept": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{"bind":{"id":"missing"}}}`),
"empty override": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{}}`),
"bad fixture sentinel": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[{"command":"chat group rename","emitted":"group","expect":"did-you-mean:oops"}]}`),
"empty fixture cases": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[]}`),
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamConcepts([]byte(input)); err == nil {
t.Fatal("decodeParamConcepts() unexpectedly accepted invalid reviewed source")
}
})
}
got, err := decodeParamConcepts([]byte(wrap(concept(`["query","keyword"]`, `["name"]`, "green"))))
if err != nil {
t.Fatalf("decodeParamConcepts() valid source error = %v", err)
}
if len(got.Concepts) != 1 || got.Concepts[0].ID != "c_one" {
t.Fatalf("decodeParamConcepts() concepts = %#v", got.Concepts)
}
}
func validParamConceptSpecFixture() paramConceptSpec {
return paramConceptSpec{
Denotes: "a reviewed value",
CanonicalHint: "query",
Members: []string{"query"},
Excludes: []string{"name"},
Commands: []string{"demo run"},
Risk: "green",
}
}
func TestDecodeParamConceptsRemainingSyntaxEdges(t *testing.T) {
valid := `{"$schema":"./param_concepts.schema.json","version":1,` +
`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo run"],"risk":"green"}}}`
if _, err := decodeParamConcepts([]byte(valid + ` {}`)); err == nil || !strings.Contains(err.Error(), "multiple JSON values") {
t.Fatalf("multiple JSON values error = %v", err)
}
withEmptyMorphDescription := strings.Replace(
valid,
`"concepts":`,
`"morphological_rules":{"camel":{"desc":""}},"concepts":`,
1,
)
if _, err := decodeParamConcepts([]byte(withEmptyMorphDescription)); err == nil || !strings.Contains(err.Error(), "empty desc") {
t.Fatalf("empty morph description error = %v", err)
}
}
func TestDecodeParamConceptSpecsRemainingValidationEdges(t *testing.T) {
base := validParamConceptSpecFixture()
invalidCanonical := base
invalidCanonical.CanonicalHint = "bad token"
invalidMember := base
invalidMember.Members = []string{"bad token"}
repeatedMember := base
repeatedMember.Members = []string{"query", "query"}
invalidExclude := base
invalidExclude.Excludes = []string{"bad token"}
repeatedExclude := base
repeatedExclude.Excludes = []string{"name", "name"}
invalidCommand := base
invalidCommand.Commands = []string{"demo /bad"}
repeatedCommand := base
repeatedCommand.Commands = []string{"demo run", "demo run"}
tests := map[string]map[string]paramConceptSpec{
"invalid canonical hint": {"c_one": invalidCanonical},
"invalid member": {"c_one": invalidMember},
"repeated member": {"c_one": repeatedMember},
"invalid exclude": {"c_one": invalidExclude},
"repeated exclude": {"c_one": repeatedExclude},
"invalid command": {"c_one": invalidCommand},
"repeated command": {"c_one": repeatedCommand},
"cross-concept member": {
"c_one": base,
"c_two": base,
},
}
for name, specs := range tests {
t.Run(name, func(t *testing.T) {
if _, _, err := decodeParamConceptSpecs(specs); err == nil {
t.Fatal("decodeParamConceptSpecs() unexpectedly accepted invalid input")
}
})
}
}
func TestDecodeParamCommandOverridesRemainingValidationEdges(t *testing.T) {
byConcept := map[string]Concept{"c_one": {ID: "c_one"}}
tests := map[string]map[string]paramCommandOverride{
"invalid path": {
" demo run": {Block: []string{"name"}},
},
"invalid bind flag": {
"demo run": {Bind: map[string]string{"bad token": "c_one"}},
},
"invalid scoped emitted": {
"demo run": {ScopedAliases: map[string]string{"bad token": "query"}},
},
"invalid scoped target": {
"demo run": {ScopedAliases: map[string]string{"keyword": "bad token"}},
},
"invalid block token": {
"demo run": {Block: []string{"bad token"}},
},
"repeated ambiguous token": {
"demo run": {Ambiguous: []string{"uid", "uid"}},
},
}
for name, specs := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamCommandOverrides(specs, byConcept); err == nil {
t.Fatal("decodeParamCommandOverrides() unexpectedly accepted invalid input")
}
})
}
}
func TestDecodeParamFixtureCasesRemainingValidationEdges(t *testing.T) {
tests := map[string]paramFixtureCaseSpec{
"invalid command": {Command: " demo run", Emitted: "query", Expect: "query"},
"invalid emitted": {Command: "demo run", Emitted: "bad token", Expect: "query"},
"empty expect": {Command: "demo run", Emitted: "query", Expect: " "},
"invalid expect": {Command: "demo run", Emitted: "query", Expect: "bad token"},
"negative occ": {Command: "demo run", Emitted: "query", Expect: "query", Occ: -1},
}
for name, fixture := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamFixtureCases(&paramValidationFixtureSpec{Cases: []paramFixtureCaseSpec{fixture}}); err == nil {
t.Fatal("decodeParamFixtureCases() unexpectedly accepted invalid input")
}
})
}
}
func TestParamConceptPathAndTokenValidationEdges(t *testing.T) {
if validParamCommandPath(" demo run") {
t.Fatal("command path with surrounding whitespace was accepted")
}
if validParamCommandPath("demo /bad") {
t.Fatal("command path with an invalid token was accepted")
}
if err := validParamTokenList("demo run", "block", []string{"bad token"}); err == nil {
t.Fatal("invalid parameter token was accepted")
}
if err := validParamTokenList("demo run", "block", []string{"uid", "uid"}); err == nil {
t.Fatal("repeated parameter token was accepted")
}
}
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"coverage": {
"surface_products": 26,
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_files": 160,
"hint_files": 54,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"version": 1,
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:735bbf8e04cfc44db741e9b14ffe1698f80aac5d2662d2c1c41b0aadcee9098d",
"source_hash": "sha256:ba691e70f1c232fc3378a743c3fcd34113960d30b46bffa7d1fc8b524115052c",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"surface_products": 26,
"surface_tools": 845,
+1 -1
View File
@@ -25,7 +25,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
}),
)
typed := err.(*Error)
if typed.Retryable || typed.ServerDiag.TraceID != "trace" {
if typed.Retryable || !typed.RetryableSet || typed.ServerDiag.TraceID != "trace" {
t.Fatalf("diagnostics options = %#v", typed)
}
if (ServerDiagnostics{TraceID: "trace"}).IsEmpty() {
+1
View File
@@ -44,6 +44,7 @@ func WithServerDiag(diag ServerDiagnostics) Option {
// Override retryable if server explicitly specified.
if diag.ServerRetryable != nil {
e.Retryable = *diag.ServerRetryable
e.RetryableSet = true
}
}
}
+59 -18
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
@@ -41,20 +42,23 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -107,6 +111,31 @@ func WithServerKey(serverKey string) Option {
func WithRetryable(retryable bool) Option {
return func(err *Error) {
err.Retryable = retryable
err.RetryableSet = true
}
}
// WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance.
func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) {
if seconds < 0 {
return
}
value := seconds
err.RetryAfterSeconds = &value
}
}
// WithNextRetryAt records the absolute time at which a retry may be attempted.
func WithNextRetryAt(next time.Time) Option {
return func(err *Error) {
if next.IsZero() {
return
}
value := next.UTC()
err.NextRetryAt = &value
}
}
@@ -266,8 +295,14 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Retryable {
errorPayload["retryable"] = true
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
if typed.RetryAfterSeconds != nil {
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
}
if typed.NextRetryAt != nil {
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
if typed.Hint != "" {
errorPayload["hint"] = typed.Hint
@@ -383,8 +418,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
lines = append(lines, tui.Dim(line))
}
if typed.Retryable {
lines = append(lines, tui.Warning("Retryable: true"))
if typed.RetryableSet {
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
}
if typed.RetryAfterSeconds != nil {
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
}
if typed.NextRetryAt != nil {
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
}
// Always shown when present: Trace ID, Server Code
+94
View File
@@ -17,6 +17,7 @@ import (
stderrors "errors"
"strings"
"testing"
"time"
)
func TestExitCodeByCategory(t *testing.T) {
@@ -77,6 +78,99 @@ func TestPrintJSON(t *testing.T) {
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
t.Parallel()
next := time.Date(2026, time.July, 30, 4, 5, 6, 0, time.FixedZone("CST", 8*60*60))
tests := []struct {
name string
err error
wantRetryable string
wantRetryAfter bool
wantNextRetryAt bool
}{
{
name: "unknown is omitted",
err: NewAPI("unknown"),
},
{
name: "explicit false is preserved",
err: NewValidation("terminal", WithRetryable(false)),
wantRetryable: `"retryable": false`,
},
{
name: "explicit true with timing",
err: NewAPI("transient", WithRetryable(true), WithRetryAfterSeconds(30), WithNextRetryAt(next)),
wantRetryable: `"retryable": true`,
wantRetryAfter: true,
wantNextRetryAt: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var jsonOut strings.Builder
if err := PrintJSON(&jsonOut, tt.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
gotJSON := jsonOut.String()
if tt.wantRetryable == "" {
if strings.Contains(gotJSON, `"retryable"`) {
t.Fatalf("unknown retryability must be omitted: %s", gotJSON)
}
} else if !strings.Contains(gotJSON, tt.wantRetryable) {
t.Fatalf("missing %s in %s", tt.wantRetryable, gotJSON)
}
if got := strings.Contains(gotJSON, `"retry_after_seconds": 30`); got != tt.wantRetryAfter {
t.Fatalf("retry_after_seconds presence = %v, want %v: %s", got, tt.wantRetryAfter, gotJSON)
}
if got := strings.Contains(gotJSON, `"next_retry_at": "2026-07-29T20:05:06Z"`); got != tt.wantNextRetryAt {
t.Fatalf("next_retry_at presence = %v, want %v: %s", got, tt.wantNextRetryAt, gotJSON)
}
var humanOut strings.Builder
if err := PrintHuman(&humanOut, tt.err); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
gotHuman := humanOut.String()
if tt.wantRetryable == "" {
if strings.Contains(gotHuman, "Retryable:") {
t.Fatalf("unknown retryability must be omitted: %s", gotHuman)
}
} else {
want := "Retryable: true"
if strings.Contains(tt.wantRetryable, "false") {
want = "Retryable: false"
}
if !strings.Contains(gotHuman, want) {
t.Fatalf("missing %q in %s", want, gotHuman)
}
}
if tt.wantRetryAfter && !strings.Contains(gotHuman, "Retry After: 30s") {
t.Fatalf("missing retry delay in %s", gotHuman)
}
if tt.wantNextRetryAt && !strings.Contains(gotHuman, "Next Retry At: 2026-07-29T20:05:06Z") {
t.Fatalf("missing next retry time in %s", gotHuman)
}
})
}
}
func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T) {
t.Parallel()
err := NewAPI(
"invalid timing",
WithRetryAfterSeconds(-1),
WithNextRetryAt(time.Time{}),
).(*Error)
if err.RetryAfterSeconds != nil || err.NextRetryAt != nil {
t.Fatalf("invalid retry timing was retained: %#v", err)
}
}
func TestPrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
+866
View File
@@ -0,0 +1,866 @@
// 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 personal
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
// AttemptStateFileName stores retry suppression state for personal
// subscription creation. It intentionally does not share the successful
// subscription run-state file.
AttemptStateFileName = "personal_subscription_attempts.json"
// AttemptStateLockFileName serializes cross-process attempt-state changes.
AttemptStateLockFileName = "personal_subscription_attempts.lock"
attemptStateVersion = 1
attemptLockWaitTimeout = 5 * time.Second
attemptLockRetryDelay = 25 * time.Millisecond
attemptFailureReset = 24 * time.Hour
attemptTerminalHold = time.Hour
attemptMaxFieldLength = 256
)
var (
// ErrAttemptClaimStale means another process replaced at least one claimed
// fingerprint. Completion is rejected as a unit so an old process cannot
// overwrite newer state.
ErrAttemptClaimStale = errors.New("personal event: subscription attempt claim is stale")
)
// AttemptState is the persisted lifecycle state of one subscription attempt.
type AttemptState string
const (
AttemptStateInFlight AttemptState = "in_flight"
AttemptStateCooldown AttemptState = "cooldown"
AttemptStateTerminalHold AttemptState = "terminal_hold"
)
// Retryability preserves the distinction between an explicit server decision
// and an error for which the server did not provide retry guidance.
type Retryability string
const (
RetryabilityUnknown Retryability = "unknown"
RetryabilityRetryable Retryability = "retryable"
RetryabilityNonRetryable Retryability = "non_retryable"
)
// Value converts retryability to a bool while preserving whether it is known.
func (r Retryability) Value() (value bool, known bool) {
switch r {
case RetryabilityRetryable:
return true, true
case RetryabilityNonRetryable:
return false, true
default:
return false, false
}
}
// AttemptSpec identifies one normalized subscription request. Fingerprint must
// come from Fingerprint; only the digest and the non-sensitive event key are
// persisted.
type AttemptSpec struct {
Fingerprint string
EventKey string
}
// AttemptClaim is the ownership token returned by Claim. Callers must finish
// it with CompleteSuccess, CompleteFailure, or Release.
type AttemptClaim struct {
AttemptID string
Fingerprints []string
previous map[string]attemptPrevious
}
type attemptPrevious struct {
record attemptRecord
present bool
}
// AttemptBlockedError reports a local suppression decision. It never includes
// the raw endpoint, idempotency key, profile selector, rule parameter, or
// filter.
type AttemptBlockedError struct {
Fingerprint string
EventKey string
State AttemptState
Retryability Retryability
RetryAfter time.Duration
NextAllowedAt time.Time
FailureCount int
ErrorCode string
TraceID string
}
func (e *AttemptBlockedError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf(
"personal event: subscription attempt %s until %s",
e.State,
e.NextAllowedAt.UTC().Format(time.RFC3339),
)
}
// AttemptFailure describes the one failed item in a claimed batch.
type AttemptFailure struct {
Fingerprint string
Retryability Retryability
RetryAfter time.Duration
ErrorCode string
TraceID string
}
// AttemptHold is the persisted suppression decision after CompleteFailure.
type AttemptHold struct {
Fingerprint string
State AttemptState
Retryability Retryability
RetryAfter time.Duration
NextAllowedAt time.Time
FailureCount int
}
// AttemptStoreOption customizes an AttemptStore.
type AttemptStoreOption func(*AttemptStore)
// WithAttemptClock installs a clock, primarily for deterministic tests.
func WithAttemptClock(now func() time.Time) AttemptStoreOption {
return func(store *AttemptStore) {
if now != nil {
store.now = now
}
}
}
// WithAttemptIDGenerator installs an attempt-ID generator. IDs are persisted
// only as CAS tokens and must not contain request data.
func WithAttemptIDGenerator(generate func() (string, error)) AttemptStoreOption {
return func(store *AttemptStore) {
if generate != nil {
store.newID = generate
}
}
}
// AttemptStore coordinates personal subscription creation across CLI
// processes sharing one identity work directory.
type AttemptStore struct {
workDir string
now func() time.Time
newID func() (string, error)
readFile func(string) ([]byte, error)
mkdirAll func(string, os.FileMode) error
tryAcquire func(string) (*eventlock.File, error)
remove func(string) error
marshal func(any, string, string) ([]byte, error)
writeFile func(string, []byte, os.FileMode) error
chmod func(string, os.FileMode) error
rename func(string, string) error
sleep func(time.Duration)
}
// NewAttemptStore creates a fail-closed persistent attempt guard rooted in the
// identity-specific personal event work directory.
func NewAttemptStore(workDir string, options ...AttemptStoreOption) *AttemptStore {
store := &AttemptStore{
workDir: strings.TrimSpace(workDir),
now: time.Now,
newID: newAttemptID,
readFile: os.ReadFile,
mkdirAll: os.MkdirAll,
tryAcquire: eventlock.TryAcquire,
remove: os.Remove,
marshal: json.MarshalIndent,
writeFile: os.WriteFile,
chmod: os.Chmod,
rename: os.Rename,
sleep: time.Sleep,
}
for _, option := range options {
if option != nil {
option(store)
}
}
return store
}
// Fingerprint hashes an endpoint, the existing subscription idempotency key,
// and optional profile selectors into a stable non-reversible store key.
// Passing the active selector ensures two profiles resolving to the same
// corp/user can recover independently after a profile change.
func Fingerprint(endpoint, idempotencyKey string, profileSelector ...string) string {
endpoint = normalizeAttemptEndpoint(endpoint)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if endpoint == "" || idempotencyKey == "" {
return ""
}
h := sha256.New()
writeFingerprintPart(h, "dws-personal-subscription-attempt-v1")
writeFingerprintPart(h, endpoint)
writeFingerprintPart(h, idempotencyKey)
for _, selector := range profileSelector {
if selector = strings.TrimSpace(selector); selector != "" {
writeFingerprintPart(h, selector)
}
}
return hex.EncodeToString(h.Sum(nil))
}
type fingerprintWriter interface {
Write([]byte) (int, error)
}
func writeFingerprintPart(w fingerprintWriter, value string) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
_, _ = w.Write(size[:])
_, _ = w.Write([]byte(value))
}
func normalizeAttemptEndpoint(raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed.String()
}
// Claim atomically reserves every fingerprint in specs. If any fingerprint is
// still suppressed, none are reserved and an AttemptBlockedError is returned.
func (s *AttemptStore) Claim(specs []AttemptSpec, lease time.Duration) (*AttemptClaim, error) {
normalized, err := normalizeAttemptSpecs(specs)
if err != nil {
return nil, err
}
if lease <= 0 {
return nil, errors.New("personal event: subscription attempt lease must be positive")
}
if err := s.validate(); err != nil {
return nil, err
}
attemptID, err := s.newID()
if err != nil {
return nil, fmt.Errorf("personal event: generate subscription attempt id: %w", err)
}
attemptID = strings.TrimSpace(attemptID)
if attemptID == "" || len(attemptID) > attemptMaxFieldLength {
return nil, errors.New("personal event: generated subscription attempt id is invalid")
}
now := s.now().UTC()
claim := &AttemptClaim{
AttemptID: attemptID,
Fingerprints: make([]string, 0, len(normalized)),
previous: make(map[string]attemptPrevious, len(normalized)),
}
var blocked *AttemptBlockedError
err = s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
pruneAttemptRecords(state, now)
for _, spec := range normalized {
record, ok := state.Records[spec.Fingerprint]
if !ok {
continue
}
if candidate := blockedAttempt(record, now); candidate != nil {
blocked = candidate
return nil
}
}
for _, spec := range normalized {
previous, present := state.Records[spec.Fingerprint]
claim.previous[spec.Fingerprint] = attemptPrevious{
record: previous,
present: present,
}
claim.Fingerprints = append(claim.Fingerprints, spec.Fingerprint)
record := previous
record.Fingerprint = spec.Fingerprint
record.EventKey = spec.EventKey
record.State = AttemptStateInFlight
record.AttemptID = attemptID
record.LeaseUntil = now.Add(lease)
record.NextAllowedAt = time.Time{}
record.Retryability = ""
record.ErrorCode = ""
record.TraceID = ""
state.Records[spec.Fingerprint] = record
}
return s.write(state)
})
if err != nil {
return nil, err
}
if blocked != nil {
return nil, blocked
}
return claim, nil
}
// CompleteSuccess clears failure history for every item in a successful
// claimed batch.
func (s *AttemptStore) CompleteSuccess(claim *AttemptClaim) error {
if err := validateAttemptClaim(claim); err != nil {
return err
}
if err := s.validate(); err != nil {
return err
}
return s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
delete(state.Records, fingerprint)
}
return s.write(state)
})
}
// CompleteFailure records the failed item, clears successfully completed
// items, and restores every unexecuted item to its exact pre-claim state.
func (s *AttemptStore) CompleteFailure(
claim *AttemptClaim,
succeeded []string,
failure AttemptFailure,
) (AttemptHold, error) {
var zero AttemptHold
if err := validateAttemptClaim(claim); err != nil {
return zero, err
}
failure.Fingerprint = strings.TrimSpace(failure.Fingerprint)
if !containsFingerprint(claim.Fingerprints, failure.Fingerprint) {
return zero, errors.New("personal event: failed fingerprint is not part of the attempt claim")
}
if !validRetryability(failure.Retryability) {
return zero, fmt.Errorf("personal event: invalid retryability %q", failure.Retryability)
}
succeededSet, err := normalizeSucceededFingerprints(claim, succeeded, failure.Fingerprint)
if err != nil {
return zero, err
}
if err := s.validate(); err != nil {
return zero, err
}
now := s.now().UTC()
var hold AttemptHold
err = s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
switch {
case fingerprint == failure.Fingerprint:
previous := claim.previous[fingerprint]
record := previous.record
count := record.FailureCount
if record.LastFailureAt.IsZero() || now.Sub(record.LastFailureAt) >= attemptFailureReset {
count = 0
}
count++
delay, stateName := attemptFailureDelay(fingerprint, count, failure)
record.Fingerprint = fingerprint
record.EventKey = state.Records[fingerprint].EventKey
record.State = stateName
record.AttemptID = ""
record.LeaseUntil = time.Time{}
record.FailureCount = count
record.LastFailureAt = now
record.NextAllowedAt = now.Add(delay)
record.Retryability = failure.Retryability
record.ErrorCode = boundedAttemptField(failure.ErrorCode)
record.TraceID = boundedAttemptField(failure.TraceID)
state.Records[fingerprint] = record
hold = AttemptHold{
Fingerprint: fingerprint,
State: stateName,
Retryability: failure.Retryability,
RetryAfter: delay,
NextAllowedAt: record.NextAllowedAt,
FailureCount: count,
}
case succeededSet[fingerprint]:
delete(state.Records, fingerprint)
default:
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
}
}
return s.write(state)
})
if err != nil {
return zero, err
}
return hold, nil
}
// Release restores every claimed item without recording a remote failure. It
// is intended for local failures before a subscription request is sent.
func (s *AttemptStore) Release(claim *AttemptClaim) error {
if err := validateAttemptClaim(claim); err != nil {
return err
}
if err := s.validate(); err != nil {
return err
}
return s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
}
return s.write(state)
})
}
func (s *AttemptStore) validate() error {
if s == nil {
return errors.New("personal event: nil subscription attempt store")
}
if s.workDir == "" {
return errors.New("personal event: subscription attempt work directory is required")
}
if s.now == nil || s.newID == nil || s.readFile == nil || s.mkdirAll == nil ||
s.tryAcquire == nil || s.remove == nil || s.marshal == nil ||
s.writeFile == nil || s.chmod == nil || s.rename == nil || s.sleep == nil {
return errors.New("personal event: subscription attempt store is not initialized")
}
return nil
}
func normalizeAttemptSpecs(specs []AttemptSpec) ([]AttemptSpec, error) {
if len(specs) == 0 {
return nil, errors.New("personal event: at least one subscription attempt is required")
}
out := make([]AttemptSpec, 0, len(specs))
seen := make(map[string]struct{}, len(specs))
for _, spec := range specs {
spec.Fingerprint = strings.TrimSpace(spec.Fingerprint)
spec.EventKey = strings.TrimSpace(spec.EventKey)
if !validAttemptFingerprint(spec.Fingerprint) {
return nil, errors.New("personal event: subscription attempt fingerprint is invalid")
}
if len(spec.EventKey) > attemptMaxFieldLength {
return nil, errors.New("personal event: subscription attempt event key is too long")
}
if _, ok := seen[spec.Fingerprint]; ok {
continue
}
seen[spec.Fingerprint] = struct{}{}
out = append(out, spec)
}
return out, nil
}
func normalizeSucceededFingerprints(
claim *AttemptClaim,
succeeded []string,
failed string,
) (map[string]bool, error) {
out := make(map[string]bool, len(succeeded))
for _, fingerprint := range succeeded {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == failed {
return nil, errors.New("personal event: failed fingerprint cannot also be successful")
}
if !containsFingerprint(claim.Fingerprints, fingerprint) {
return nil, errors.New("personal event: successful fingerprint is not part of the attempt claim")
}
out[fingerprint] = true
}
return out, nil
}
func validateAttemptClaim(claim *AttemptClaim) error {
if claim == nil || strings.TrimSpace(claim.AttemptID) == "" ||
len(claim.Fingerprints) == 0 || claim.previous == nil {
return errors.New("personal event: invalid subscription attempt claim")
}
for _, fingerprint := range claim.Fingerprints {
if !validAttemptFingerprint(fingerprint) {
return errors.New("personal event: subscription attempt claim contains an invalid fingerprint")
}
if _, ok := claim.previous[fingerprint]; !ok {
return errors.New("personal event: subscription attempt claim is incomplete")
}
}
return nil
}
func containsFingerprint(fingerprints []string, target string) bool {
for _, fingerprint := range fingerprints {
if fingerprint == target {
return true
}
}
return false
}
func validAttemptFingerprint(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}
func validRetryability(value Retryability) bool {
switch value {
case RetryabilityUnknown, RetryabilityRetryable, RetryabilityNonRetryable:
return true
default:
return false
}
}
func attemptFailureDelay(
fingerprint string,
count int,
failure AttemptFailure,
) (time.Duration, AttemptState) {
if failure.Retryability == RetryabilityNonRetryable {
return attemptTerminalHold, AttemptStateTerminalHold
}
base := attemptBackoff(count)
delay := base + deterministicAttemptJitter(fingerprint, count, base)
if failure.RetryAfter > delay {
delay = failure.RetryAfter
}
return delay, AttemptStateCooldown
}
func attemptBackoff(count int) time.Duration {
switch count {
case 1:
return 30 * time.Second
case 2:
return 2 * time.Minute
case 3:
return 10 * time.Minute
default:
return 30 * time.Minute
}
}
func deterministicAttemptJitter(fingerprint string, count int, base time.Duration) time.Duration {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", fingerprint, count)))
// 0..2000 basis points, inclusive (0%..20%).
basisPoints := int(binary.BigEndian.Uint16(sum[:2])) % 2001
return time.Duration(int64(base) * int64(basisPoints) / 10000)
}
func boundedAttemptField(value string) string {
value = strings.TrimSpace(value)
if len(value) > attemptMaxFieldLength {
return value[:attemptMaxFieldLength]
}
return value
}
func restoreAttemptPrevious(state *attemptStateFile, fingerprint string, previous attemptPrevious) {
if previous.present {
state.Records[fingerprint] = previous.record
return
}
delete(state.Records, fingerprint)
}
func claimOwnsAll(state *attemptStateFile, claim *AttemptClaim) bool {
for _, fingerprint := range claim.Fingerprints {
record, ok := state.Records[fingerprint]
if !ok || record.State != AttemptStateInFlight || record.AttemptID != claim.AttemptID {
return false
}
}
return true
}
func blockedAttempt(record attemptRecord, now time.Time) *AttemptBlockedError {
var next time.Time
retryability := record.Retryability
switch record.State {
case AttemptStateInFlight:
if !record.LeaseUntil.After(now) {
return nil
}
next = record.LeaseUntil
retryability = RetryabilityUnknown
case AttemptStateCooldown, AttemptStateTerminalHold:
if !record.NextAllowedAt.After(now) {
return nil
}
next = record.NextAllowedAt
default:
return nil
}
return &AttemptBlockedError{
Fingerprint: record.Fingerprint,
EventKey: record.EventKey,
State: record.State,
Retryability: retryability,
RetryAfter: next.Sub(now),
NextAllowedAt: next,
FailureCount: record.FailureCount,
ErrorCode: record.ErrorCode,
TraceID: record.TraceID,
}
}
type attemptStateFile struct {
Version int `json:"version"`
Records map[string]attemptRecord `json:"records"`
}
type attemptRecord struct {
Fingerprint string `json:"fingerprint"`
EventKey string `json:"event_key,omitempty"`
State AttemptState `json:"state"`
AttemptID string `json:"attempt_id,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
LastFailureAt time.Time `json:"last_failure_at,omitempty"`
NextAllowedAt time.Time `json:"next_allowed_at,omitempty"`
Retryability Retryability `json:"retryability,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
func (s *AttemptStore) load() (*attemptStateFile, error) {
path := filepath.Join(s.workDir, AttemptStateFileName)
data, err := s.readFile(path)
if os.IsNotExist(err) {
return newAttemptStateFile(), nil
}
if err != nil {
return nil, fmt.Errorf("personal event: read subscription attempt state: %w", err)
}
var state attemptStateFile
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("personal event: decode subscription attempt state: %w", err)
}
if state.Version != attemptStateVersion {
return nil, fmt.Errorf(
"personal event: unsupported subscription attempt state version %d",
state.Version,
)
}
if state.Records == nil {
return nil, errors.New("personal event: subscription attempt state has no records map")
}
for fingerprint, record := range state.Records {
if err := validateAttemptRecord(fingerprint, record); err != nil {
return nil, err
}
}
return &state, nil
}
func newAttemptStateFile() *attemptStateFile {
return &attemptStateFile{
Version: attemptStateVersion,
Records: make(map[string]attemptRecord),
}
}
func validateAttemptRecord(fingerprint string, record attemptRecord) error {
if !validAttemptFingerprint(fingerprint) || fingerprint != record.Fingerprint {
return errors.New("personal event: subscription attempt state contains an invalid fingerprint")
}
if len(record.EventKey) > attemptMaxFieldLength ||
len(record.AttemptID) > attemptMaxFieldLength ||
len(record.ErrorCode) > attemptMaxFieldLength ||
len(record.TraceID) > attemptMaxFieldLength {
return errors.New("personal event: subscription attempt state contains an oversized field")
}
if record.FailureCount < 0 {
return errors.New("personal event: subscription attempt state contains a negative failure count")
}
if record.Retryability != "" && !validRetryability(record.Retryability) {
return errors.New("personal event: subscription attempt state contains invalid retryability")
}
switch record.State {
case AttemptStateInFlight:
if record.AttemptID == "" || record.LeaseUntil.IsZero() {
return errors.New("personal event: in-flight subscription attempt state is incomplete")
}
case AttemptStateCooldown:
if record.NextAllowedAt.IsZero() ||
(record.Retryability != RetryabilityUnknown &&
record.Retryability != RetryabilityRetryable) {
return errors.New("personal event: cooldown subscription attempt state is invalid")
}
case AttemptStateTerminalHold:
if record.NextAllowedAt.IsZero() ||
record.Retryability != RetryabilityNonRetryable {
return errors.New("personal event: terminal subscription attempt state is invalid")
}
default:
return errors.New("personal event: subscription attempt state contains an invalid state")
}
return nil
}
func pruneAttemptRecords(state *attemptStateFile, now time.Time) {
for fingerprint, record := range state.Records {
switch {
case !record.LastFailureAt.IsZero() &&
!now.Before(record.LastFailureAt) &&
now.Sub(record.LastFailureAt) >= attemptFailureReset &&
((record.State == AttemptStateInFlight && !record.LeaseUntil.After(now)) ||
((record.State == AttemptStateCooldown ||
record.State == AttemptStateTerminalHold) &&
!record.NextAllowedAt.After(now))):
delete(state.Records, fingerprint)
case record.State == AttemptStateInFlight &&
record.LastFailureAt.IsZero() &&
!record.LeaseUntil.IsZero() &&
!now.Before(record.LeaseUntil) &&
now.Sub(record.LeaseUntil) >= attemptFailureReset:
delete(state.Records, fingerprint)
}
}
}
func (s *AttemptStore) write(state *attemptStateFile) error {
if state == nil {
return errors.New("personal event: nil subscription attempt state")
}
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
}
path := filepath.Join(s.workDir, AttemptStateFileName)
if len(state.Records) == 0 {
if err := s.remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("personal event: remove empty subscription attempt state: %w", err)
}
return nil
}
keys := make([]string, 0, len(state.Records))
for fingerprint := range state.Records {
keys = append(keys, fingerprint)
}
sort.Strings(keys)
ordered := make(map[string]attemptRecord, len(keys))
for _, fingerprint := range keys {
ordered[fingerprint] = state.Records[fingerprint]
}
payload := attemptStateFile{Version: attemptStateVersion, Records: ordered}
data, err := s.marshal(payload, "", " ")
if err != nil {
return fmt.Errorf("personal event: encode subscription attempt state: %w", err)
}
data = append(data, '\n')
tmp := path + ".tmp"
if err := s.writeFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("personal event: write subscription attempt state: %w", err)
}
if err := s.chmod(tmp, config.FilePerm); err != nil {
_ = s.remove(tmp)
return fmt.Errorf("personal event: secure subscription attempt state: %w", err)
}
if err := s.rename(tmp, path); err != nil {
_ = s.remove(tmp)
return fmt.Errorf("personal event: replace subscription attempt state: %w", err)
}
return nil
}
func (s *AttemptStore) withLock(fn func() error) error {
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
}
lockPath := filepath.Join(s.workDir, AttemptStateLockFileName)
deadline := s.now().Add(attemptLockWaitTimeout)
for {
held, err := s.tryAcquire(lockPath)
if err == nil {
if err := s.chmod(lockPath, config.FilePerm); err != nil {
_ = held.Close()
return fmt.Errorf("personal event: secure subscription attempt lock: %w", err)
}
defer held.Close()
return fn()
}
if !errors.Is(err, eventlock.ErrBusy) {
return fmt.Errorf("personal event: acquire subscription attempt lock: %w", err)
}
remaining := deadline.Sub(s.now())
if remaining <= 0 {
return fmt.Errorf(
"personal event: timed out waiting for subscription attempt lock after %s",
attemptLockWaitTimeout,
)
}
delay := attemptLockRetryDelay
if remaining < delay {
delay = remaining
}
s.sleep(delay)
}
}
func newAttemptID() (string, error) {
return rand.Text(), nil
}
File diff suppressed because it is too large Load Diff
+179 -42
View File
@@ -23,6 +23,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -67,6 +68,8 @@ type Client struct {
HTTPClient *http.Client
Identity Identity
AccessTokenProvider func(context.Context) (string, error)
ClientVersion string
UserAgent string
}
type CreateSubscriptionRequest struct {
@@ -141,10 +144,14 @@ func (s dwsSubscription) toSubscription() Subscription {
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
Details map[string]any `json:"details,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Retryable *bool `json:"retryable,omitempty"`
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
TraceID string `json:"trace_id,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
func (e *APIError) Error() string {
@@ -178,18 +185,6 @@ func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionR
}
var sub Subscription
if err := c.do(ctx, http.MethodPost, "/subscription/user", nil, c.buildCreateRequest(req), &sub); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
if subID, ok := apiErr.Details["subscribe_id"].(string); ok && subID != "" {
return &Subscription{
SubscribeID: subID,
EventKey: req.EventKey,
RuleType: req.RuleType,
Status: "active",
SourceID: c.Identity.SourceID,
}, nil
}
}
return nil, err
}
if sub.EventKey == "" {
@@ -378,14 +373,20 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return fmt.Errorf("personal event: read response: %w", err)
}
responseLog := sanitizeLogPayload(data)
responseID := firstNonEmpty(responseRequestID(data), responseHeaderRequestID(resp.Header))
responseTrace := firstNonEmpty(responseBodyTraceID(data), responseTraceID(resp.Header))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if apiErr := decodeAPIError(data); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, responseRequestID(data))
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), apiErr)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, responseID, responseTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
return fmt.Errorf("personal event: HTTP %d", resp.StatusCode)
apiErr := withHTTPResponseDetails(&APIError{
Code: fmt.Sprintf("HTTP_%d", resp.StatusCode),
Message: firstNonEmpty(http.StatusText(resp.StatusCode), "HTTP request failed"),
}, method, path, resp, responseID, responseTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
return apiErr
}
if len(bytes.TrimSpace(data)) == 0 {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil)
@@ -393,23 +394,25 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
}
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") {
envID := firstNonEmpty(env.requestID(), responseID)
envTrace := firstNonEmpty(env.traceID(), responseTrace)
if env.Success == nil {
if apiErr := env.apiError(); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
return apiErr
}
}
if env.Success != nil && !*env.Success {
if apiErr := env.apiError(); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
return errors.New("personal event: request failed")
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
if env.Result == nil {
return nil
}
@@ -419,10 +422,10 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return decodeResult(env.Result, out)
}
if out == nil {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
return nil
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
return json.Unmarshal(data, out)
}
@@ -451,25 +454,58 @@ func (c *Client) decorate(req *http.Request, accessToken string) {
if c.Identity.CorpID != "" {
req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID)
}
if version := strings.TrimSpace(c.ClientVersion); version != "" {
req.Header.Set("X-Cli-Version", version)
}
if userAgent := strings.TrimSpace(c.UserAgent); userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
req.Header.Set("Accept", "application/json")
}
type responseEnvelope struct {
Success *bool `json:"success"`
RequestID string `json:"request_id,omitempty"`
RequestID2 string `json:"requestId,omitempty"`
Result json.RawMessage `json:"result"`
Error *APIError `json:"error"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Success *bool `json:"success"`
RequestID string `json:"request_id,omitempty"`
RequestID2 string `json:"requestId,omitempty"`
TraceID string `json:"trace_id,omitempty"`
TraceID2 string `json:"traceId,omitempty"`
Arguments []json.RawMessage `json:"arguments,omitempty"`
Result json.RawMessage `json:"result"`
Error *APIError `json:"error"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
RetryAfterSecondsCamel *int64 `json:"retryAfterSeconds,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
NextRetryAtCamel *time.Time `json:"nextRetryAt,omitempty"`
}
func (e responseEnvelope) apiError() *APIError {
if e.Error != nil {
if e.Error.Retryable == nil {
e.Error.Retryable = e.Retryable
}
if e.Error.RetryAfterSeconds == nil {
e.Error.RetryAfterSeconds = firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel)
}
if e.Error.NextRetryAt == nil {
e.Error.NextRetryAt = firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel)
}
if e.Error.TraceID == "" {
e.Error.TraceID = e.traceID()
}
return e.Error
}
if e.ErrorCode != "" || e.ErrorMsg != "" {
return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg}
return &APIError{
Code: e.ErrorCode,
Message: e.ErrorMsg,
Retryable: e.Retryable,
RetryAfterSeconds: firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel),
NextRetryAt: firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel),
TraceID: e.traceID(),
}
}
return nil
}
@@ -478,6 +514,10 @@ func (e responseEnvelope) requestID() string {
return firstNonEmpty(e.RequestID, e.RequestID2)
}
func (e responseEnvelope) traceID() string {
return firstNonEmpty(e.TraceID, e.TraceID2, firstArgumentString(e.Arguments))
}
func decodeResult(raw json.RawMessage, out any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
@@ -511,11 +551,8 @@ func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool) {
func decodeAPIError(data []byte) *APIError {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
if env.Error != nil {
return env.Error
}
if env.ErrorCode != "" || env.ErrorMsg != "" {
return &APIError{Code: env.ErrorCode, Message: env.ErrorMsg}
if apiErr := env.apiError(); apiErr != nil {
return apiErr
}
}
var apiErr APIError
@@ -533,12 +570,24 @@ func responseRequestID(data []byte) string {
return ""
}
func responseBodyTraceID(data []byte) string {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
if env.Error != nil && strings.TrimSpace(env.Error.TraceID) != "" {
return strings.TrimSpace(env.Error.TraceID)
}
return env.traceID()
}
return ""
}
func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError {
if apiErr == nil {
return nil
}
apiErr.HTTPStatus = status
if apiErr.Details == nil {
apiErr.Details = make(map[string]any, 4)
apiErr.Details = make(map[string]any, 8)
}
apiErr.Details["method"] = method
apiErr.Details["path"] = path
@@ -549,6 +598,91 @@ func withRequestDetails(apiErr *APIError, method, path string, status int, reque
return apiErr
}
func withHTTPResponseDetails(apiErr *APIError, method, path string, resp *http.Response, requestID, traceID string) *APIError {
if apiErr == nil {
return nil
}
status := 0
if resp != nil {
status = resp.StatusCode
traceID = firstNonEmpty(apiErr.TraceID, traceID, responseTraceID(resp.Header))
}
apiErr = withRequestDetails(apiErr, method, path, status, requestID)
if apiErr.TraceID == "" {
apiErr.TraceID = strings.TrimSpace(traceID)
}
if resp == nil {
return apiErr
}
retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After"))
if retryAfter == "" {
return apiErr
}
apiErr.Details["retry_after"] = retryAfter
if apiErr.RetryAfterSeconds == nil && apiErr.NextRetryAt == nil {
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 {
apiErr.RetryAfterSeconds = &seconds
} else if next, err := http.ParseTime(retryAfter); err == nil {
next = next.UTC()
apiErr.NextRetryAt = &next
}
}
if apiErr.RetryAfterSeconds != nil {
apiErr.Details["retry_after_seconds"] = *apiErr.RetryAfterSeconds
}
if apiErr.NextRetryAt != nil {
apiErr.Details["next_retry_at"] = apiErr.NextRetryAt.UTC().Format(time.RFC3339)
}
return apiErr
}
func responseTraceID(headers http.Header) string {
for _, key := range []string{"X-Trace-Id", "X-Dingtalk-Trace-Id"} {
if value := strings.TrimSpace(headers.Get(key)); value != "" {
return value
}
}
return ""
}
func responseHeaderRequestID(headers http.Header) string {
return strings.TrimSpace(headers.Get("X-Request-Id"))
}
func firstArgumentString(arguments []json.RawMessage) string {
if len(arguments) == 0 {
return ""
}
var value string
if err := json.Unmarshal(arguments[0], &value); err != nil {
return ""
}
return strings.TrimSpace(value)
}
func firstInt64Pointer(values ...*int64) *int64 {
for _, value := range values {
if value == nil {
continue
}
copy := *value
return &copy
}
return nil
}
func firstTimePointer(values ...*time.Time) *time.Time {
for _, value := range values {
if value == nil || value.IsZero() {
continue
}
copy := value.UTC()
return &copy
}
return nil
}
func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) {
attrs := []any{
"method", method,
@@ -568,6 +702,9 @@ func logControlRequest(message, method, path string, q url.Values, status int, r
attrs = append(attrs, "request_id", requestID)
}
if apiErr != nil {
if apiErr.TraceID != "" {
attrs = append(attrs, "trace_id", apiErr.TraceID)
}
if apiErr.Code != "" {
attrs = append(attrs, "error_code", apiErr.Code)
}
+279
View File
@@ -25,6 +25,7 @@ import (
"strconv"
"strings"
"testing"
"time"
)
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
@@ -282,6 +283,12 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
t.Fatalf("details = %#v", apiErr.Details)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", *apiErr.Retryable)
}
if apiErr.HTTPStatus != http.StatusOK || apiErr.TraceID != "" {
t.Fatalf("HTTP diagnostics = status %d trace %q", apiErr.HTTPStatus, apiErr.TraceID)
}
out := logs.String()
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
if !strings.Contains(out, want) {
@@ -290,6 +297,278 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
}
}
func TestCrossPlatformCoverageClientBusinessErrorPreservesRetryContractAndClientHeaders(t *testing.T) {
nextRetryAt := "2026-07-30T04:05:06Z"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Cli-Version"); got != "1.2.3" {
t.Fatalf("X-Cli-Version = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "dws-cli/1.2.3" {
t.Fatalf("User-Agent = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"errorCode": "RATE_LIMITED",
"errorMsg": "slow down",
"retryable": false,
"retryAfterSeconds": 30,
"nextRetryAt": nextRetryAt,
"arguments": []string{"portal-trace-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
c.ClientVersion = "1.2.3"
c.UserAgent = "dws-cli/1.2.3"
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.Retryable == nil || *apiErr.Retryable {
t.Fatalf("retryable = %v, want explicit false", apiErr.Retryable)
}
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 30 {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != nextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
if apiErr.TraceID != "portal-trace-1" || apiErr.HTTPStatus != http.StatusOK {
t.Fatalf("HTTP diagnostics = trace %q status %d", apiErr.TraceID, apiErr.HTTPStatus)
}
if _, exists := apiErr.Details["request_id"]; exists {
t.Fatalf("portal trace was mislabeled as request_id: %#v", apiErr.Details)
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticIdentityPrecedence(t *testing.T) {
tests := []struct {
name string
body map[string]any
headers http.Header
wantTraceID string
wantRequestID string
}{
{
name: "nested trace wins",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{
"code": "SYSTEM_ERROR",
"message": "failed",
"trace_id": "nested-trace",
},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "nested-trace",
wantRequestID: "body-request",
},
{
name: "top-level trace wins over arguments and header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "top-trace",
wantRequestID: "body-request",
},
{
name: "portal argument wins over header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "portal-trace",
wantRequestID: "body-request",
},
{
name: "headers are independent fallbacks",
body: map[string]any{
"success": false,
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "header-trace",
wantRequestID: "header-request",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
for key, values := range test.headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
_ = json.NewEncoder(w).Encode(test.body)
}))
defer srv.Close()
client := NewClient(srv.URL, Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
})
_, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.TraceID != test.wantTraceID {
t.Fatalf("trace_id = %q, want %q", apiErr.TraceID, test.wantTraceID)
}
if got, _ := apiErr.Details["request_id"].(string); got != test.wantRequestID {
t.Fatalf("request_id = %q, want %q; details=%#v", got, test.wantRequestID, apiErr.Details)
}
})
}
}
func TestCrossPlatformCoverageClientHTTPErrorPreservesRetryAfterAndHeaderTrace(t *testing.T) {
tests := []struct {
name string
retryAfter string
wantSeconds int64
wantNextRetryAt string
}{
{name: "delta seconds", retryAfter: "45", wantSeconds: 45},
{name: "http date", retryAfter: "Thu, 30 Jul 2026 04:05:06 GMT", wantNextRetryAt: "2026-07-30T04:05:06Z"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.Header().Set("Retry-After", tt.retryAfter)
w.Header().Set("X-Trace-Id", "header-trace")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if calls != 1 {
t.Fatalf("HTTP calls = %d, want one-shot client", calls)
}
if apiErr.Code != "HTTP_429" || apiErr.HTTPStatus != http.StatusTooManyRequests || apiErr.TraceID != "header-trace" {
t.Fatalf("API error = %#v", apiErr)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", apiErr.Retryable)
}
if tt.wantSeconds > 0 {
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != tt.wantSeconds {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
}
if tt.wantNextRetryAt != "" {
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != tt.wantNextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
}
})
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticHelpersHandleNilAndMalformedInputs(t *testing.T) {
if got := withHTTPResponseDetails(nil, http.MethodPost, "/subscription/user", nil, "request-1", "trace-1"); got != nil {
t.Fatalf("withHTTPResponseDetails(nil) = %#v, want nil", got)
}
apiErr := &APIError{Code: "SYSTEM_ERROR", Message: "failed"}
got := withHTTPResponseDetails(apiErr, http.MethodPost, "/subscription/user", nil, "request-1", " trace-1 ")
if got != apiErr {
t.Fatalf("withHTTPResponseDetails() returned a different error: got %#v, want %#v", got, apiErr)
}
if got.HTTPStatus != 0 || got.TraceID != "trace-1" {
t.Fatalf("diagnostics = status %d trace %q", got.HTTPStatus, got.TraceID)
}
if got.Details["request_id"] != "request-1" {
t.Fatalf("details = %#v", got.Details)
}
if got := firstArgumentString([]json.RawMessage{json.RawMessage(`{`)}); got != "" {
t.Fatalf("firstArgumentString(malformed) = %q, want empty", got)
}
}
func TestCrossPlatformCoverageClientCreateSubscriptionRejectsErrorsWithSubscribeID(t *testing.T) {
for _, code := range []string{
"SYSTEM_ERROR",
"DUP",
"DUPLICATE_SUBSCRIPTION",
"SUBSCRIPTION_ALREADY_EXISTS",
"SUBSCRIPTION_ALREADY_EXIST",
"ALREADY_SUBSCRIBED",
"DUPLICATE",
} {
t.Run(code, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": code,
"message": "registration failed",
"details": map[string]any{"subscribe_id": "pending-sub"},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
if err == nil || sub != nil {
t.Fatalf("subscription = %#v, error = %v; API errors must stay failures", sub, err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != code {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-DWS-Corp-Id"); got != "" {
@@ -219,8 +219,8 @@ func TestCrossPlatformCoveragePersonalClientHelpersAndOperations(t *testing.T) {
}
base.HTTPClient = personalHTTPClient(400, `{"error":{"code":"DUP","details":{"subscribe_id":"existing"}}}`)
created, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{EventKey: "e", RuleType: "r"})
if err != nil || created.SubscribeID != "existing" {
t.Fatalf("duplicate create = %#v, %v", created, err)
if err == nil || created != nil {
t.Fatalf("duplicate error create = %#v, %v", created, err)
}
request := base.buildCreateRequest(CreateSubscriptionRequest{
EventKey: "e", RuleType: "r", Name: "n", RuleParam: map[string]any{"bad": make(chan int)},
@@ -0,0 +1,161 @@
// 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.
// Command cmd_param_aliases reduces the reviewed parameter concept dictionary
// (internal/cli/param_concepts.json) against the live Cobra command tree and
// writes the deterministic per-command alias table to
// internal/cli/param_aliases_generated.go.
//
// The reduction algorithm lives in package cli (cli.ReduceParamAliases) so the
// build-time intersection and the runtime normalizer share exactly one
// implementation. This command is a thin, deterministic serializer: it never
// invents identity, and it fails closed on any un-whitelisted co-occurrence,
// missing override path, or non-real alias target.
package main
import (
"bytes"
"flag"
"fmt"
"go/format"
"os"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
)
const defaultOutput = "internal/cli/param_aliases_generated.go"
var (
newParamAliasRoot = app.NewSchemaSourceRootCommand
reduceParamAliasEntries = cli.ReduceParamAliases
formatParamAliasSource = format.Source
writeParamAliasFile = os.WriteFile
exitParamAliasProcess = os.Exit
)
func main() {
var rootPath string
var outputPath string
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated parameter-alias table")
flag.Parse()
if err := generateParamAliases(rootPath, outputPath); err != nil {
fail(err)
}
}
func generateParamAliases(rootPath, outputPath string) error {
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
return err
}
entries, err := reduceParamAliasEntries(newParamAliasRoot())
if err != nil {
return err
}
source, err := renderParamAliases(entries)
if err != nil {
return err
}
if err := writeParamAliasFile(outputPath, source, 0o644); err != nil {
return fmt.Errorf("write generated parameter aliases: %w", err)
}
_, _ = fmt.Fprintf(os.Stderr, "generated parameter aliases: output=%s commands=%d\n", outputPath, len(entries))
return nil
}
func validateOutputIsolation(rootPath, outputPath string) error {
inputs := []outputguard.Input{
{Name: "reviewed parameter concept dictionary", Path: "internal/cli/param_concepts.json"},
{Name: "reviewed parameter concept schema", Path: "internal/cli/param_concepts.schema.json"},
}
target := outputguard.Target{Name: "--output", Path: outputPath}
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
return err
}
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
}
// renderParamAliases serializes the reduced entries into gofmt-stable Go
// source. Entries and every map/slice are emitted in sorted order so
// consecutive generations are byte-identical.
func renderParamAliases(entries []cli.ParamAliasEntry) ([]byte, error) {
var b bytes.Buffer
b.WriteString("// Copyright 2026 Alibaba Group\n")
b.WriteString("// Licensed under the Apache License, Version 2.0 (the \"License\");\n\n")
b.WriteString("// Code generated by cmd_param_aliases; DO NOT EDIT.\n")
b.WriteString("// Source: internal/cli/param_concepts.json reduced against the live Cobra tree.\n")
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
b.WriteString("package cli\n\n")
b.WriteString("// generatedParamAliases is the per-command parameter-alias table reduced from\n")
b.WriteString("// the reviewed concept dictionary. Each entry binds one runnable Cobra leaf.\n")
b.WriteString("var generatedParamAliases = []ParamAliasEntry{\n")
for _, entry := range entries {
b.WriteString("\t{\n")
fmt.Fprintf(&b, "\t\tCLIPath: %q,\n", entry.CLIPath)
if len(entry.Aliases) > 0 {
b.WriteString("\t\tAliases: map[string]string{\n")
for _, key := range sortedKeys(entry.Aliases) {
fmt.Fprintf(&b, "\t\t\t%q: %q,\n", key, entry.Aliases[key])
}
b.WriteString("\t\t},\n")
}
if len(entry.Blocked) > 0 {
fmt.Fprintf(&b, "\t\tBlocked: %s,\n", renderStringSlice(entry.Blocked))
}
if len(entry.Ambiguous) > 0 {
fmt.Fprintf(&b, "\t\tAmbiguous: %s,\n", renderStringSlice(entry.Ambiguous))
}
b.WriteString("\t},\n")
}
b.WriteString("}\n")
b.WriteString("\nfunc loadGeneratedParamAliases() []ParamAliasEntry {\n")
b.WriteString("\treturn generatedParamAliases\n")
b.WriteString("}\n")
formatted, err := formatParamAliasSource(b.Bytes())
if err != nil {
return nil, fmt.Errorf("format generated parameter aliases: %w", err)
}
return formatted, nil
}
func renderStringSlice(values []string) string {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = fmt.Sprintf("%q", v)
}
return "[]string{" + strings.Join(quoted, ", ") + "}"
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func fail(err error) {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
exitParamAliasProcess(1)
}
@@ -0,0 +1,184 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"context"
"errors"
"flag"
"go/format"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func TestParamAliasGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
got := reflect.ValueOf(newParamAliasRoot).Pointer()
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
if got != want {
t.Fatal("parameter alias generator must use the distribution-owned Schema source command tree")
}
}
func preserveParamAliasGeneratorGlobals(t *testing.T) {
t.Helper()
oldArgs, oldFlags := os.Args, flag.CommandLine
oldRoot := newParamAliasRoot
oldReduce := reduceParamAliasEntries
oldFormat := formatParamAliasSource
oldWrite := writeParamAliasFile
oldExit := exitParamAliasProcess
t.Cleanup(func() {
os.Args, flag.CommandLine = oldArgs, oldFlags
newParamAliasRoot = oldRoot
reduceParamAliasEntries = oldReduce
formatParamAliasSource = oldFormat
writeParamAliasFile = oldWrite
exitParamAliasProcess = oldExit
})
}
func repositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatal(err)
}
return root
}
func TestCrossPlatformCoverageParamAliasMainWritesGeneratedFile(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
output := filepath.Join(t.TempDir(), "param_aliases_generated.go")
flag.CommandLine = flag.NewFlagSet("param-alias-success", flag.ContinueOnError)
os.Args = []string{"cmd_param_aliases", "-root", repositoryRoot(t), "-output", output}
newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) {
return []cli.ParamAliasEntry{{CLIPath: "demo get", Aliases: map[string]string{"uid": "user"}}}, nil
}
main()
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "func loadGeneratedParamAliases() []ParamAliasEntry") ||
!strings.Contains(string(data), `"uid": "user"`) {
t.Fatalf("generated source missing runtime table:\n%s", data)
}
}
func TestCrossPlatformCoverageParamAliasMainReportsFailure(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
flag.CommandLine = flag.NewFlagSet("param-alias-failure", flag.ContinueOnError)
os.Args = []string{
"cmd_param_aliases",
"-root", repositoryRoot(t),
"-output", filepath.Join(repositoryRoot(t), "internal", "cli", "param_concepts.json"),
}
exitParamAliasProcess = func(code int) { panic(code) }
defer func() {
if recovered := recover(); recovered != 1 {
t.Fatalf("main exit = %#v, want 1", recovered)
}
}()
main()
}
func TestGenerateParamAliasesFailurePaths(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
root := repositoryRoot(t)
output := filepath.Join(t.TempDir(), "param_aliases_generated.go")
newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
if err := generateParamAliases(root, filepath.Join(root, "internal", "cli", "param_concepts.json")); err == nil {
t.Fatal("generateParamAliases accepted an output that overlaps a reviewed input")
}
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) {
return nil, errors.New("reduce")
}
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
t.Fatalf("reduction error = %v", err)
}
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { return nil, nil }
formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
t.Fatalf("format error = %v", err)
}
formatParamAliasSource = format.Source
writeParamAliasFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
t.Fatalf("write error = %v", err)
}
}
func TestValidateParamAliasOutputIsolation(t *testing.T) {
root := repositoryRoot(t)
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "aliases.go")); err != nil {
t.Fatalf("temporary output rejected: %v", err)
}
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
t.Fatalf("non-canonical repository output error = %v", err)
}
}
func TestRenderParamAliasesDeterministicShape(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
entries := []cli.ParamAliasEntry{
{
CLIPath: "demo get",
Aliases: map[string]string{"z-name": "name", "a-name": "name"},
Blocked: []string{"page", "count"},
Ambiguous: []string{"user-id"},
},
{CLIPath: "demo empty"},
}
data, err := renderParamAliases(entries)
if err != nil {
t.Fatal(err)
}
text := string(data)
if strings.Index(text, `"a-name": "name"`) > strings.Index(text, `"z-name": "name"`) {
t.Fatalf("alias keys are not sorted:\n%s", text)
}
for _, want := range []string{
"func loadGeneratedParamAliases() []ParamAliasEntry",
`Blocked: []string{"page", "count"}`,
`Ambiguous: []string{"user-id"}`,
`CLIPath: "demo empty"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("generated source missing %q:\n%s", want, text)
}
}
empty, err := renderParamAliases(nil)
if err != nil || !strings.Contains(string(empty), "var generatedParamAliases = []ParamAliasEntry{") {
t.Fatalf("empty render = %q, error = %v", empty, err)
}
if got := renderStringSlice(nil); got != "[]string{}" {
t.Fatalf("renderStringSlice(nil) = %q", got)
}
if got := sortedKeys(nil); len(got) != 0 {
t.Fatalf("sortedKeys(nil) = %v", got)
}
if got := sortedKeys(map[string]string{"b": "2", "a": "1"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("sortedKeys() = %v", got)
}
formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
if _, err := renderParamAliases(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
t.Fatalf("formatter error = %v", err)
}
}
+32
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
stderrors "errors"
"io"
"reflect"
"strings"
"testing"
@@ -651,6 +652,37 @@ func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) {
}
}
type devAppFailingCountRunner struct {
calls int
err error
}
func (r *devAppFailingCountRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
return executor.Result{Invocation: invocation}, r.err
}
func TestCrossPlatformCoverageDevAppEventSubscribeRunnerFailureIsNotRetried(t *testing.T) {
wantErr := stderrors.New("event subscription failed")
runner := &devAppFailingCountRunner{err: wantErr}
root := newDevAppTestRoot(runner)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{
"dev", "app", "event", "subscribe",
"--unified-app-id", "u-1",
"--event-codes", "a,b",
"--yes",
})
if err := root.Execute(); !stderrors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want %v", err, wantErr)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
}
func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
+245 -10
View File
@@ -14,9 +14,14 @@
package pipeline
import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -30,36 +35,61 @@ import (
// If the target command cannot be resolved (e.g. the user typed a
// non-existent command), PreParse is skipped silently and Cobra will
// handle the error.
func RunPreParse(root *cobra.Command, engine *Engine) {
func RunPreParse(root *cobra.Command, engine *Engine) error {
_, err := RunPreParseArgs(root, engine, os.Args[1:])
return err
}
// RunPreParseArgs is the testable form of RunPreParse. Production passes
// os.Args[1:]; end-to-end tests can pass an isolated argv while exercising the
// exact same command traversal, FlagInfo extraction, handler chain, and
// root.SetArgs delivery path.
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
if engine == nil || !engine.HasHandlers(PreParse) {
return
return nil, nil
}
rawArgs := os.Args[1:]
if len(rawArgs) == 0 {
return
return nil, nil
}
// Traverse the command tree to find the target command.
target, _, err := root.Traverse(rawArgs)
// Cobra's Traverse does not merge root persistent flags before deciding
// whether a leading flag consumes the next token. For example, it can treat
// the command name in `--dry-run calendar event list` as a value and resolve
// the unrelated root command `event list`. Remove only known root-persistent
// flags from the traversal copy; the original argv remains intact for the
// handlers and Cobra's real parse.
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
if err != nil {
return
return nil, nil
}
// Build FlagInfo from the target command's registered flags.
flagInfos := FlagInfoFromCommand(target)
if len(flagInfos) == 0 {
return
return nil, nil
}
ctx := &Context{
Args: append([]string{}, rawArgs...),
Args: append([]string{}, rawArgs...),
// target.CommandPath() still carries the "dws" prefix; PreParse
// handlers that key off a command (e.g. the semantic-alias table)
// normalize it themselves, so the runtime key matches the build key.
Command: target.CommandPath(),
FlagSpecs: flagInfos,
}
if err := engine.RunPhase(PreParse, ctx); err != nil {
// Cobra has not parsed persistent flags yet, but this error is rendered
// immediately by app.Execute. Prime only the presentation controls so
// --format/--debug/--verbose affect this early error exactly as they do
// errors returned after Cobra parsing. No credentials, profiles, output
// paths, or execution controls are applied here.
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
}
slog.Debug("pipeline pre-parse", "error", err)
return
return ctx, err
}
// Only set corrected args if PreParse actually changed something.
@@ -75,6 +105,210 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
)
}
}
return ctx, nil
}
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
if root == nil || len(rawArgs) == 0 {
return rawArgs
}
flags := root.PersistentFlags()
if flags == nil || !flags.HasFlags() {
return rawArgs
}
matcher := newFlagTokenMatcher(flags)
out := make([]string, 0, len(rawArgs))
for index := 0; index < len(rawArgs); index++ {
argument := rawArgs[index]
if argument == "--" {
out = append(out, rawArgs[index:]...)
break
}
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
if !matched {
out = append(out, argument)
continue
}
if index+1 < len(rawArgs) {
if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok {
index++
continue
}
}
if !inlineValue && flag.NoOptDefVal == "" && index+1 < len(rawArgs) {
index++
}
}
return out
}
// separatedBoolValue recognises model-friendly `--flag false` and exact
// shorthand `-f false` spellings without mistaking an already attached value
// or a shorthand cluster for a detached value. pflag otherwise treats a bare
// bool flag as true and leaves the following token positional.
func separatedBoolValue(argument, following string, flag *pflag.Flag, inlineValue bool) (string, bool) {
if flag == nil || (flag.Value.Type() != "bool" && flag.Value.Type() != "boolean") {
return "", false
}
if strings.HasPrefix(argument, "--") {
if inlineValue || strings.Contains(argument, "=") {
return "", false
}
} else if flag.Shorthand == "" || argument != "-"+flag.Shorthand {
return "", false
}
return cmdutil.NormalizeBoolLiteral(following)
}
type longFlagMatch struct {
flag *pflag.Flag
value string
hasValue bool
recognized bool
}
type flagTokenMatcher struct {
byName map[string]*pflag.Flag
byShorthand map[string]*pflag.Flag
known map[string]bool
candidates []string
specByName map[string]FlagInfo
}
func newFlagTokenMatcher(flagSets ...*pflag.FlagSet) *flagTokenMatcher {
matcher := &flagTokenMatcher{
byName: make(map[string]*pflag.Flag),
byShorthand: make(map[string]*pflag.Flag),
known: make(map[string]bool),
specByName: make(map[string]FlagInfo),
}
for _, flags := range flagSets {
if flags == nil {
continue
}
flags.VisitAll(func(flag *pflag.Flag) {
if _, exists := matcher.byName[flag.Name]; exists {
return
}
matcher.byName[flag.Name] = flag
matcher.known[flag.Name] = true
matcher.candidates = append(matcher.candidates, flag.Name)
matcher.specByName[flag.Name] = flagInfoFromPflag(flag)
if flag.Shorthand != "" {
matcher.byShorthand[flag.Shorthand] = flag
}
})
}
return matcher
}
func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch {
if m == nil || !strings.HasPrefix(argument, "--") || argument == "--" {
return longFlagMatch{}
}
canonical := argument
body := strings.TrimPrefix(canonical, "--")
name, value, hasValue := strings.Cut(body, "=")
if flag := m.byName[name]; flag != nil {
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true}
}
if normalized, ok := NormalizeFlagToken(argument, m.known); ok {
canonical = normalized
} else if split, ok := SplitStickyFlag(argument, m.specByName); ok {
name = strings.TrimPrefix(split.Flag, "--")
return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true}
} else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok {
canonical = fuzzy
} else {
return longFlagMatch{}
}
body = strings.TrimPrefix(canonical, "--")
name, value, hasValue = strings.Cut(body, "=")
flag := m.byName[name]
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil}
}
func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) {
if m == nil || argument == "" || argument == "-" || argument == "--" {
return nil, false, false
}
if strings.HasPrefix(argument, "--") {
match := m.matchLongToken(argument)
return match.flag, match.hasValue, match.recognized
}
if !strings.HasPrefix(argument, "-") {
return nil, false, false
}
body := strings.TrimPrefix(argument, "-")
shorthands := []rune(body)
for index, shorthand := range shorthands {
flag := m.byShorthand[string(shorthand)]
if flag == nil {
return nil, false, false
}
if flag.NoOptDefVal == "" {
// A value-taking shorthand consumes the next token only when it is
// last; otherwise the remainder is its attached value (`-vfjson`).
return flag, index < len(shorthands)-1, true
}
}
return m.byShorthand[string(shorthands[0])], true, true
}
func primeEarlyErrorPresentation(root, target *cobra.Command, rawArgs []string) error {
if root == nil || target == nil || len(rawArgs) == 0 {
return nil
}
rootFlags := root.PersistentFlags()
presentationFlags := pflag.NewFlagSet("early-error-presentation", pflag.ContinueOnError)
presentationFlags.SetOutput(io.Discard)
presentationFlags.ParseErrorsWhitelist.UnknownFlags = true
presentationFlags.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
return pflag.NormalizedName(cmdutil.Morph(name))
})
names := make([]string, 0, 3)
if source := rootFlags.Lookup("format"); source != nil {
value, err := rootFlags.GetString("format")
if err != nil {
return fmt.Errorf("read presentation flag --format: %w", err)
}
presentationFlags.StringP("format", source.Shorthand, value, source.Usage)
names = append(names, "format")
}
for _, name := range []string{"debug", "verbose"} {
source := rootFlags.Lookup(name)
if source == nil {
continue
}
value, err := rootFlags.GetBool(name)
if err != nil {
return fmt.Errorf("read presentation flag --%s: %w", name, err)
}
presentationFlags.BoolP(name, source.Shorthand, value, source.Usage)
names = append(names, name)
}
// Keep the existing contract in which a PreParse conflict wins over help;
// registering help prevents pflag's special unknown-help early return.
presentationFlags.BoolP("help", "h", false, "")
parseErr := presentationFlags.Parse(rawArgs)
errs := []error{parseErr}
for _, name := range names {
if !presentationFlags.Changed(name) {
continue
}
value := presentationFlags.Lookup(name).Value.String()
if err := rootFlags.Set(name, value); err != nil {
errs = append(errs, fmt.Errorf("apply presentation flag --%s: %w", name, err))
}
}
return errors.Join(errs...)
}
// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
@@ -117,6 +351,7 @@ func appendFlagInfo(infos *[]FlagInfo, seen map[string]bool, flag *pflag.Flag) {
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
fi := FlagInfo{
Name: f.Name,
Shorthand: f.Shorthand,
PropertyName: f.Name,
Type: f.Value.Type(),
}
+345 -3
View File
@@ -4,9 +4,11 @@ import (
"errors"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnotations(t *testing.T) {
@@ -16,7 +18,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota
root := &cobra.Command{Use: "root"}
root.PersistentFlags().String("profile", "", "")
child := &cobra.Command{Use: "child"}
child.Flags().String("start-time", "", "")
child.Flags().StringP("start-time", "s", "", "")
child.Flags().Lookup("start-time").Annotations = map[string][]string{
"x-cli-format": {"date-time"},
"x-cli-enum": {"one", "two"},
@@ -31,7 +33,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota
for _, info := range infos {
byName[info.Name] = info
}
if byName["profile"].Type != "string" || byName["start-time"].Format != "date-time" ||
if byName["profile"].Type != "string" || byName["start-time"].Shorthand != "s" || byName["start-time"].Format != "date-time" ||
!reflect.DeepEqual(byName["start-time"].Enum, []string{"one", "two"}) {
t.Fatalf("flag infos = %#v", infos)
}
@@ -106,8 +108,348 @@ func TestCrossPlatformCoverageRunPreParseAppliesCorrectionsOnlyOnSuccess(t *test
failing := NewEngine()
failing.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("boom") }))
os.Args = []string{"root", "child", "--name", "original"}
RunPreParse(root, failing)
if err := RunPreParse(root, failing); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("failed preparse error = %v, want boom", err)
}
if err := root.Execute(); err != nil || *value != "original" {
t.Fatalf("failed preparse execute = %q, %v", *value, err)
}
}
func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) {
tests := []struct {
name string
args []string
executable bool
}{
{name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "boolean long flag with detached false", args: []string{"--dry-run", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "camel-case boolean long flag", args: []string{"--dryRun", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "camel-case boolean with detached false", args: []string{"--dryRun", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "fuzzy boolean long flag", args: []string{"--dry-rnu", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "fuzzy boolean with detached false", args: []string{"--dry-rnu", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "fuzzy valued long flag", args: []string{"--profle", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "sticky valued long flag", args: []string{"--timeout30", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "clustered attached shorthand", args: []string{"-vfjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "boolean shorthand with detached false", args: []string{"-v", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("profile", "", "")
root.PersistentFlags().Int("timeout", 0, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
// This similarly named root path makes the old traversal failure
// deterministic: `--dry-run` consumed "calendar" as a value and
// incorrectly selected `dws event list`.
misleadingEvent := &cobra.Command{Use: "event"}
misleadingEvent.AddCommand(&cobra.Command{Use: "list"})
root.AddCommand(misleadingEvent)
calendar := &cobra.Command{Use: "calendar"}
event := &cobra.Command{Use: "event"}
value := ""
list := &cobra.Command{Use: "list"}
list.Flags().StringVar(&value, "start", "", "")
event.AddCommand(list)
calendar.AddCommand(event)
root.AddCommand(calendar)
engine := NewEngine()
engine.Register(newStub("calendar-date-alias", PreParse, func(ctx *Context) error {
if ctx.Command != "dws calendar event list" {
t.Fatalf("resolved command = %q, want dws calendar event list", ctx.Command)
}
for index, argument := range ctx.Args {
if argument == "--date" {
ctx.Args[index] = "--start"
ctx.AddCorrection("calendar-date-alias", PreParse, "start", "--date", "--start", "test")
}
}
return nil
}))
root.SetArgs(test.args)
ctx, err := RunPreParseArgs(root, engine, test.args)
if err != nil {
t.Fatalf("RunPreParseArgs() error = %v", err)
}
if ctx == nil || len(ctx.Corrections) != 1 {
t.Fatalf("RunPreParseArgs() context = %#v", ctx)
}
if test.executable {
if err := root.Execute(); err != nil {
t.Fatalf("corrected command failed: %v", err)
}
if value != "2026-03-10" {
t.Fatalf("canonical --start value = %q", value)
}
}
})
}
}
func TestRunPreParsePrimesPresentationFlagsForEarlyErrors(t *testing.T) {
tests := []struct {
name string
args []string
wantFormat string
wantDebug bool
wantVerbose bool
}{
{
name: "canonical flags after command",
args: []string{"child", "--name", "demo", "--format", "table", "--debug"},
wantFormat: "table",
wantDebug: true,
},
{
name: "normalized presentation names",
args: []string{"--dryRun", "--FORMAT=pretty", "--Verbose", "child", "--name", "demo"},
wantFormat: "pretty",
wantVerbose: true,
},
{
name: "clustered shorthands",
args: []string{"-vfraw", "child", "--name", "demo"},
wantFormat: "raw",
wantVerbose: true,
},
{
name: "explicit boolean presentation values",
args: []string{"--debug=true", "-v=false", "child", "--name", "demo"},
wantFormat: "json",
wantDebug: true,
wantVerbose: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
child.Flags().String("name", "", "")
root.AddCommand(child)
engine := NewEngine()
engine.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("early") }))
ctx, err := RunPreParseArgs(root, engine, test.args)
if err == nil || ctx == nil {
t.Fatalf("RunPreParseArgs() = %#v, %v; want early error with context", ctx, err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
verbose, _ := root.PersistentFlags().GetBool("verbose")
if format != test.wantFormat || debug != test.wantDebug || verbose != test.wantVerbose {
t.Fatalf("presentation flags = format:%q debug:%v verbose:%v; want %q/%v/%v", format, debug, verbose, test.wantFormat, test.wantDebug, test.wantVerbose)
}
})
}
}
func TestRunPreParseKeepsPrimaryErrorWhenPresentationParsingFails(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
child.Flags().String("name", "", "")
root.AddCommand(child)
primaryErr := errors.New("primary pre-parse failure")
engine := NewEngine()
engine.Register(newStub("fail", PreParse, func(*Context) error { return primaryErr }))
ctx, err := RunPreParseArgs(root, engine, []string{
"child", "--name", "demo", "--format", "table", "--debug=maybe",
})
if ctx == nil || !errors.Is(err, primaryErr) {
t.Fatalf("RunPreParseArgs() = %#v, %v; want primary error", ctx, err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
if format != "table" || debug {
t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug)
}
}
func TestCommandTraversalFlagTokenEdges(t *testing.T) {
raw := []string{"child"}
if got := argsForCommandTraversal(nil, raw); !reflect.DeepEqual(got, raw) {
t.Fatalf("nil-root traversal args = %v", got)
}
root := &cobra.Command{Use: "root"}
if got := argsForCommandTraversal(root, nil); got != nil {
t.Fatalf("empty traversal args = %v", got)
}
root.PersistentFlags().BoolP("verbose", "v", false, "")
root.PersistentFlags().StringP("format", "f", "", "")
if got := argsForCommandTraversal(root, []string{"--", "--verbose", "child"}); !reflect.DeepEqual(got, []string{"--", "--verbose", "child"}) {
t.Fatalf("double-dash traversal args = %v", got)
}
if flag, inline, matched := newFlagTokenMatcher(nil).matchTraversalToken("--verbose"); flag != nil || inline || matched {
t.Fatalf("nil flag set matched: %#v, %v, %v", flag, inline, matched)
}
if flag, inline, matched := (*flagTokenMatcher)(nil).matchTraversalToken(""); flag != nil || inline || matched {
t.Fatalf("nil matcher matched: %#v, %v, %v", flag, inline, matched)
}
if match := (*flagTokenMatcher)(nil).matchLongToken("--verbose"); match.recognized {
t.Fatalf("nil long matcher matched: %#v", match)
}
if flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-x"); flag != nil || inline || matched {
t.Fatalf("unknown shorthand matched: %#v, %v, %v", flag, inline, matched)
}
flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-vv")
if !matched || !inline || flag == nil || flag.Name != "verbose" {
t.Fatalf("boolean shorthand cluster = %#v, %v, %v", flag, inline, matched)
}
duplicate := pflag.NewFlagSet("duplicate", pflag.ContinueOnError)
duplicate.Bool("verbose", false, "")
matcher := newFlagTokenMatcher(root.PersistentFlags(), duplicate)
if len(matcher.byName) != 2 || matcher.byName["verbose"] != root.PersistentFlags().Lookup("verbose") {
t.Fatalf("duplicate flag precedence = %#v", matcher.byName)
}
}
func TestSeparatedBoolValueRecognition(t *testing.T) {
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.BoolP("verbose", "v", false, "")
flags.String("format", "", "")
verbose := flags.Lookup("verbose")
format := flags.Lookup("format")
tests := []struct {
name string
argument string
following string
flag *pflag.Flag
inline bool
want string
ok bool
}{
{name: "nil flag", argument: "--verbose", following: "false"},
{name: "non bool", argument: "--format", following: "false", flag: format},
{name: "long false", argument: "--verbose", following: "false", flag: verbose, want: "false", ok: true},
{name: "long synonym", argument: "--verbose", following: "on", flag: verbose, want: "true", ok: true},
{name: "inline long", argument: "--verbosefalse", following: "false", flag: verbose, inline: true},
{name: "equals long", argument: "--verbose=false", following: "true", flag: verbose},
{name: "exact shorthand", argument: "-v", following: "0", flag: verbose, want: "false", ok: true},
{name: "shorthand cluster", argument: "-vv", following: "false", flag: verbose},
{name: "invalid literal", argument: "--verbose", following: "maybe", flag: verbose},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, ok := separatedBoolValue(test.argument, test.following, test.flag, test.inline)
if got != test.want || ok != test.ok {
t.Fatalf("separatedBoolValue() = %q, %v; want %q, %v", got, ok, test.want, test.ok)
}
})
}
}
func TestPrimeEarlyErrorPresentationEdges(t *testing.T) {
if err := primeEarlyErrorPresentation(nil, nil, nil); err != nil {
t.Fatalf("nil presentation priming error = %v", err)
}
root := &cobra.Command{Use: "root"}
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
child.Flags().String("name", "", "")
root.AddCommand(child)
if err := primeEarlyErrorPresentation(root, child, []string{
"child", "--unknown", "value", "-x", "--name", "demo",
"-f", "table", "-v", "maybe", "--", "--debug",
}); err != nil {
t.Fatalf("presentation priming error = %v", err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
verbose, _ := root.PersistentFlags().GetBool("verbose")
if format != "table" || debug || !verbose {
t.Fatalf("presentation after edge argv = format:%q debug:%v verbose:%v", format, debug, verbose)
}
}
func TestPrimeEarlyErrorPresentationReportsParseAndContractErrors(t *testing.T) {
t.Run("invalid presentation value is reported after applying valid values", func(t *testing.T) {
root := &cobra.Command{Use: "root"}
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
err := primeEarlyErrorPresentation(root, child, []string{"child", "--format", "table", "--debug=maybe"})
if err == nil || !strings.Contains(err.Error(), "invalid argument") {
t.Fatalf("invalid presentation error = %v", err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
if format != "table" || debug {
t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug)
}
})
for _, test := range []struct {
name string
add func(*pflag.FlagSet)
want string
}{
{name: "format type drift", add: func(flags *pflag.FlagSet) { flags.Bool("format", false, "") }, want: "read presentation flag --format"},
{name: "debug type drift", add: func(flags *pflag.FlagSet) {
flags.String("format", "json", "")
flags.String("debug", "", "")
}, want: "read presentation flag --debug"},
} {
t.Run(test.name, func(t *testing.T) {
root := &cobra.Command{Use: "root"}
test.add(root.PersistentFlags())
err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("presentation contract error = %v, want %q", err, test.want)
}
})
}
t.Run("apply error is returned", func(t *testing.T) {
root := &cobra.Command{Use: "root"}
value := &rejectingPresentationString{value: "json"}
root.PersistentFlags().VarP(value, "format", "f", "")
err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"})
if err == nil || !strings.Contains(err.Error(), "apply presentation flag --format") {
t.Fatalf("presentation apply error = %v", err)
}
})
t.Run("missing presentation flags are optional", func(t *testing.T) {
root := &cobra.Command{Use: "root"}
if err := primeEarlyErrorPresentation(root, root, []string{"--unknown", "value"}); err != nil {
t.Fatalf("optional presentation flags error = %v", err)
}
})
}
type rejectingPresentationString struct {
value string
}
func (v *rejectingPresentationString) Set(string) error { return errors.New("rejected") }
func (v *rejectingPresentationString) String() string { return v.value }
func (*rejectingPresentationString) Type() string { return "string" }
+18 -1
View File
@@ -24,6 +24,23 @@ type Engine struct {
handlers map[Phase][]Handler
}
// HandlerError preserves the pipeline location of a handler failure for logs
// and diagnostics while keeping the underlying domain error available to
// user-facing adapters through Unwrap.
type HandlerError struct {
Phase Phase
Handler string
Cause error
}
func (e *HandlerError) Error() string {
return fmt.Sprintf("pipeline %s handler %q: %v", e.Phase, e.Handler, e.Cause)
}
func (e *HandlerError) Unwrap() error {
return e.Cause
}
// NewEngine creates a pipeline engine with no registered handlers.
func NewEngine() *Engine {
return &Engine{
@@ -64,7 +81,7 @@ func (e *Engine) HasHandlers(phase Phase) bool {
func (e *Engine) RunPhase(phase Phase, ctx *Context) error {
for _, h := range e.handlers[phase] {
if err := h.Handle(ctx); err != nil {
return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err)
return &HandlerError{Phase: phase, Handler: h.Name(), Cause: err}
}
}
return nil
+40
View File
@@ -168,6 +168,10 @@ func TestRunPhaseErrorAbortsChain(t *testing.T) {
if !strings.Contains(err.Error(), "fail") {
t.Errorf("error should contain handler name, got %q", err.Error())
}
var handlerErr *HandlerError
if !errors.As(err, &handlerErr) || handlerErr.Phase != PreParse || handlerErr.Handler != "fail" || handlerErr.Unwrap() != boom {
t.Fatalf("handler error = %#v, want pre-parse/fail wrapping boom", handlerErr)
}
if !h1.called {
t.Error("h1 should have been called")
}
@@ -240,6 +244,42 @@ func TestContextAddCorrection(t *testing.T) {
}
}
func TestContextFlagProtectionAndConflictError(t *testing.T) {
var nilContext *Context
nilContext.ProtectFlag("uid", FlagProtectionBlocked)
if nilContext.IsFlagProtected("uid") {
t.Fatal("nil context reported a protected flag")
}
ctx := &Context{}
ctx.ProtectFlag("", FlagProtectionBlocked)
if ctx.ProtectedFlags != nil {
t.Fatalf("empty flag initialized protection map: %#v", ctx.ProtectedFlags)
}
ctx.ProtectFlag("uid", FlagProtectionAmbiguous)
if !ctx.IsFlagProtected("uid") || ctx.IsFlagProtected("missing") {
t.Fatalf("protection lookup mismatch: %#v", ctx.ProtectedFlags)
}
err := (&FlagConflictError{
Command: "dws demo run",
Canonical: "user",
Spellings: []string{"--user-id", "uid"},
}).Error()
if !strings.Contains(err, `for --user on "dws demo run": --user-id, --uid`) {
t.Fatalf("FlagConflictError.Error() = %q", err)
}
boolErr := (&BoolValueConflictError{
Command: "dws demo run",
Flag: "--yes",
Values: []string{"true", "false"},
}).Error()
if !strings.Contains(boolErr, `for --yes on "dws demo run": false, true`) {
t.Fatalf("BoolValueConflictError.Error() = %q", boolErr)
}
}
func TestPhaseString(t *testing.T) {
tests := []struct {
phase Phase
+153
View File
@@ -0,0 +1,153 @@
// 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 pipeline
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
const maxFlagEditDistance = 2
// NormalizeFlagToken folds a long flag's morphological spelling and returns a
// canonical token only when the folded name is a real flag. Both the normal
// PreParse handler chain and Cobra command traversal use this primitive so a
// spelling accepted after a command is also recognised before it.
func NormalizeFlagToken(argument string, known map[string]bool) (string, bool) {
if !strings.HasPrefix(argument, "--") {
return "", false
}
bare := argument[2:]
if bare == "" {
return "", false
}
var suffix string
if index := strings.IndexByte(bare, '='); index >= 0 {
suffix = bare[index:]
bare = bare[:index]
}
if known[bare] {
return "", false
}
normalized := cmdutil.Morph(bare)
if normalized == bare || !known[normalized] {
return "", false
}
return "--" + normalized + suffix, true
}
// StickyFlagPair is the canonical flag and value resolved from one glued
// long-flag token. Inline is required for boolean flags because pflag treats a
// bare boolean as true without consuming the following argv token.
type StickyFlagPair struct {
Flag string
Value string
Inline bool
}
// SplitStickyFlag splits a safely recognisable glued flag/value token. The
// suffix must satisfy the real flag's type/format/enum contract, preventing a
// typo from being reinterpreted as data.
func SplitStickyFlag(argument string, specByName map[string]FlagInfo) (StickyFlagPair, bool) {
if !strings.HasPrefix(argument, "--") || strings.Contains(argument, "=") {
return StickyFlagPair{}, false
}
bare := argument[2:]
if bare == "" {
return StickyFlagPair{}, false
}
if _, ok := specByName[bare]; ok {
return StickyFlagPair{}, false
}
if _, ok := specByName[cmdutil.Morph(bare)]; ok {
return StickyFlagPair{}, false
}
for index := len(bare) - 1; index >= 1; index-- {
prefix := bare[:index]
matchedFlag := ""
if _, ok := specByName[prefix]; ok {
matchedFlag = prefix
} else if normalized := cmdutil.Morph(prefix); normalized != "" {
if _, ok := specByName[normalized]; ok {
matchedFlag = normalized
}
}
if matchedFlag == "" {
continue
}
suffix := bare[index:]
spec := specByName[matchedFlag]
if !cmdutil.SuffixLooksLikeValue(suffix, spec.Type, spec.Format, spec.Enum) {
return StickyFlagPair{}, false
}
inline := false
if spec.Type == "bool" || spec.Type == "boolean" {
suffix, _ = cmdutil.NormalizeBoolLiteral(suffix)
inline = true
}
return StickyFlagPair{Flag: "--" + matchedFlag, Value: suffix, Inline: inline}, true
}
return StickyFlagPair{}, false
}
// FuzzyMatchFlag returns the unique closest real long flag within the
// conservative edit-distance threshold used by ParamNameHandler.
func FuzzyMatchFlag(argument string, known map[string]bool, candidates []string) (string, bool) {
if !strings.HasPrefix(argument, "--") {
return "", false
}
bare := argument[2:]
if bare == "" {
return "", false
}
var suffix string
if index := strings.IndexByte(bare, '='); index >= 0 {
suffix = bare[index:]
bare = bare[:index]
}
if known[bare] {
return "", false
}
threshold := maxFlagEditDistance
if len(bare) <= 3 {
threshold = 1
}
bestDistance := threshold + 1
bestMatch := ""
ambiguous := false
for _, candidate := range candidates {
distance := cmdutil.LevenshteinDist(bare, candidate)
if distance < bestDistance {
bestDistance = distance
bestMatch = candidate
ambiguous = false
} else if distance == bestDistance && candidate != bestMatch {
ambiguous = true
}
}
if bestDistance > threshold || ambiguous || bestMatch == "" {
return "", false
}
return "--" + bestMatch + suffix, true
}
+101
View File
@@ -0,0 +1,101 @@
// 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 pipeline
import "testing"
func TestNormalizeFlagTokenDirectContract(t *testing.T) {
known := map[string]bool{"user-id": true}
tests := []struct {
argument string
want string
ok bool
}{
{argument: "-u"},
{argument: "--"},
{argument: "--user-id"},
{argument: "--unknown"},
{argument: "--unknownFlag"},
{argument: "--userId=42", want: "--user-id=42", ok: true},
}
for _, test := range tests {
got, ok := NormalizeFlagToken(test.argument, known)
if got != test.want || ok != test.ok {
t.Errorf("NormalizeFlagToken(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok)
}
}
}
func TestSplitStickyFlagDirectContract(t *testing.T) {
specs := map[string]FlagInfo{
"limit": {Name: "limit", Type: "int"},
"page-size": {Name: "page-size", Type: "int"},
"yes": {Name: "yes", Type: "bool"},
}
tests := []struct {
argument string
want StickyFlagPair
ok bool
}{
{argument: "-limit100"},
{argument: "--limit=100"},
{argument: "--"},
{argument: "--limit"},
{argument: "--pageSize"},
{argument: "--unknown100"},
{argument: "--limitabc"},
{argument: "--yesfalse", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true},
{argument: "--yestrue", want: StickyFlagPair{Flag: "--yes", Value: "true", Inline: true}, ok: true},
{argument: "--yesno", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true},
{argument: "--yesmaybe"},
{argument: "--limit100", want: StickyFlagPair{Flag: "--limit", Value: "100"}, ok: true},
{argument: "--pageSize50", want: StickyFlagPair{Flag: "--page-size", Value: "50"}, ok: true},
}
for _, test := range tests {
got, ok := SplitStickyFlag(test.argument, specs)
if got != test.want || ok != test.ok {
t.Errorf("SplitStickyFlag(%q) = %#v, %v; want %#v, %v", test.argument, got, ok, test.want, test.ok)
}
}
}
func TestFuzzyMatchFlagDirectContract(t *testing.T) {
known := map[string]bool{"limit": true, "name": true, "nave": true, "id": true}
candidates := []string{"limit", "name", "nave", "id"}
tests := []struct {
argument string
candidates []string
useCandidates bool
want string
ok bool
}{
{argument: "-limt"},
{argument: "--"},
{argument: "--limit"},
{argument: "--xy"},
{argument: "--nae"},
{argument: "--nothing", useCandidates: true},
{argument: "--limt=10", want: "--limit=10", ok: true},
}
for _, test := range tests {
caseCandidates := candidates
if test.useCandidates {
caseCandidates = test.candidates
}
got, ok := FuzzyMatchFlag(test.argument, known, caseCandidates)
if got != test.want || ok != test.ok {
t.Errorf("FuzzyMatchFlag(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok)
}
}
}
+12 -74
View File
@@ -14,10 +14,8 @@
package handlers
import (
"strings"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// AliasHandler normalises flag names in raw argv so that common
@@ -46,6 +44,10 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error {
result := make([]string, 0, len(ctx.Args))
for i, arg := range ctx.Args {
if arg == "--" {
result = append(result, ctx.Args[i:]...)
break
}
rewritten, ok := tryNormaliseFlag(arg, known)
if ok {
ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias")
@@ -63,40 +65,14 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error {
// normalised to a known flag name. It handles both bare flags and
// "--flag=value" syntax.
func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
if !strings.HasPrefix(arg, "--") {
return "", false
}
bare := arg[2:]
if bare == "" {
return "", false
}
// Handle --flag=value syntax: split, normalise the key, reassemble.
var suffix string
if idx := strings.IndexByte(bare, '='); idx >= 0 {
suffix = bare[idx:] // includes "="
bare = bare[:idx]
}
// Already a known flag in its current form — no change needed.
if known[bare] {
return "", false
}
normalised := toKebabCase(bare)
if normalised == bare {
return "", false
}
if !known[normalised] {
return "", false
}
return "--" + normalised + suffix, true
return pipeline.NormalizeFlagToken(arg, known)
}
// toKebabCase converts a string from camelCase, PascalCase, or
// snake_case to kebab-case. Examples:
// toKebabCase converts a string from camelCase, PascalCase, or snake_case to
// kebab-case. It is a thin compatibility shim over the single shared
// normaliser cmdutil.Morph so the pipeline handlers and the build-time
// parameter-alias generator can never diverge on how a flag spelling is
// folded. Examples:
//
// "userId" → "user-id"
// "UserName" → "user-name"
@@ -104,43 +80,5 @@ func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
// "USER_ID" → "user-id"
// "pageSize" → "page-size"
func toKebabCase(s string) string {
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s) + 4) // small extra for hyphens
runes := []rune(s)
for i, r := range runes {
if r == '_' || r == ' ' {
if b.Len() > 0 {
b.WriteByte('-')
}
continue
}
if unicode.IsUpper(r) {
// Insert hyphen before an uppercase letter when:
// 1. Not at start, AND
// 2. Previous char was lowercase, OR
// 3. Next char is lowercase (handles "userID" → "user-id"
// at the boundary between "I" and "D" in "ID" we don't
// split, but "IDs" → we split before "s" which is
// handled by the lowercase check at the next iteration).
if i > 0 {
prev := runes[i-1]
if unicode.IsLower(prev) {
b.WriteByte('-')
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
b.WriteByte('-')
}
}
b.WriteRune(unicode.ToLower(r))
} else {
b.WriteRune(unicode.ToLower(r))
}
}
return strings.Trim(b.String(), "-")
return cmdutil.Morph(s)
}
+6
View File
@@ -184,3 +184,9 @@ func TestAliasHandlerNameAndPhase(t *testing.T) {
t.Errorf("Phase() = %v, want PreParse", h.Phase())
}
}
func TestTryNormaliseFlagRejectsBareDoubleDash(t *testing.T) {
if got, ok := tryNormaliseFlag("--", map[string]bool{"limit": true}); ok || got != "" {
t.Fatalf("tryNormaliseFlag(--) = %q, %v", got, ok)
}
}
+162
View File
@@ -0,0 +1,162 @@
// 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 handlers
import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// BoolValueHandler gives every real Cobra boolean flag one consistent input
// grammar before pflag parsing. pflag interprets `--yes false` as a bare
// `--yes` (true) plus a positional `false`; rewriting the pair to
// `--yes=false` preserves the value the caller actually supplied. The same
// rule applies to default-true, required, local, and inherited boolean flags:
// the handler preserves an explicit value and never substitutes a default.
//
// This handler runs after all flag-name handlers so camelCase, semantic
// aliases, and conservative fuzzy corrections have already reached their
// canonical names. It consumes only recognised boolean literals, stops at
// `--`, and rejects contradictory values for the same canonical flag so the
// result cannot depend on argv order.
type BoolValueHandler struct{}
func (BoolValueHandler) Name() string { return "boolvalue" }
func (BoolValueHandler) Phase() pipeline.Phase { return pipeline.PreParse }
func (BoolValueHandler) Handle(ctx *pipeline.Context) error {
if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
return nil
}
longNames := make(map[string]pipeline.FlagInfo)
shortNames := make(map[string]pipeline.FlagInfo)
for _, spec := range ctx.FlagSpecs {
if spec.Name == "" || (spec.Type != "bool" && spec.Type != "boolean") {
continue
}
longNames[spec.Name] = spec
if spec.Shorthand != "" {
shortNames[spec.Shorthand] = spec
}
}
result := make([]string, 0, len(ctx.Args))
valuesByFlag := make(map[string]map[string]bool)
for index := 0; index < len(ctx.Args); index++ {
argument := ctx.Args[index]
if argument == "--" {
result = append(result, ctx.Args[index:]...)
break
}
spec, inlineValue, hasInlineValue, matched := matchBooleanFlagToken(argument, longNames, shortNames)
if !matched || ctx.IsFlagProtected(cmdutil.Morph(spec.Name)) {
result = append(result, argument)
continue
}
normalized := "true"
corrected := argument
original := argument
changed := false
if hasInlineValue {
var ok bool
normalized, ok = cmdutil.NormalizeBoolLiteral(inlineValue)
if !ok {
result = append(result, argument)
continue
}
corrected = "--" + spec.Name + "=" + normalized
changed = corrected != argument
} else if index+1 < len(ctx.Args) {
if value, ok := cmdutil.NormalizeBoolLiteral(ctx.Args[index+1]); ok {
normalized = value
corrected = "--" + spec.Name + "=" + normalized
original = strings.Join(ctx.Args[index:index+2], " ")
changed = true
index++
}
}
if valuesByFlag[spec.Name] == nil {
valuesByFlag[spec.Name] = make(map[string]bool)
}
valuesByFlag[spec.Name][normalized] = true
if changed {
ctx.AddCorrection("boolvalue", pipeline.PreParse, "--"+spec.Name, original, corrected, "explicit-bool")
}
result = append(result, corrected)
}
ctx.Args = result
names := make([]string, 0, len(valuesByFlag))
for name := range valuesByFlag {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
values := valuesByFlag[name]
if len(values) < 2 {
continue
}
list := make([]string, 0, len(values))
for value := range values {
list = append(list, value)
}
sort.Strings(list)
return &pipeline.BoolValueConflictError{
Command: ctx.Command,
Flag: name,
Values: list,
}
}
return nil
}
// matchBooleanFlagToken recognises canonical long flags, exact shorthands,
// and their explicit =value forms. Shorthand clusters remain native pflag
// syntax and are intentionally not reinterpreted here.
func matchBooleanFlagToken(argument string, longNames, shortNames map[string]pipeline.FlagInfo) (pipeline.FlagInfo, string, bool, bool) {
if strings.HasPrefix(argument, "--") {
bare, suffix, isFlag := splitFlagToken(argument)
if !isFlag {
return pipeline.FlagInfo{}, "", false, false
}
spec, ok := longNames[bare]
if !ok {
return pipeline.FlagInfo{}, "", false, false
}
if suffix == "" {
return spec, "", false, true
}
return spec, strings.TrimPrefix(suffix, "="), true, true
}
if !strings.HasPrefix(argument, "-") || strings.HasPrefix(argument, "--") {
return pipeline.FlagInfo{}, "", false, false
}
body := strings.TrimPrefix(argument, "-")
name, value, hasValue := body, "", false
if index := strings.IndexByte(body, '='); index >= 0 {
name, value, hasValue = body[:index], body[index+1:], true
}
spec, ok := shortNames[name]
if !ok {
return pipeline.FlagInfo{}, "", false, false
}
return spec, value, hasValue, true
}
@@ -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.
package handlers
import (
"errors"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestBoolValueHandler(t *testing.T) {
boolFlags := []pipeline.FlagInfo{{Name: "yes", Shorthand: "y", Type: "bool"}}
tests := []struct {
name string
args []string
flags []pipeline.FlagInfo
protected []string
want []string
corrections int
conflict bool
}{
{name: "long false", args: []string{"--yes", "false"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "long mixed-case false", args: []string{"--yes", "FaLsE"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "long true synonym", args: []string{"--yes", "yes"}, flags: boolFlags, want: []string{"--yes=true"}, corrections: 1},
{name: "shorthand zero", args: []string{"-y", "0"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "shorthand equals synonym", args: []string{"-y=off"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "preserves following flags", args: []string{"--yes", "off", "--format", "json"}, flags: boolFlags, want: []string{"--yes=false", "--format", "json"}, corrections: 1},
{name: "explicit equals stays native", args: []string{"--yes=false"}, flags: boolFlags, want: []string{"--yes=false"}},
{name: "explicit equals synonym normalizes", args: []string{"--yes=No"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "bare bool stays native", args: []string{"--yes"}, flags: boolFlags, want: []string{"--yes"}},
{name: "invalid literal is positional", args: []string{"--yes", "maybe"}, flags: boolFlags, want: []string{"--yes", "maybe"}},
{name: "invalid inline literal stays native", args: []string{"--yes=maybe"}, flags: boolFlags, want: []string{"--yes=maybe"}},
{name: "non bool flag is unchanged", args: []string{"--name", "false"}, flags: []pipeline.FlagInfo{{Name: "name", Type: "string"}}, want: []string{"--name", "false"}},
{name: "unknown flag is unchanged", args: []string{"--confirm", "false"}, flags: boolFlags, want: []string{"--confirm", "false"}},
{name: "shorthand cluster is unchanged", args: []string{"-vy", "false"}, flags: boolFlags, want: []string{"-vy", "false"}},
{name: "protected bool is unchanged", args: []string{"--yes", "false"}, flags: boolFlags, protected: []string{"yes"}, want: []string{"--yes", "false"}},
{name: "protected noncanonical bool uses morphed key", args: []string{"--dry_run", "false"}, flags: []pipeline.FlagInfo{{Name: "dry_run", Type: "bool"}}, protected: []string{"dry-run"}, want: []string{"--dry_run", "false"}},
{name: "stops at double dash", args: []string{"--", "--yes", "false"}, flags: boolFlags, want: []string{"--", "--yes", "false"}},
{name: "no specs", args: []string{"--yes", "false"}, want: []string{"--yes", "false"}},
{name: "identical repeated values remain valid", args: []string{"--yes", "--yes=true", "--yes", "yes"}, flags: boolFlags, want: []string{"--yes", "--yes=true", "--yes=true"}, corrections: 1},
{name: "contradictory detached values fail", args: []string{"--yes", "true", "--yes", "false"}, flags: boolFlags, want: []string{"--yes=true", "--yes=false"}, corrections: 2, conflict: true},
{name: "bare and explicit false fail", args: []string{"--yes", "--yes=false"}, flags: boolFlags, want: []string{"--yes", "--yes=false"}, conflict: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := &pipeline.Context{
Args: append([]string(nil), test.args...),
FlagSpecs: test.flags,
}
for _, protected := range test.protected {
ctx.ProtectFlag(protected, pipeline.FlagProtectionBlocked)
}
handler := BoolValueHandler{}
err := handler.Handle(ctx)
var conflict *pipeline.BoolValueConflictError
if test.conflict {
if !errors.As(err, &conflict) {
t.Fatalf("Handle() error = %v, want BoolValueConflictError", err)
}
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
t.Fatalf("conflict = %#v", conflict)
}
} else if err != nil {
t.Fatalf("Handle() error = %v", err)
}
if !reflect.DeepEqual(ctx.Args, test.want) {
t.Fatalf("Args = %#v, want %#v", ctx.Args, test.want)
}
if len(ctx.Corrections) != test.corrections {
t.Fatalf("Corrections = %#v, want %d", ctx.Corrections, test.corrections)
}
if test.corrections > 0 {
correction := ctx.Corrections[0]
if correction.Handler != "boolvalue" || correction.Kind != "explicit-bool" || correction.Field != "--yes" {
t.Fatalf("correction metadata = %#v", correction)
}
}
})
}
}
func TestMatchBooleanFlagToken(t *testing.T) {
longNames := map[string]pipeline.FlagInfo{"yes": {Name: "yes", Shorthand: "y", Type: "bool"}}
shortNames := map[string]pipeline.FlagInfo{"y": longNames["yes"]}
tests := []struct {
argument string
value string
hasValue bool
matched bool
}{
{argument: "--yes", matched: true},
{argument: "--yes=false", value: "false", hasValue: true, matched: true},
{argument: "-y", matched: true},
{argument: "-y=true", value: "true", hasValue: true, matched: true},
{argument: "-vy"},
{argument: "--unknown=false"},
{argument: "yes"},
{argument: "--"},
}
for _, test := range tests {
t.Run(test.argument, func(t *testing.T) {
spec, value, hasValue, matched := matchBooleanFlagToken(test.argument, longNames, shortNames)
if value != test.value || hasValue != test.hasValue || matched != test.matched {
t.Fatalf("matchBooleanFlagToken(%q) = %#v, %q, %v, %v", test.argument, spec, value, hasValue, matched)
}
if matched && spec.Name != "yes" {
t.Fatalf("matched spec = %#v", spec)
}
})
}
}
func TestBoolValueHandlerMeta(t *testing.T) {
handler := BoolValueHandler{}
if handler.Name() != "boolvalue" || handler.Phase() != pipeline.PreParse {
t.Fatalf("handler metadata = %q/%v", handler.Name(), handler.Phase())
}
}
+74 -6
View File
@@ -21,7 +21,7 @@ import (
)
// TestFullPreParsePipeline exercises the complete PreParse handler
// chain: AliasHandler → StickyHandler → ParamNameHandler. It
// chain: AliasHandler → StickyHandler → ParamNameHandler → BoolValueHandler. It
// simulates a model-generated CLI invocation with multiple errors
// and verifies the pipeline corrects all of them in one pass.
func TestFullPreParsePipeline(t *testing.T) {
@@ -30,6 +30,7 @@ func TestFullPreParsePipeline(t *testing.T) {
AliasHandler{},
StickyHandler{},
ParamNameHandler{},
BoolValueHandler{},
)
// Numeric / boolean flag typing matters for the sticky guard. The
@@ -106,6 +107,20 @@ func TestFullPreParsePipeline(t *testing.T) {
want: "--limit-value 100",
corrections: 1, // sticky handles both kebab-normalisation and split
},
{
name: "camelCase bool with detached value",
args: []string{"--dryRun", "false"},
flags: []pipeline.FlagInfo{{Name: "dry-run", Type: "bool"}},
want: "--dry-run=false",
corrections: 2, // alias(dryRun) + boolvalue(false)
},
{
name: "fuzzy bool with detached value",
args: []string{"--yess", "no"},
flags: []pipeline.FlagInfo{{Name: "yes", Type: "bool"}},
want: "--yes=false",
corrections: 2, // paramname(yess) + boolvalue(no)
},
// Hardening: a mistyped flag whose name happens to start with
// a real flag must NOT be split. The pipeline should leave the
@@ -145,6 +160,56 @@ func TestFullPreParsePipeline(t *testing.T) {
}
}
func TestSemanticProtectionSurvivesStickyAndParamName(t *testing.T) {
engine := pipeline.NewEngine()
engine.RegisterAll(
SemanticAliasHandler{Lookup: fakeLookup(nil, []string{"limt", "limit100"}, nil)},
StickyHandler{},
ParamNameHandler{},
)
ctx := &pipeline.Context{
Command: "dws demo cmd",
Args: []string{"--limt", "10", "--limit100"},
FlagSpecs: []pipeline.FlagInfo{
{Name: "limit", Type: "int"},
},
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("RunPhase() error = %v", err)
}
if got, want := strings.Join(ctx.Args, " "), "--limt 10 --limit100"; got != want {
t.Fatalf("protected args = %q, want %q", got, want)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("protected args were corrected: %#v", ctx.Corrections)
}
}
func TestFullPreParsePipelineStopsAtDoubleDash(t *testing.T) {
engine := pipeline.NewEngine()
engine.RegisterAll(
AliasHandler{},
SemanticAliasHandler{Lookup: fakeLookup(map[string]string{"keyword": "query"}, nil, nil)},
StickyHandler{},
ParamNameHandler{},
BoolValueHandler{},
)
ctx := &pipeline.Context{
Command: "dws demo cmd",
Args: []string{"--query", "before", "--", "--keyword", "--limit100", "--limt"},
FlagSpecs: []pipeline.FlagInfo{
{Name: "query", Type: "string"},
{Name: "limit", Type: "int"},
},
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("RunPhase() error = %v", err)
}
if got, want := strings.Join(ctx.Args, " "), "--query before -- --keyword --limit100 --limt"; got != want {
t.Fatalf("args after -- = %q, want %q", got, want)
}
}
// TestFullPostParsePipeline exercises the PostParse handler chain
// with the ParamValueHandler normalising multiple value types in a
// single invocation.
@@ -227,7 +292,7 @@ func TestFullPipelineEndToEnd(t *testing.T) {
t.Fatalf("PreParse error: %v", err)
}
want := "--user-id u001 --page-size 50 --verbose true"
want := "--user-id u001 --page-size 50 --verbose=true"
got := strings.Join(ctx.Args, " ")
if got != want {
t.Errorf("after PreParse: Args = %q, want %q", got, want)
@@ -284,6 +349,7 @@ func TestFullFivePhasePipeline(t *testing.T) {
AliasHandler{},
StickyHandler{},
ParamNameHandler{},
BoolValueHandler{},
ParamValueHandler{},
PreRequestHandler{},
PostResponseHandler{},
@@ -326,7 +392,7 @@ func TestFullFivePhasePipeline(t *testing.T) {
t.Fatalf("PreParse error: %v", err)
}
want := "--user-id u001 --page-size 50 --verbose true"
want := "--user-id u001 --page-size 50 --verbose=true"
got := strings.Join(ctx.Args, " ")
if got != want {
t.Errorf("after PreParse: Args = %q, want %q", got, want)
@@ -426,6 +492,7 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
AliasHandler{},
StickyHandler{},
ParamNameHandler{},
BoolValueHandler{},
ParamValueHandler{},
PreRequestHandler{},
PostResponseHandler{},
@@ -436,7 +503,7 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
want int
}{
{pipeline.Register, 1},
{pipeline.PreParse, 3},
{pipeline.PreParse, 4},
{pipeline.PostParse, 1},
{pipeline.PreRequest, 1},
{pipeline.PostResponse, 1},
@@ -446,8 +513,8 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
t.Errorf("Handlers(%v) = %d, want %d", tt.phase, got, tt.want)
}
}
if got := engine.HandlerCount(); got != 7 {
t.Errorf("HandlerCount = %d, want 7", got)
if got := engine.HandlerCount(); got != 8 {
t.Errorf("HandlerCount = %d, want 8", got)
}
}
@@ -473,6 +540,7 @@ func TestPreParseDoesNotBreakValidArgs(t *testing.T) {
AliasHandler{},
StickyHandler{},
ParamNameHandler{},
BoolValueHandler{},
)
original := []string{
+10 -51
View File
@@ -14,8 +14,6 @@
package handlers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
@@ -35,8 +33,6 @@ import (
// done and fuzzy matching only handles genuine near-misses.
type ParamNameHandler struct{}
const maxEditDistance = 2
func (ParamNameHandler) Name() string { return "paramname" }
func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse }
@@ -54,7 +50,15 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error {
}
result := make([]string, 0, len(ctx.Args))
for _, arg := range ctx.Args {
for i, arg := range ctx.Args {
if arg == "--" {
result = append(result, ctx.Args[i:]...)
break
}
if bare, _, isFlag := splitFlagToken(arg); isFlag && ctx.IsFlagProtected(cmdutil.Morph(bare)) {
result = append(result, arg)
continue
}
rewritten, ok := tryFuzzyMatch(arg, known, names)
if ok {
ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy")
@@ -71,50 +75,5 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error {
// tryFuzzyMatch attempts to correct an unrecognised "--flag" token by
// finding the closest known flag name within the edit distance threshold.
func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) {
if !strings.HasPrefix(arg, "--") {
return "", false
}
bare := arg[2:]
if bare == "" {
return "", false
}
// Handle --flag=value syntax.
var suffix string
if idx := strings.IndexByte(bare, '='); idx >= 0 {
suffix = bare[idx:]
bare = bare[:idx]
}
// Already known — nothing to fix.
if known[bare] {
return "", false
}
threshold := maxEditDistance
if len(bare) <= 3 {
threshold = 1
}
bestDist := threshold + 1
bestMatch := ""
ambiguous := false
for _, candidate := range candidates {
dist := cmdutil.LevenshteinDist(bare, candidate)
if dist < bestDist {
bestDist = dist
bestMatch = candidate
ambiguous = false
} else if dist == bestDist && candidate != bestMatch {
ambiguous = true
}
}
if bestDist > threshold || ambiguous || bestMatch == "" {
return "", false
}
return "--" + bestMatch + suffix, true
return pipeline.FuzzyMatchFlag(arg, known, candidates)
}
@@ -0,0 +1,171 @@
// 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 handlers
import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// SemanticAliasHandler rewrites semantic parameter synonyms to a command's
// canonical real flag using the build-time reduced alias table. Where
// AliasHandler only folds a spelling that already matches a real flag
// (--userId → --user-id), this handler resolves a different word to the real
// flag the command actually accepts (--keyword → --query) based on the
// reviewed concept dictionary.
//
// It runs in PreParse after AliasHandler (morphology first) and before sticky
// and paramname. The alias table is injected as Lookup so this handler never
// imports the cli package; root.go wires cli.LookupParamAlias in.
type SemanticAliasHandler struct {
// Lookup returns the aliases/blocked/ambiguous sets reduced for a raw
// Cobra CommandPath, or ok=false when the command has no reduced entry.
// Keys are already morphed (cmdutil.Morph), matching how the table is built.
Lookup func(rawCommandPath string) (aliases map[string]string, blocked, ambiguous []string, ok bool)
}
func (SemanticAliasHandler) Name() string { return "semantic-alias" }
func (SemanticAliasHandler) Phase() pipeline.Phase { return pipeline.PreParse }
func (h SemanticAliasHandler) Handle(ctx *pipeline.Context) error {
if h.Lookup == nil || ctx.Command == "" || len(ctx.Args) == 0 {
return nil
}
aliases, blocked, ambiguous, ok := h.Lookup(ctx.Command)
if !ok {
return nil
}
for _, name := range blocked {
ctx.ProtectFlag(name, pipeline.FlagProtectionBlocked)
}
for _, name := range ambiguous {
ctx.ProtectFlag(name, pipeline.FlagProtectionAmbiguous)
}
if err := rejectMixedAliasSpellings(ctx, aliases); err != nil {
return err
}
for i, arg := range ctx.Args {
if arg == "--" {
break
}
bare, suffix, isFlag := splitFlagToken(arg)
if !isFlag {
continue
}
morphed := cmdutil.Morph(bare)
// A blocked or intentionally ambiguous name must never be silently
// rewritten: it is left untouched so the unknown-flag did-you-mean
// path can surface the reviewed candidates instead of guessing.
if ctx.IsFlagProtected(morphed) {
continue
}
canon, hit := aliases[morphed]
if !hit || canon == bare {
continue
}
rewritten := "--" + canon + suffix
ctx.Args[i] = rewritten
ctx.AddCorrection("semantic-alias", pipeline.PreParse, canon, arg, rewritten, "semantic")
}
return nil
}
func rejectMixedAliasSpellings(ctx *pipeline.Context, aliases map[string]string) error {
if len(aliases) == 0 {
return nil
}
targetByMorph := make(map[string]string, len(aliases))
for _, canonical := range aliases {
targetByMorph[cmdutil.Morph(canonical)] = canonical
}
spellingsByTarget := make(map[string]map[string]bool)
hasAliasByTarget := make(map[string]bool)
for _, arg := range ctx.Args {
if arg == "--" {
break
}
bare, _, isFlag := splitFlagToken(arg)
if !isFlag {
continue
}
morphed := cmdutil.Morph(bare)
if ctx.IsFlagProtected(morphed) {
continue
}
canonical, isAlias := aliases[morphed]
if !isAlias {
canonical = targetByMorph[morphed]
}
if canonical == "" {
continue
}
if spellingsByTarget[canonical] == nil {
spellingsByTarget[canonical] = make(map[string]bool)
}
spellingsByTarget[canonical][morphed] = true
if isAlias {
hasAliasByTarget[canonical] = true
}
}
targets := make([]string, 0, len(spellingsByTarget))
for canonical := range spellingsByTarget {
targets = append(targets, canonical)
}
sort.Strings(targets)
for _, canonical := range targets {
spellings := spellingsByTarget[canonical]
if !hasAliasByTarget[canonical] || len(spellings) < 2 {
continue
}
list := make([]string, 0, len(spellings))
for spelling := range spellings {
list = append(list, spelling)
}
sort.Strings(list)
return &pipeline.FlagConflictError{
Command: ctx.Command,
Canonical: canonical,
Spellings: list,
}
}
return nil
}
// splitFlagToken splits a raw argv token into its bare flag name and any
// "=value" suffix. isFlag is false for anything that is not a "--flag" token
// (positional args, "-x" short flags, the bare "--" separator, or "--=v").
func splitFlagToken(arg string) (bare, suffix string, isFlag bool) {
if !strings.HasPrefix(arg, "--") {
return "", "", false
}
body := arg[2:]
if body == "" {
return "", "", false
}
if idx := strings.IndexByte(body, '='); idx >= 0 {
if idx == 0 {
return "", "", false
}
return body[:idx], body[idx:], true
}
return body, "", true
}
@@ -0,0 +1,184 @@
// 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 handlers
import (
"errors"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// fakeLookup returns a fixed table for the "dws demo cmd" command and ok=false
// for anything else, standing in for cli.LookupParamAlias in tests.
func fakeLookup(aliases map[string]string, blocked, ambiguous []string) func(string) (map[string]string, []string, []string, bool) {
return func(raw string) (map[string]string, []string, []string, bool) {
if raw != "dws demo cmd" {
return nil, nil, nil, false
}
return aliases, blocked, ambiguous, true
}
}
func newSemanticHandler() SemanticAliasHandler {
return SemanticAliasHandler{
Lookup: fakeLookup(
map[string]string{"keyword": "query", "page-size": "limit"},
[]string{"count"},
[]string{"user-id"},
),
}
}
func TestSemanticAliasHandlerRewritesSynonym(t *testing.T) {
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--keyword", "hello"}}
if err := newSemanticHandler().Handle(ctx); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if want := []string{"--query", "hello"}; !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("Args = %v, want %v", ctx.Args, want)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Kind != "semantic" || ctx.Corrections[0].Corrected != "--query" {
t.Fatalf("correction = %#v", ctx.Corrections)
}
}
func TestSemanticAliasHandlerNameAndPhase(t *testing.T) {
h := SemanticAliasHandler{}
if h.Name() != "semantic-alias" || h.Phase() != pipeline.PreParse {
t.Fatalf("handler identity = %q / %s", h.Name(), h.Phase())
}
}
func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) {
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}}
if err := newSemanticHandler().Handle(ctx); err != nil {
t.Fatalf("Handle() error = %v", err)
}
// --pageSize morphs to page-size, which the table aliases to limit.
if want := []string{"--limit=50"}; !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("Args = %v, want %v", ctx.Args, want)
}
}
func TestSemanticAliasHandlerLeavesBlockedAndAmbiguous(t *testing.T) {
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--count", "10", "--user-id", "u1"}}
if err := newSemanticHandler().Handle(ctx); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if want := []string{"--count", "10", "--user-id", "u1"}; !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("blocked/ambiguous names must not be rewritten: Args = %v, want %v", ctx.Args, want)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("no corrections expected, got %#v", ctx.Corrections)
}
if ctx.ProtectedFlags["count"] != pipeline.FlagProtectionBlocked || ctx.ProtectedFlags["user-id"] != pipeline.FlagProtectionAmbiguous {
t.Fatalf("protections = %#v", ctx.ProtectedFlags)
}
}
func TestSemanticAliasHandlerProtectsWithoutAliases(t *testing.T) {
h := SemanticAliasHandler{Lookup: fakeLookup(nil, []string{"limt"}, nil)}
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--limt", "10"}}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if !ctx.IsFlagProtected("limt") {
t.Fatalf("blocked-only entry did not populate pipeline protection: %#v", ctx.ProtectedFlags)
}
}
func TestSemanticAliasHandlerRejectsMixedAliasAndCanonical(t *testing.T) {
for _, args := range [][]string{
{"--keyword", "one", "--query", "two"},
{"--query=two", "--keyword=one"},
} {
ctx := &pipeline.Context{Command: "dws demo cmd", Args: args}
err := newSemanticHandler().Handle(ctx)
var conflict *pipeline.FlagConflictError
if !errors.As(err, &conflict) {
t.Fatalf("Handle(%v) error = %v, want FlagConflictError", args, err)
}
if conflict.Canonical != "query" || !reflect.DeepEqual(conflict.Spellings, []string{"keyword", "query"}) {
t.Fatalf("conflict = %#v", conflict)
}
}
}
func TestSemanticAliasHandlerStopsAtDoubleDash(t *testing.T) {
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "one", "--", "--keyword", "two"}}
if err := newSemanticHandler().Handle(ctx); err != nil {
t.Fatalf("Handle() error = %v", err)
}
if want := []string{"--query", "one", "--", "--keyword", "two"}; !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("Args = %v, want %v", ctx.Args, want)
}
}
func TestSemanticAliasHandlerNoOpCases(t *testing.T) {
h := newSemanticHandler()
// Unknown command → Lookup returns ok=false.
ctx := &pipeline.Context{Command: "dws other", Args: []string{"--keyword", "x"}}
_ = h.Handle(ctx)
if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) || len(ctx.Corrections) != 0 {
t.Fatalf("unknown command must be a no-op: %v / %#v", ctx.Args, ctx.Corrections)
}
// Empty command path.
ctx = &pipeline.Context{Command: "", Args: []string{"--keyword", "x"}}
_ = h.Handle(ctx)
if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) {
t.Fatalf("empty command must be a no-op: %v", ctx.Args)
}
// Nil Lookup (handler not wired).
ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--keyword", "x"}}
_ = SemanticAliasHandler{}.Handle(ctx)
if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) {
t.Fatalf("nil Lookup must be a no-op: %v", ctx.Args)
}
// A real flag that also appears nowhere in the table is left alone.
ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "x", "--unknown", "y", "positional", "-n"}}
_ = h.Handle(ctx)
if !reflect.DeepEqual(ctx.Args, []string{"--query", "x", "--unknown", "y", "positional", "-n"}) {
t.Fatalf("canonical/positional/short tokens must be untouched: %v", ctx.Args)
}
}
func TestSplitFlagToken(t *testing.T) {
cases := []struct {
arg string
bare string
suffix string
isFlag bool
}{
{"--query", "query", "", true},
{"--limit=50", "limit", "=50", true},
{"--pageSize", "pageSize", "", true},
{"positional", "", "", false},
{"-n", "", "", false},
{"--", "", "", false},
{"--=v", "", "", false},
}
for _, c := range cases {
bare, suffix, isFlag := splitFlagToken(c.arg)
if bare != c.bare || suffix != c.suffix || isFlag != c.isFlag {
t.Fatalf("splitFlagToken(%q) = (%q,%q,%v), want (%q,%q,%v)",
c.arg, bare, suffix, isFlag, c.bare, c.suffix, c.isFlag)
}
}
}
+25 -73
View File
@@ -14,15 +14,13 @@
package handlers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// StickyHandler detects glued flag-value pairs in raw argv and splits
// them into separate tokens. For example, "--limit100" becomes
// "--limit", "100" when "limit" is a known flag name.
// StickyHandler detects glued flag-value pairs in raw argv and normalizes
// them. For example, "--limit100" becomes "--limit", "100" while a boolean
// such as "--verbosefalse" becomes the single safe token "--verbose=false".
//
// The handler only operates on tokens that start with "--" and do not
// contain "=". It tries to match the longest known flag name prefix
@@ -45,11 +43,25 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
specByName := buildFlagSpecIndex(ctx.FlagSpecs)
result := make([]string, 0, len(ctx.Args))
for _, arg := range ctx.Args {
for i, arg := range ctx.Args {
if arg == "--" {
result = append(result, ctx.Args[i:]...)
break
}
if bare, _, isFlag := splitFlagToken(arg); isFlag && ctx.IsFlagProtected(cmdutil.Morph(bare)) {
result = append(result, arg)
continue
}
split, ok := trySplitSticky(arg, specByName)
if ok {
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
result = append(result, split.flag, split.value)
if split.inline {
corrected := split.flag + "=" + split.value
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, corrected, "sticky")
result = append(result, corrected)
} else {
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
result = append(result, split.flag, split.value)
}
} else {
result = append(result, arg)
}
@@ -60,8 +72,9 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
}
type stickyPair struct {
flag string
value string
flag string
value string
inline bool
}
// trySplitSticky checks if arg looks like a glued flag-value (e.g.
@@ -79,69 +92,8 @@ type stickyPair struct {
// that camelCase+glued values like "--pageSize50" are correctly
// split to "--page-size", "50".
func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) {
if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
return stickyPair{}, false
}
// Strip "--" prefix to work with the bare token.
bare := arg[2:]
if bare == "" {
return stickyPair{}, false
}
// If the whole token is a known flag, it is not sticky — it is
// a normal flag expecting a separate value token.
if _, ok := specByName[bare]; ok {
return stickyPair{}, false
}
if _, ok := specByName[toKebabCase(bare)]; ok {
return stickyPair{}, false
}
// Try longest-prefix match: walk from len-1 down to 1, looking
// for the longest known flag that is a prefix of bare. For each
// candidate prefix, try both the raw form and kebab-case form.
bestLen := 0
bestFlag := ""
for i := len(bare) - 1; i >= 1; i-- {
prefix := bare[:i]
matchedFlag := ""
if _, ok := specByName[prefix]; ok {
matchedFlag = prefix
} else {
kebab := toKebabCase(prefix)
if kebab != "" {
if _, ok := specByName[kebab]; ok {
matchedFlag = kebab
}
}
}
if matchedFlag != "" && i > bestLen {
bestLen = i
bestFlag = matchedFlag
break // longest first since we walk from the end
}
}
if bestFlag == "" {
return stickyPair{}, false
}
suffix := bare[bestLen:]
// Guard: only split if the suffix plausibly looks like a value
// for this flag's declared type/format/enum. Otherwise leave the
// token untouched so Cobra reports "unknown flag" instead of
// silently corrupting the value.
fi := specByName[bestFlag]
if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) {
return stickyPair{}, false
}
return stickyPair{
flag: "--" + bestFlag,
value: suffix,
}, true
pair, ok := pipeline.SplitStickyFlag(arg, specByName)
return stickyPair{flag: pair.Flag, value: pair.Value, inline: pair.Inline}, ok
}
// buildFlagSpecIndex creates an index of known flag names (without "--"
+16 -2
View File
@@ -157,10 +157,24 @@ func TestStickyHandler(t *testing.T) {
corrections: 0,
},
{
name: "boolean-like value splits when type is bool",
name: "boolean-like value normalizes inline when type is bool",
args: []string{"--verbosetrue"},
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
want: "--verbose true",
want: "--verbose=true",
corrections: 1,
},
{
name: "confirmation false normalizes to an inline false value",
args: []string{"--yesfalse"},
flags: specs(flagSpec{name: "yes", typ: "bool"}),
want: "--yes=false",
corrections: 1,
},
{
name: "model-friendly boolean no normalizes to inline false",
args: []string{"--yesno"},
flags: specs(flagSpec{name: "yes", typ: "bool"}),
want: "--yes=false",
corrections: 1,
},
{
+87 -2
View File
@@ -13,6 +13,12 @@
package pipeline
import (
"fmt"
"sort"
"strings"
)
// Phase represents a named stage in the CLI execution pipeline.
// Handlers are grouped by phase and executed in chain order within
// each phase. Phases themselves execute in a fixed order defined
@@ -77,8 +83,11 @@ type Context struct {
// PreParse handlers may rewrite this in place.
Args []string
// Command is the resolved product.tool canonical path
// (available from PostParse onward).
// Command identifies the resolved command. RunPreParse fills it with
// Cobra's raw CommandPath() (e.g. "dws chat message send-by-bot") so
// PreParse handlers can key per-command tables; the PostParse pipeline
// fills it with the resolved product.tool canonical path. The two phases
// use independent Context instances, so the differing forms never mix.
Command string
// Params holds structured key→value parameters after Cobra
@@ -105,11 +114,82 @@ type Context struct {
// handlers use this to match against raw argv tokens.
FlagSpecs []FlagInfo
// ProtectedFlags carries reviewed semantic guard decisions across the
// complete PreParse chain. Keys are morphed flag names. Sticky and fuzzy
// handlers must not reinterpret a name classified as blocked or ambiguous
// by the semantic alias table.
ProtectedFlags map[string]FlagProtection
// Corrections records every correction applied by handlers,
// enabling downstream logging and debugging.
Corrections []Correction
}
// FlagProtection identifies why an emitted flag name must not be automatically
// rewritten.
type FlagProtection string
const (
FlagProtectionBlocked FlagProtection = "blocked"
FlagProtectionAmbiguous FlagProtection = "ambiguous"
)
// ProtectFlag records a reviewed no-touch decision for the remainder of the
// current pipeline context.
func (c *Context) ProtectFlag(morphed string, protection FlagProtection) {
if c == nil || morphed == "" {
return
}
if c.ProtectedFlags == nil {
c.ProtectedFlags = make(map[string]FlagProtection)
}
c.ProtectedFlags[morphed] = protection
}
// IsFlagProtected reports whether a morphed flag name is guarded from further
// automatic interpretation.
func (c *Context) IsFlagProtected(morphed string) bool {
if c == nil {
return false
}
_, ok := c.ProtectedFlags[morphed]
return ok
}
// FlagConflictError is returned when multiple distinct spellings that reduce
// to one scalar canonical flag are present in the same argv. Rejecting the
// command makes the outcome independent of argument order.
type FlagConflictError struct {
Command string
Canonical string
Spellings []string
}
func (e *FlagConflictError) Error() string {
spellings := append([]string(nil), e.Spellings...)
sort.Strings(spellings)
for i := range spellings {
spellings[i] = "--" + strings.TrimPrefix(spellings[i], "--")
}
return fmt.Sprintf("conflicting parameter spellings for --%s on %q: %s; pass exactly one spelling", e.Canonical, e.Command, strings.Join(spellings, ", "))
}
// BoolValueConflictError is returned when one canonical boolean flag receives
// both true and false in the same argv. Rejecting contradictory values keeps
// the outcome independent of argument order while allowing repeated identical
// spellings to retain Cobra's native behaviour.
type BoolValueConflictError struct {
Command string
Flag string
Values []string
}
func (e *BoolValueConflictError) Error() string {
values := append([]string(nil), e.Values...)
sort.Strings(values)
return fmt.Sprintf("conflicting boolean values for --%s on %q: %s; pass exactly one value", strings.TrimPrefix(e.Flag, "--"), e.Command, strings.Join(values, ", "))
}
// FlagInfo describes a single CLI flag derived from a tool's input
// schema. PreParse handlers use this to recognise valid flag names
// when performing fuzzy matching or alias resolution.
@@ -117,6 +197,11 @@ type FlagInfo struct {
// Name is the canonical kebab-case flag name (e.g. "user-id").
Name string
// Shorthand is the optional single-character pflag shorthand (e.g. "y"
// for --yes). PreParse uses exact shorthand tokens when normalising
// explicit boolean values; shorthand clusters retain native pflag syntax.
Shorthand string
// PropertyName is the original schema property key (e.g. "userId").
PropertyName string
+37
View File
@@ -292,6 +292,43 @@ func TestCallToolUsesJSONRPCMethod(t *testing.T) {
}
}
func TestCrossPlatformCoverageCallToolDevAppEventSubscribeRetriesAreBounded(t *testing.T) {
attempts := 0
httpClient := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
attempts++
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
}),
}
client := NewClient(httpClient)
client.sleep = func(context.Context, time.Duration) error { return nil }
_, err := client.CallTool(
context.Background(),
"https://mcp.example.test/dws",
"subscribe_dev_app_events",
map[string]any{
"unifiedAppId": "u-1",
"eventCodes": []string{"event-a", "event-b"},
},
)
if err == nil {
t.Fatal("CallTool() succeeded after repeated HTTP 503 responses")
}
wantAttempts := client.MaxRetries + 1
if attempts != wantAttempts {
t.Fatalf("HTTP attempts = %d, want configured bound %d", attempts, wantAttempts)
}
if attempts > 2 {
t.Fatalf("dev app event subscribe HTTP attempts = %d, want at most 2 by default", attempts)
}
}
func TestCallToolInjectsAuthHeaders(t *testing.T) {
t.Parallel()
+6 -1
View File
@@ -150,7 +150,12 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
if v := lf.Annotations["x-cli-enum"]; len(v) > 0 {
enumCopy = append([]string{}, v...)
}
if SuffixLooksLikeValue(bestValue, lf.Value.Type(), fmtStr, enumCopy) {
flagType := lf.Value.Type()
if SuffixLooksLikeValue(bestValue, flagType, fmtStr, enumCopy) {
if flagType == "bool" || flagType == "boolean" {
normalized, _ := NormalizeBoolLiteral(bestValue)
return FlagFixResult{Suggestion: fmt.Sprintf("Use an explicit boolean value: --%s=%s", bestFlag, normalized)}
}
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
}
+22
View File
@@ -54,6 +54,28 @@ func TestSuggestFlagFix_trueGlue_isoDateSuffix(t *testing.T) {
}
}
func TestSuggestFlagFix_boolGlueNeverAutoFixes(t *testing.T) {
cmd := &cobra.Command{Use: "delete"}
cmd.Flags().Bool("yes", false, "confirm destructive operation")
for _, test := range []struct {
body string
want string
}{
{body: "yesfalse", want: "--yes=false"},
{body: "yestrue", want: "--yes=true"},
{body: "yesno", want: "--yes=false"},
} {
fix := SuggestFlagFix(cmd, errUnknownFlag(test.body))
if fix.AutoFixFlag != "" || fix.AutoFixValue != "" {
t.Fatalf("SuggestFlagFix(%q) auto-fix = %q/%q, want none", test.body, fix.AutoFixFlag, fix.AutoFixValue)
}
if strings.Contains(fix.Suggestion, "Space required") || !strings.Contains(fix.Suggestion, test.want) {
t.Fatalf("SuggestFlagFix(%q) suggestion = %q, want safe inline %q", test.body, fix.Suggestion, test.want)
}
}
}
func TestSuggestFlagFix_levenshteinAddsUsage(t *testing.T) {
cmd := &cobra.Command{Use: "send"}
cmd.Flags().String("conversation-id", "", "Conversation id")
+69
View File
@@ -0,0 +1,69 @@
// 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 cmdutil
import "strings"
// Morph normalizes a flag spelling to a table-free canonical form so that
// morphological variants collapse to one key: case is folded, '_', '.' and
// spaces are treated as '-', and camelCase boundaries become '-'. It is the
// single shared primitive used both at build time (when the parameter-alias
// generator intersects concept members with a command's real flags) and at
// runtime (when pflag's SetNormalizeFunc resolves an emitted name). Keeping one
// implementation is a hard invariant: if the two diverged, a name the generator
// believed reducible could fail to resolve at runtime, which is contract drift.
//
// Morph is idempotent and stable on already-canonical names: Morph("limit") ==
// "limit" and Morph("page-size") == "page-size", so applying it to a command's
// real flags never changes their identity.
func Morph(name string) string {
s := camelToKebab(name)
s = strings.NewReplacer("_", "-", ".", "-", " ", "-").Replace(s)
s = strings.ToLower(s)
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
return strings.Trim(s, "-")
}
// camelToKebab inserts a '-' at camelCase boundaries. A boundary is an
// uppercase rune that follows a lowercase letter or digit (fooBar -> foo-Bar),
// or an uppercase rune that starts a new word inside an acronym run
// (APIKey -> API-Key). Existing separators are left untouched; ToLower runs
// afterwards in Morph.
func camelToKebab(s string) string {
if s == "" {
return s
}
runes := []rune(s)
var b strings.Builder
b.Grow(len(runes) + 4)
for i, r := range runes {
if i > 0 && isUpper(r) {
prev := runes[i-1]
switch {
case isLower(prev) || isDigit(prev):
b.WriteByte('-')
case isUpper(prev) && i+1 < len(runes) && isLower(runes[i+1]):
b.WriteByte('-')
}
}
b.WriteRune(r)
}
return b.String()
}
func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
func isLower(r rune) bool { return r >= 'a' && r <= 'z' }
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
+98
View File
@@ -0,0 +1,98 @@
// 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 cmdutil
import "testing"
func TestMorphIsStableOnCanonicalNames(t *testing.T) {
for _, name := range []string{"limit", "page-size", "query", "conversation-id", "start", "id", "unified-app-id"} {
if got := Morph(name); got != name {
t.Fatalf("Morph(%q) = %q, want stable %q", name, got, name)
}
}
}
func TestMorphNormalizesVariants(t *testing.T) {
cases := map[string]string{
"pageSize": "page-size",
"page_size": "page-size",
"page.size": "page-size",
"PageSize": "page-size",
"maxResults": "max-results",
"startTime": "start-time",
"start_time": "start-time",
"APIKey": "api-key",
"userID": "user-id",
"conversationId": "conversation-id",
"LIMIT": "limit",
"--page--size": "page-size",
}
for in, want := range cases {
if got := Morph(in); got != want {
t.Fatalf("Morph(%q) = %q, want %q", in, got, want)
}
}
}
func TestMorphIsIdempotent(t *testing.T) {
for _, name := range []string{"pageSize", "start_time", "APIKey", "conversationId", "max.results"} {
once := Morph(name)
if twice := Morph(once); twice != once {
t.Fatalf("Morph not idempotent for %q: %q -> %q", name, once, twice)
}
}
}
func TestMorphHandlesEmptyAndSeparators(t *testing.T) {
if got := Morph(""); got != "" {
t.Fatalf("Morph(\"\") = %q, want empty", got)
}
if got := Morph("-x-"); got != "x" {
t.Fatalf("Morph(%q) = %q, want %q", "-x-", got, "x")
}
}
// TestMorphCoversLegacyToKebabCase locks the basis-A invariant: Morph is a
// strict superset of the pipeline handlers' former toKebabCase, which is now a
// shim delegating here. These are the exact cases the handler package used to
// assert, so a future change to Morph that regressed any of them (camelCase,
// PascalCase, snake_case, UPPER_CASE, acronym runs, spaces, or leading
// separators) would be caught before it could split the two implementations.
func TestMorphCoversLegacyToKebabCase(t *testing.T) {
cases := map[string]string{
"userId": "user-id",
"UserName": "user-name",
"user_name": "user-name",
"USER_ID": "user-id",
"pageSize": "page-size",
"user-id": "user-id",
"limit": "limit",
"ID": "id",
"userID": "user-id",
"HTMLParser": "html-parser",
"getHTTPResponse": "get-http-response",
"a": "a",
"": "",
"ABC": "abc",
"already-kebab": "already-kebab",
"with spaces": "with-spaces",
"__leading_underscores": "leading-underscores",
"mixedCamel_and_snake": "mixed-camel-and-snake",
}
for in, want := range cases {
if got := Morph(in); got != want {
t.Fatalf("Morph(%q) = %q, want %q (legacy toKebabCase parity)", in, got, want)
}
}
}
+12 -6
View File
@@ -40,7 +40,8 @@ func SuffixLooksLikeValue(suffix, typ, format string, enum []string) bool {
return startsWithNumericSuffix(suffix)
case "bool", "boolean":
return isBoolLiteralSuffix(suffix)
_, ok := NormalizeBoolLiteral(suffix)
return ok
case "duration":
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
@@ -110,12 +111,17 @@ func startsWithNumericSuffix(s string) bool {
return false
}
func isBoolLiteralSuffix(s string) bool {
switch strings.ToLower(s) {
case "true", "false", "1", "0", "t", "f", "yes", "no", "on", "off", "y", "n":
return true
// NormalizeBoolLiteral reduces model-friendly boolean spellings to the exact
// values accepted by an unambiguous --flag=true/false pflag token.
func NormalizeBoolLiteral(s string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "t", "yes", "on", "y":
return "true", true
case "false", "0", "f", "no", "off", "n":
return "false", true
default:
return "", false
}
return false
}
func matchesEnumSuffix(s string, enum []string) bool {
+48
View File
@@ -15,6 +15,28 @@ package cmdutil
import "testing"
func TestNormalizeBoolLiteral(t *testing.T) {
tests := []struct {
input string
want string
ok bool
}{
{input: "TRUE", want: "true", ok: true},
{input: " yes ", want: "true", ok: true},
{input: "0", want: "false", ok: true},
{input: "Off", want: "false", ok: true},
{input: "maybe"},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
got, ok := NormalizeBoolLiteral(test.input)
if got != test.want || ok != test.ok {
t.Fatalf("NormalizeBoolLiteral(%q) = %q, %v; want %q, %v", test.input, got, ok, test.want, test.ok)
}
})
}
}
// TestSuffixLooksLikeValue_UTF8FirstRune locks in the UTF-8 first-rune
// reading contract on SuffixLooksLikeValue. The function previously read
// suffix[0] (a single byte) which produced incorrect splits / matches for
@@ -67,6 +89,32 @@ func TestSuffixLooksLikeValue_UTF8FirstRune(t *testing.T) {
typ: "int",
want: true,
},
{
// The caller must keep the normalized value inline as
// --flag=false; SuffixLooksLikeValue only validates the suffix.
name: "bool + false suffix is recognized",
suffix: "false",
typ: "bool",
want: true,
},
{
name: "boolean + model-friendly no suffix is recognized",
suffix: "no",
typ: "boolean",
want: true,
},
{
name: "boolean + unknown suffix is rejected",
suffix: "maybe",
typ: "boolean",
want: false,
},
{
name: "boolean + true suffix is recognized",
suffix: "true",
typ: "boolean",
want: true,
},
{
// Invalid UTF-8 (lone continuation byte 0x80) decodes as
// utf8.RuneError; the RuneError guard makes the fallback
+2 -1
View File
@@ -15,6 +15,7 @@ RUN_GO_TESTS=1
VERBOSE=0
KEEP_WORKDIR=0
E2E_VERSION="${DWS_PACKAGE_VERSION:-v1.0.53-beta.3}"
GO_TEST_TIMEOUT="${MULTI_PROFILE_GO_TEST_TIMEOUT:-10m}"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -582,7 +583,7 @@ cd "$ROOT"
if [[ "$RUN_GO_TESTS" -eq 1 ]]; then
log "running multi-profile Go regressions"
go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli
go test -timeout "$GO_TEST_TIMEOUT" -count=1 ./internal/auth ./internal/app ./test/cli
fi
log "building dws"
+4 -2
View File
@@ -6,7 +6,7 @@ BASE_REF=""
OVERALL_PROFILE="coverage.txt"
ADDITIONAL_DIFF_PROFILE="${COVERAGE_ADDITIONAL_DIFF_PROFILE:-${COVERAGE_ADDITIONAL_PROFILE:-}}"
BASELINE_PROFILE="coverage-base.txt"
DIFF_PROFILE="coverage-policy.txt"
DIFF_PROFILE="${COVERAGE_DIFF_PROFILE-coverage-policy.txt}"
TARGET="${COVERAGE_TARGET:-100}"
OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}"
ENFORCE_OVERALL="${COVERAGE_ENFORCE_OVERALL:-false}"
@@ -77,13 +77,15 @@ go build -o "$CHECKER" ./scripts/policy/coverage-gate
module="$(go list -m -f '{{.Path}}')"
set -- "$CHECKER" \
--diff-profile "$DIFF_PROFILE" \
--base-ref "$BASE_REF" \
--module "$module" \
--overall-tolerance "$OVERALL_TOLERANCE" \
--target "$TARGET" \
--enforce-overall-target="$ENFORCE_OVERALL"
if [ -n "$DIFF_PROFILE" ]; then
set -- "$@" --diff-profile "$DIFF_PROFILE"
fi
if [ "$CHANGED_ONLY" = "true" ]; then
set -- "$@" --changed-only
else
+40
View File
@@ -14,15 +14,22 @@ tmp="$(mktemp -d)"
exec_tmp="$(policy_runtime_mktemp_dir dws-generated-drift)"
metadata_generator="$exec_tmp/schema-agent-metadata"
catalog_generator="$exec_tmp/schema-catalog"
param_aliases_generator="$exec_tmp/param-aliases"
trap 'rm -rf "$tmp" "$exec_tmp"' EXIT HUP INT TERM
go build -o "$metadata_generator" ./internal/generator/cmd_schema_agent_metadata
go build -a -o "$catalog_generator" ./internal/generator/cmd_schema_catalog
go build -o "$param_aliases_generator" ./internal/generator/cmd_param_aliases
# CommandRegistry is a reviewed input, never a generated artifact. Keep an
# independent byte-for-byte guard around the ordinary downstream generators.
registry_guard="$tmp/schema_command_registry"
cp -R internal/cli/schema_command_registry "$registry_guard"
# The parameter concept dictionary + its schema are reviewed inputs too.
concepts_guard="$tmp/param_concepts.json"
concepts_schema_guard="$tmp/param_concepts.schema.json"
cp internal/cli/param_concepts.json "$concepts_guard"
cp internal/cli/param_concepts.schema.json "$concepts_schema_guard"
# Also guard human-authored metadata + selection hint trees.
metadata_guard="$tmp/metadata-hints"
selection_guard="$tmp/selection-hints"
@@ -33,6 +40,8 @@ metadata_tmp="$tmp/metadata"
audit_tmp="$tmp/audit.json"
catalog_tmp="$tmp/schema_catalog"
catalog_tmp_second="$tmp/schema_catalog-second"
param_aliases_tmp="$tmp/param_aliases_generated.go"
param_aliases_tmp_second="$tmp/param_aliases_generated-second.go"
"$metadata_generator" \
-root . \
@@ -62,11 +71,29 @@ fi
-root . \
-output "$catalog_tmp_second"
"$param_aliases_generator" \
-root . \
-output "$param_aliases_tmp"
"$param_aliases_generator" \
-root . \
-output "$param_aliases_tmp_second"
if ! diff -qr internal/cli/schema_command_registry "$registry_guard" >/dev/null; then
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2
exit 1
fi
if ! cmp -s internal/cli/param_concepts.json "$concepts_guard"; then
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2
exit 1
fi
if ! cmp -s internal/cli/param_concepts.schema.json "$concepts_schema_guard"; then
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2
exit 1
fi
if ! diff -qr internal/cli/schema_hints/metadata "$metadata_guard" >/dev/null; then
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2
exit 1
@@ -90,4 +117,17 @@ if ! diff -qr internal/cli/schema_catalog "$catalog_tmp" >/dev/null; then
exit 1
fi
if ! cmp -s "$param_aliases_tmp" "$param_aliases_tmp_second"; then
printf '%s\n' 'generated drift: consecutive parameter-alias generations are not byte-identical' >&2
diff -u "$param_aliases_tmp" "$param_aliases_tmp_second" || true
exit 1
fi
if ! cmp -s internal/cli/param_aliases_generated.go "$param_aliases_tmp"; then
printf '%s\n' 'generated drift: internal/cli/param_aliases_generated.go is stale' >&2
printf '%s\n' 'run: make generate-schema' >&2
diff -u internal/cli/param_aliases_generated.go "$param_aliases_tmp" || true
exit 1
fi
printf 'generated drift check: ok\n'
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
# Scan every runnable command for parameter-concept co-occurrence. A concept
# whose members intersect two or more VISIBLE real flags on one command cannot
# be auto-reduced (picking one would silently mis-map the other) and must be an
# explicitly reviewed `ambiguous` entry in param_concepts.json, or reduction
# fails. This re-runs the single reduction algorithm — cli.ReduceParamAliases,
# invoked by the parameter-alias generator — against the live Cobra tree built
# by app.NewRootCommand(), so a future command or flag that introduces an
# unreviewed co-occurrence (or a scoped_aliases/bind override targeting a flag
# that is not real) turns this gate red before it can ship a silent mis-map.
#
# It is intentionally distinct from check-generated-drift.sh: drift proves the
# committed table is byte-current, whereas this gate isolates and reports the
# co-occurrence / override-target contract with a focused failure message.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
exec_tmp="$(policy_runtime_mktemp_dir dws-param-cooccurrence)"
# The generator refuses to write inside the repository tree, so the throwaway
# output goes to a system temp dir (mirrors check-generated-drift.sh).
out_tmp="$(mktemp -d)"
generator="$exec_tmp/param-aliases"
out="$out_tmp/param_aliases_generated.go"
err="$exec_tmp/err.log"
trap 'rm -rf "$exec_tmp" "$out_tmp"' EXIT HUP INT TERM
"${GO:-go}" build -o "$generator" ./internal/generator/cmd_param_aliases
if ! "$generator" -root . -output "$out" 2>"$err"; then
printf '%s\n' 'param-alias co-occurrence gate: FAIL' >&2
printf '%s\n' 'an unreviewed concept co-occurrence (>=2 visible real flags) or an invalid scoped/bind override target was found:' >&2
cat "$err" >&2
exit 1
fi
printf 'param-alias co-occurrence gate: ok\n'
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
# Validate the reviewed parameter concept dictionary
# (internal/cli/param_concepts.json) against its closed schema and every loader
# semantic invariant: globally unique concept members, reviewed exact command
# scopes, members disjoint from their own excludes, bind targets referencing a
# declared concept, no unresolved confirm/investigate decisions, audited
# single/list and ID/name boundaries, closed (unknown-field-rejecting) decoding
# at every level, and the fixture did-you-mean sentinel allow-list. These
# assertions run through the real
# embedded loader (cli.LoadParamConcepts / decodeParamConcepts), not a
# re-implementation, so the shell gate and the runtime agree by construction.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
if ! "${GO:-go}" test -count=1 ./internal/cli -run \
'^(TestParamConceptsJSONSchemaDocumentsClosedShape|TestEmbeddedParamConceptsLoadsAndSatisfiesInvariants|TestParamConceptRiskAuditBoundaries|TestDecodeParamConceptsRejectsUnknownFieldsAtEveryLevel|TestDecodeParamConceptsEnforcesReviewedConstraints|TestGeneratedParamAliasesAreWellFormed)$'; then
printf '%s\n' 'param concepts check: FAIL (reviewed dictionary violates its schema or a loader invariant)' >&2
exit 1
fi
printf 'param concepts check: ok\n'
+20
View File
@@ -171,6 +171,26 @@ dws event stop --all --yes
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先预览 `dws event stop <subscribe_id> --dry-run`、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
- 一个 consume 可监听多个兼容 event key,每个事件仍有独立订阅和 consumer,共用一个 bus、远程连接及输出。`event stop <subscribe_id>` 只移除目标事件,最后一个被移除后进程退出。
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
### 本地保护状态运维
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
## Output parsing
- 推荐 `--flatten -f ndjson`:顶层业务字段,一行一个事件 JSON,适合 Agent 管道读取。
+20
View File
@@ -74,6 +74,26 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
### 本地保护状态运维
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
## Call flow
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
@@ -369,6 +369,26 @@ dws event stop --all --yes
裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。多事件进程中,停止一个 `subscribe_id` 只移除对应逻辑 consumer,其余事件继续监听;最后一个被移除时进程退出。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。
- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。
- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。
- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。
- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。
- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。
### 本地保护状态运维
- open 版默认状态文件为 `~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。
- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。
- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。
- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。
## Troubleshooting
- 没有输出:单事件确认 stderr 已出现 `ready event_key=...`;多事件确认已出现 `ready event_count=...`,不要把某条 `subscription` 行误认为整体就绪。
+59
View File
@@ -514,6 +514,48 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
t.Error("Code Admission must not suppress required contexts with paths-ignore")
}
focusedStart := strings.Index(admission, "\n test-focused:\n")
focusedEnd := strings.Index(admission, "\n test-race:\n")
if focusedStart < 0 || focusedEnd <= focusedStart {
t.Fatal("Code Admission workflow missing focused test job boundaries")
}
focusedJob := admission[focusedStart:focusedEnd]
if !strings.Contains(focusedJob, "timeout-minutes: 20") {
t.Error("focused test job must allow the scoped race suite up to 20 minutes")
}
if !strings.Contains(focusedJob, `go test -v -race -count=1 -timeout=15m "${packages[@]}"`) {
t.Error("focused race tests must retain enough package-level time for internal/app")
}
raceStart := focusedEnd
raceEnd := strings.Index(admission, "\n test-release-scripts:\n")
if raceEnd <= raceStart {
t.Fatal("Code Admission workflow missing race test job boundaries")
}
raceJob := admission[raceStart:raceEnd]
if !strings.Contains(raceJob, `go test -v -race -count=1 -timeout=12m "${packages[@]}"`) {
t.Error("full race shards must retain enough package-level time for internal/app")
}
coverageStart := strings.Index(admission, "\n coverage:\n")
coverageEnd := strings.Index(admission, "\n policy:\n")
if coverageStart < 0 || coverageEnd <= coverageStart {
t.Fatal("Code Admission workflow missing coverage job boundaries")
}
coverageJob := admission[coverageStart:coverageEnd]
for _, want := range []string{
`FULL_SUITE: ${{ needs.lint.outputs.full_suite }}`,
"policy_profile=coverage-policy.txt\n" +
` if [ "$FULL_SUITE" != true ]; then` + "\n" +
" policy_profile=\n" +
" fi",
`COVERAGE_DIFF_PROFILE="$policy_profile"`,
} {
if !strings.Contains(coverageJob, want) {
t.Errorf("Code Admission workflow missing scoped coverage contract %q", want)
}
}
notification := readWorkflow(".github/workflows/notify-wukong.yml")
if !strings.Contains(notification, "- CI") {
t.Error("Wukong notification must follow the renamed CI workflow")
@@ -529,6 +571,12 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
if !strings.Contains(coverageGate, `OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}"`) {
t.Error("coverage gate must reject any reported overall regression")
}
if !strings.Contains(coverageGate, `DIFF_PROFILE="${COVERAGE_DIFF_PROFILE-coverage-policy.txt}"`) {
t.Error("coverage gate must allow scoped CI to explicitly omit the supporting policy profile")
}
if !strings.Contains(coverageGate, `if [ -n "$DIFF_PROFILE" ]; then`) {
t.Error("coverage gate must add the supporting policy profile only when configured")
}
if !strings.Contains(coverageGate, `--baseline-profile "$BASELINE_PROFILE"`) {
t.Error("coverage gate must evaluate the merge-base profile with the candidate checker")
}
@@ -568,6 +616,17 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
if strings.Contains(integration, "pull_request:") {
t.Error("complete Multi-profile E2E must not run as a pull-request admission context")
}
if !strings.Contains(integration, "include-hidden-files: true") {
t.Error("main integration must upload diagnostics stored below the hidden .tmp-bin directory")
}
integrationScript := readWorkflow("scripts/dev/test-multi-profile-e2e.sh")
if !strings.Contains(integrationScript, `GO_TEST_TIMEOUT="${MULTI_PROFILE_GO_TEST_TIMEOUT:-10m}"`) {
t.Error("multi-profile E2E must allow enough time for the complete internal/app regression suite")
}
if strings.Contains(integrationScript, "go test -timeout 180s") {
t.Error("multi-profile E2E must not retain the obsolete three-minute Go test budget")
}
}
func TestChangelogPRGateRejectsUnsafeChanges(t *testing.T) {
@@ -0,0 +1,120 @@
package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCoverageGatePolicyProfileCanBeExplicitlyOmitted(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeGoPath := filepath.Join(binDir, "go")
const fakeGo = `#!/bin/sh
set -eu
case "$1" in
build)
shift
output=
while [ "$#" -gt 0 ]; do
case "$1" in
-o)
output="$2"
shift 2
;;
*)
shift
;;
esac
done
cat > "$output" <<'EOF'
#!/bin/sh
printf '%s\n' "$@" > "$COVERAGE_ARGS_LOG"
EOF
chmod +x "$output"
;;
list)
printf '%s\n' "example.com/coverage-fixture"
;;
*)
printf 'unexpected fake go command: %s\n' "$1" >&2
exit 2
;;
esac
`
if err := os.WriteFile(fakeGoPath, []byte(fakeGo), 0o755); err != nil {
t.Fatalf("WriteFile(fake go) error = %v", err)
}
baseEnv := make([]string, 0, len(os.Environ())+2)
for _, value := range os.Environ() {
if strings.HasPrefix(value, "PATH=") ||
strings.HasPrefix(value, "COVERAGE_DIFF_PROFILE=") ||
strings.HasPrefix(value, "COVERAGE_ARGS_LOG=") {
continue
}
baseEnv = append(baseEnv, value)
}
baseEnv = append(baseEnv, "PATH="+binDir+":"+os.Getenv("PATH"))
runGate := func(t *testing.T, diffProfile *string) []string {
t.Helper()
argsLog := filepath.Join(t.TempDir(), "args.log")
cmd := exec.Command(
"sh",
"./scripts/policy/check-coverage-gate.sh",
"--base-ref",
"HEAD",
)
cmd.Dir = root
cmd.Env = append(append([]string{}, baseEnv...), "COVERAGE_ARGS_LOG="+argsLog)
if diffProfile != nil {
cmd.Env = append(cmd.Env, "COVERAGE_DIFF_PROFILE="+*diffProfile)
}
output, runErr := cmd.CombinedOutput()
if runErr != nil {
t.Fatalf("coverage gate error = %v\noutput:\n%s", runErr, output)
}
data, readErr := os.ReadFile(argsLog)
if readErr != nil {
t.Fatalf("ReadFile(args log) error = %v", readErr)
}
return strings.Fields(string(data))
}
assertDiffProfiles := func(t *testing.T, args []string, want ...string) {
t.Helper()
var got []string
for i := 0; i+1 < len(args); i++ {
if args[i] == "--diff-profile" {
got = append(got, args[i+1])
i++
}
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("diff profiles = %q, want %q; args = %q", got, want, args)
}
}
t.Run("unset keeps strict policy profile", func(t *testing.T) {
assertDiffProfiles(
t,
runGate(t, nil),
"coverage-policy.txt",
"coverage.txt",
)
})
t.Run("explicit empty omits only policy profile", func(t *testing.T) {
empty := ""
assertDiffProfiles(t, runGate(t, &empty), "coverage.txt")
})
}
+125
View File
@@ -130,6 +130,131 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
}
}
func TestCrossPlatformCoverageEventSkillPinsSubscriptionRetryOrchestrationContract(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
filepath.Join(root, "docs", "event-subprocess-contract.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
normalizedText := strings.Join(strings.Fields(text), " ")
for _, required := range []string{
"16",
"--profile",
"Agent/host",
"0/2/1",
"retryable=false",
"max_additional_attempts=0",
"retryable=true",
"max_additional_attempts=2",
"retryable=unknown",
"max_additional_attempts=1",
"retry_after_seconds",
"next_retry_at",
"in_flight",
"cooldown",
"terminal_hold",
"subscribe_id",
"trace_id",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing subscription retry orchestration contract %q", path, required)
}
}
if path == filepath.Join(root, "docs", "event-subprocess-contract.md") {
for _, required := range []string{
"not a CLI-enforced persisted total-attempt cap",
"performs no in-process automatic retry",
"does not persist or enforce the Agent/host attempt count",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s overstates CLI retry enforcement; missing %q", path, required)
}
}
continue
}
for _, required := range []string{
"不是 CLI 持久化硬总次数上限",
"进程内不会自动重试",
"不持久化或计算跨调用的 Agent/host 尝试次数",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s overstates CLI retry enforcement; missing %q", path, required)
}
}
}
}
func TestCrossPlatformCoverageEventSkillDocumentsSubscriptionGuardOperations(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-event", "references", "event-im.md"),
filepath.Join(root, "skills", "mono", "references", "products", "event.md"),
filepath.Join(root, "docs", "event-subprocess-contract.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
normalizedText := strings.Join(strings.Fields(text), " ")
for _, required := range []string{
"~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json",
"DWS_CONFIG_DIR",
"personal_subscription_attempts.json",
"personal_subscription_attempts.lock",
"0700",
"0600",
"24h",
"1h",
"terminal_hold",
"next_retry_at",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing subscription guard operations %q", path, required)
}
}
if path == filepath.Join(root, "docs", "event-subprocess-contract.md") {
for _, required := range []string{
"Delete only",
"never the lock file",
"every protection record for that identity",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s missing emergency guard-clear warning %q", path, required)
}
}
continue
}
for _, required := range []string{
"只删除 `personal_subscription_attempts.json`",
"不要删除 lock 文件",
"该 identity 的全部保护记录",
} {
if !strings.Contains(normalizedText, required) {
t.Errorf("%s missing emergency guard-clear warning %q", path, required)
}
}
}
}
func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {