Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3519965285 | ||
|
|
a7074bf53f | ||
|
|
21144af79b | ||
|
|
3bdc30badb | ||
|
|
b82e975429 | ||
|
|
2808e71cb6 | ||
|
|
ec99654854 | ||
|
|
9aa76ea748 | ||
|
|
885c3fe021 | ||
|
|
d0d56cbaf5 | ||
|
|
dfba9546f4 | ||
|
|
b3ba9fee97 | ||
|
|
1e04e301ea | ||
|
|
5f038d440b | ||
|
|
8936c20ef0 | ||
|
|
e2abc70e84 | ||
|
|
15a27a9f83 | ||
|
|
2e1cce8501 | ||
|
|
870fba823b | ||
|
|
d083de5f84 | ||
|
|
1fb966dbff | ||
|
|
7987fb3a35 | ||
|
|
3d6a9c6232 | ||
|
|
195569ddfa | ||
|
|
7827856876 | ||
|
|
f79f41e930 | ||
|
|
bfd53df9b2 | ||
|
|
4db117893e | ||
|
|
b314749ef7 | ||
|
|
5133103a54 | ||
|
|
9faa332306 | ||
|
|
17fa1e1b34 | ||
|
|
af1f8ccd05 | ||
|
|
c26cbbbbb8 | ||
|
|
2733f510af | ||
|
|
abc62622fb |
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 := ¶mAliasCaptureCaller{}
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := ¶mAliasDryRunRejectRunner{}
|
||||
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 ¶mAliasCaptureRunner{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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 := ¶mAliasCaptureCaller{}
|
||||
_, 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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(¶mValidationFixtureSpec{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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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 ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstTimePointer(values ...*time.Time) *time.Time {
|
||||
for _, value := range values {
|
||||
if value == nil || value.IsZero() {
|
||||
continue
|
||||
}
|
||||
copy := value.UTC()
|
||||
return ©
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "--"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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' }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
Executable
+26
@@ -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'
|
||||
@@ -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 管道读取。
|
||||
|
||||
@@ -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` 行误认为整体就绪。
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user