From 9a8a41a318336ab88e5d2b87129381900556dd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 10:46:38 +0800 Subject: [PATCH 1/7] feat(corecmd): support @file / stdin input sources on string flags Port the lark-cli Flag.Input capability: a KindString flag may declare Input sources ("file" for @path, "stdin" for -) and the framework rewrites the explicit token into the payload content before required/enum/constraint/Validate checks. @@value escapes to a literal @value; a single stdin consumer per invocation is enforced; a leading UTF-8 BOM is stripped. Shortcut.Flag gains the same declaration and the adapter maps it through; LeafSpec inherits it via the LeafFlag alias. --- internal/corecmd/corecmd.go | 15 ++ internal/corecmd/input.go | 189 +++++++++++++++++++++++ internal/corecmd/input_test.go | 266 +++++++++++++++++++++++++++++++++ internal/shortcut/adapter.go | 1 + internal/shortcut/types.go | 6 + 5 files changed, 477 insertions(+) create mode 100644 internal/corecmd/input.go create mode 100644 internal/corecmd/input_test.go diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 5d0fbe59..04741dce 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -145,6 +145,15 @@ type FlagSpec struct { // alike) and makes a whitespace-only value count as empty in required checks. Trim bool + // Input declares extra input sources for a KindString flag beyond the + // literal command-line value: InputFile enables @path (value replaced by + // the file content), InputStdin enables - (value replaced by stdin). + // "@@value" always escapes to the literal "@value". Only explicit CLI + // tokens are resolved; EnvVar fallback and registration defaults pass + // through unchanged. Resolution runs before required/enum/constraint/ + // Validate checks, so they see the payload content. Empty = flag value only. + Input []string + // Schema parameter final facts (embedded to dws.schema.*; assembly pass-through). Enum []string // accepted values Format string // machine-readable format (e.g. uri) @@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command { validateDispatchDecl(spec) validateSafetySpec(spec) validateContractDecl(spec) + validateInputSpecs(spec.Use, spec.Flags) // Help prose inherits the declaration when not authored separately: // Selection.Examples (already contract-validated against the real flags) // double as the --help Example block, keeping one authored source. @@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error { return err } } + // Input resolution rewrites explicit @file / stdin values in place so the + // required/enum/constraint/Validate stages below check the payload content. + if err := resolveInputFlags(cmd, spec.Flags); err != nil { + return err + } if err := ValidateRequired(cmd, spec.Flags); err != nil { return err } diff --git a/internal/corecmd/input.go b/internal/corecmd/input.go new file mode 100644 index 00000000..91c0746b --- /dev/null +++ b/internal/corecmd/input.go @@ -0,0 +1,189 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" +) + +// FlagSpec.Input source constants. They declare extra input sources for a +// KindString flag beyond the literal command-line value, mirroring the +// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV) +// never need shell quoting. +const ( + // InputFile allows the flag value @path to be replaced by the file content. + InputFile = "file" + // InputStdin allows the flag value - to be replaced by the stdin content. + InputStdin = "stdin" +) + +// utf8BOM is stripped from file/stdin content so a Windows-edited payload +// cannot corrupt the first CSV cell or break JSON parsing downstream. +const utf8BOM = "\ufeff" + +// validateInputSpecs rejects malformed Input declarations at build time. Like +// the other declaration checks this panics: a bad Input spec is a programming +// error every test and startup path should trip immediately. +func validateInputSpecs(use string, flags []FlagSpec) { + for _, flag := range flags { + if len(flag.Input) == 0 { + continue + } + if flag.Kind != KindString { + panic(fmt.Sprintf( + "command %q flag %q: Input is only supported on KindString flags", + use, flag.Name)) + } + seen := map[string]bool{} + for _, source := range flag.Input { + if source != InputFile && source != InputStdin { + panic(fmt.Sprintf( + "command %q flag %q: unknown Input source %q (allowed: %s, %s)", + use, flag.Name, source, InputFile, InputStdin)) + } + if seen[source] { + panic(fmt.Sprintf( + "command %q flag %q: duplicate Input source %q", + use, flag.Name, source)) + } + seen[source] = true + } + } +} + +// inputSupports reports whether the flag declared the given input source. +func inputSupports(flag FlagSpec, source string) bool { + for _, s := range flag.Input { + if s == source { + return true + } + } + return false +} + +// explicitInputFlagName picks the declared name that carries the user's +// explicit token, mirroring rawValue's order: the main flag wins only when +// changed and non-empty, then declared aliases in order. EnvVar fallback and +// registration defaults are never input-resolved — @file only applies to what +// the user literally typed. +func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string { + for _, name := range append([]string{flag.Name}, flag.Aliases...) { + if !cmd.Flags().Changed(name) { + continue + } + if strings.TrimSpace(cmdutil.MustGetFlag(cmd, name)) != "" { + return name + } + } + return "" +} + +// resolveInputFlags rewrites the explicit values of Input-declaring flags +// before required/enum/constraint/Validate checks, so every downstream stage +// sees the real payload content. Semantics: +// +// - "-" reads stdin; a process has a single stdin, so a second Input flag +// using "-" in the same invocation is rejected. +// - "@path" reads the named file (leading/trailing whitespace trimmed). +// - "@@value" escapes to the literal "@value" and is not source-resolved. +// +// Resolution runs once per invocation inside runDeclaredPreflight. It +// rewrites the cobra flag value in place (Set keeps Changed=true), so +// fallback-chain readers (EffectiveValue/BuildArgs) observe the content. +// +// Interaction with confirmation: stdin is consumed here, before the Safety +// prompt of a user_required write would read it; such an interactive prompt +// then sees EOF and fails closed with confirmation_required. Write commands +// declaring InputStdin must be invoked with --yes (or --dry-run). +func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error { + stdinConsumed := false + for _, flag := range flags { + if len(flag.Input) == 0 { + continue + } + name := explicitInputFlagName(cmd, flag) + if name == "" { + continue + } + raw := cmdutil.MustGetFlag(cmd, name) + + switch { + case raw == "-": + if !inputSupports(flag, InputStdin) { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name)) + } + if stdinConsumed { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name), + apperrors.WithHint(fmt.Sprintf( + "一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)", + flag.Name))) + } + stdinConsumed = true + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err)) + } + if err := cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)); err != nil { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 写入解析值失败:%v", flag.Name, err)) + } + + case strings.HasPrefix(raw, "@@"): + // Escape: strip the first @, keep the rest as a literal inline value. + if err := cmd.Flags().Set(name, raw[1:]); err != nil { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 写入解析值失败:%v", flag.Name, err)) + } + + case strings.HasPrefix(raw, "@"): + if !inputSupports(flag, InputFile) { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name)) + } + path := strings.TrimSpace(raw[1:]) + if path == "" { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name)) + } + data, err := os.ReadFile(path) + if err != nil { + verr := apperrors.NewValidation( + fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err)) + if inputSupports(flag, InputStdin) { + verr = apperrors.NewValidation( + fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), + apperrors.WithHint(fmt.Sprintf( + "该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name))) + } + return verr + } + if err := cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)); err != nil { + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 写入解析值失败:%v", flag.Name, err)) + } + } + } + return nil +} diff --git a/internal/corecmd/input_test.go b/internal/corecmd/input_test.go new file mode 100644 index 00000000..94aab0bd --- /dev/null +++ b/internal/corecmd/input_test.go @@ -0,0 +1,266 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can +// assert what the full pipeline (resolution → validation → BuildArgs) ships. +func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command { + return New(Spec{ + Use: "t", + Short: "t", + Flags: flags, + Invoke: func(c *Ctx, toolArgs map[string]any) error { + *captured = toolArgs + return nil + }, + }) +} + +func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error { + t.Helper() + cmd.SetArgs(args) + return cmd.Execute() +} + +func writeInputFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "payload.txt") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func TestResolveInputFlags_File(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, + }, &got) + path := writeInputFile(t, "# hello\nworld") + + if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "# hello\nworld" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestResolveInputFlags_Stdin(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, + }, &got) + cmd.SetIn(strings.NewReader("piped payload")) + + if err := runInputCommand(t, cmd, "--content", "-"); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "piped payload" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// The @@ escape keeps a literal leading @ inline and must not be treated as a +// source reference even when Input is declared. +func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, + }, &got) + + if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "@literal@x" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestResolveInputFlags_BOMStripped(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "\ufeff{\"a\":1}") + + if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "{\"a\":1}" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// Required must be satisfied by the resolved payload, proving resolution runs +// before the required stage. +func TestResolveInputFlags_SatisfiesRequired(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "payload") + + if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "payload" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// Enum validation sees the resolved content, not the "@path" token. +func TestResolveInputFlags_EnumValidatesResolvedContent(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "sideways") + + err := runInputCommand(t, cmd, "--mode", "@"+path) + if err == nil || !strings.Contains(err.Error(), "不合法") { + t.Fatalf("expected enum rejection on resolved content, got %v", err) + } +} + +// A value passed through a declared alias is resolved exactly like the main +// name (fallback-chain parity). +func TestResolveInputFlags_AliasResolved(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "via alias") + + if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "via alias" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +func TestResolveInputFlags_FileNotSupported(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, + }, &got) + + err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt") + if err == nil || !strings.Contains(err.Error(), "不支持文件输入") { + t.Fatalf("expected file-input rejection, got %v", err) + } +} + +func TestResolveInputFlags_StdinNotSupported(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, + }, &got) + cmd.SetIn(strings.NewReader("x")) + + err := runInputCommand(t, cmd, "--content", "-") + if err == nil || !strings.Contains(err.Error(), "不支持 stdin") { + t.Fatalf("expected stdin rejection, got %v", err) + } +} + +func TestResolveInputFlags_SingleStdinConsumer(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}}, + {Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}}, + }, &got) + cmd.SetIn(strings.NewReader("x")) + + err := runInputCommand(t, cmd, "--first", "-", "--second", "-") + if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") { + t.Fatalf("expected single-stdin rejection, got %v", err) + } +} + +func TestResolveInputFlags_FileNotFound(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, + }, &got) + + err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt")) + if err == nil || !strings.Contains(err.Error(), "读取文件") { + t.Fatalf("expected read failure, got %v", err) + } +} + +func TestResolveInputFlags_EmptyPathAfterAt(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, + }, &got) + + err := runInputCommand(t, cmd, "--content", "@ ") + if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") { + t.Fatalf("expected empty-path rejection, got %v", err) + } +} + +// A flag without Input keeps its literal value even when it looks like a +// source reference — resolution is strictly opt-in per declaration. +func TestResolveInputFlags_NoInputSpecPassthrough(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "token", Usage: "T", Bind: "token"}, + }, &got) + + if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil { + t.Fatalf("execute: %v", err) + } + if got["token"] != "@not-a-file" { + t.Fatalf("toolArgs[token] = %q", got["token"]) + } +} + +func TestValidateInputSpecs_Panics(t *testing.T) { + cases := []struct { + name string + flag FlagSpec + }{ + {"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}}, + {"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}}, + {"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + New(Spec{ + Use: "t", + Short: "t", + Flags: []FlagSpec{tc.flag}, + Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil }, + }) + }) + } +} diff --git a/internal/shortcut/adapter.go b/internal/shortcut/adapter.go index fd1f968d..7b870b83 100644 --- a/internal/shortcut/adapter.go +++ b/internal/shortcut/adapter.go @@ -217,6 +217,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec { RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc), Enum: append([]string(nil), f.Enum...), Aliases: append([]string(nil), f.Aliases...), + Input: append([]string(nil), f.Input...), }) } return out diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 8e46ec3a..b3545f36 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -107,6 +107,12 @@ type Flag struct { // compatibility escape hatch for aliases that were historically public. Aliases []string `json:"-"` AliasesVisible bool `json:"-"` + // Input declares extra input sources for a string flag beyond the literal + // command-line value: "file" enables @path (value replaced by the file + // content), "stdin" enables - (value replaced by stdin). "@@value" escapes + // to the literal "@value". Resolution happens before Required/Enum/Validate + // checks. Empty = flag value only. + Input []string `json:"input,omitempty"` } // ConstraintKind is a machine-readable cross-parameter or custom validation From 78e6f11d727d00bb4ce46643f1041db31bffd712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 12:06:02 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs(rfc):=20add=20@file=20/=20stdin=20Inpu?= =?UTF-8?q?t=20flag=20usage=20guide=20to=20=C2=A75.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the landed corecmd.Input transitional form: declaration shape (FlagSpec/LeafFlag/shortcut.Flag), runtime resolution semantics and ordering, author rules (help prose, confirmation interaction with stdin, construction-time validation), and the delta table against the target typed InputSource design. --- docs/rfc-command-framework-convergence.md | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index fd66356d..30d6b7e3 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -673,6 +673,51 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec - 构造时拒绝 `InputSourceInvalid`。 - 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。 +`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南: + +**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。 + +```go +// LeafSpec / helpers +Flags: []helpers.LeafFlag{ + { + Name: "content", + Usage: "文档内容(支持 @文件路径 或 - 读 stdin)", + Bind: "content", + Input: []string{corecmd.InputFile, corecmd.InputStdin}, + }, +} + +// shortcut +Flags: []shortcut.Flag{ + {Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)", + Input: []string{"file", "stdin"}}, +} +``` + +**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值): + +- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。 +- `--flag @@value`:转义为字面 `@value`,不做来源解析。 +- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。 +- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。 +- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。 + +**作者守则**: + +- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。 +- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。 +- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。 +- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。 + +**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛): + +| 维度 | 今日 | 目标 | +|---|---|---| +| 源类型 | `[]string` 常量 | 类型化 `InputSource` | +| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 | +| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 | + 核心 FlagSpec 故意没有: - `Bind`; From 7a58b0d19a62c6879c35f078e98f6f6c5a98cf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 12:11:55 +0800 Subject: [PATCH 3/7] fix(corecmd): align Input prefix check with Trim semantics Self-review fixes: a Trim flag receiving " @path" judged usability on the trimmed value (rawValue) while the source prefix check saw the raw value, so the token would ship as a literal. Trim before the prefix check. Also build the file-read error once with a conditional hint option, and pin the default-value/env passthrough plus Trim edge with regression tests. --- internal/corecmd/input.go | 19 ++++++++++++------- internal/corecmd/input_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/corecmd/input.go b/internal/corecmd/input.go index 91c0746b..e534246b 100644 --- a/internal/corecmd/input.go +++ b/internal/corecmd/input.go @@ -125,6 +125,11 @@ func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error { continue } raw := cmdutil.MustGetFlag(cmd, name) + // Trim flags judge usability on the trimmed value (rawValue), so the + // prefix check must agree or " @path" would slip through unresolved. + if flag.Trim { + raw = strings.TrimSpace(raw) + } switch { case raw == "-": @@ -169,15 +174,15 @@ func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error { } data, err := os.ReadFile(path) if err != nil { - verr := apperrors.NewValidation( - fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err)) + var opts []apperrors.Option if inputSupports(flag, InputStdin) { - verr = apperrors.NewValidation( - fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), - apperrors.WithHint(fmt.Sprintf( - "该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name))) + // Rejected @file paths are usually absolute (temp files). + // Steer toward stdin rather than copying the file around. + opts = append(opts, apperrors.WithHint(fmt.Sprintf( + "该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name))) } - return verr + return apperrors.NewValidation( + fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...) } if err := cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)); err != nil { return apperrors.NewValidation( diff --git a/internal/corecmd/input_test.go b/internal/corecmd/input_test.go index 94aab0bd..6f7a69bd 100644 --- a/internal/corecmd/input_test.go +++ b/internal/corecmd/input_test.go @@ -239,6 +239,39 @@ func TestResolveInputFlags_NoInputSpecPassthrough(t *testing.T) { } } +// Registration defaults and env fallback are never input-resolved: @file only +// applies to what the user literally typed on the command line. +func TestResolveInputFlags_DefaultNotResolved(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}}, + }, &got) + + if err := runInputCommand(t, cmd); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "@not-a-file" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + +// Trim flags judge usability on the trimmed value (rawValue), so a leading +// whitespace before @ must still resolve instead of shipping as a literal. +func TestResolveInputFlags_TrimmedLeadingSpace(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}}, + }, &got) + path := writeInputFile(t, "trimmed payload") + + if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != "trimmed payload" { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + func TestValidateInputSpecs_Panics(t *testing.T) { cases := []struct { name string From e92309f7c47dc73d65893d7279d25685e0cdaa0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 12:14:10 +0800 Subject: [PATCH 4/7] fix(corecmd): match Input name selection to rawValue usability exactly Second-review fix: explicitInputFlagName judged usability with an unconditional TrimSpace while rawValue only trims when Trim is set. For a non-Trim flag a whitespace main value is usable and shadows a changed alias; the resolver could then rewrite the shadowed alias (and fail on its @path) while the fallback chain still read the main value. Mirror rawValue's usable() exactly and pin the shadow case with a regression test whose alias path does not exist. --- internal/corecmd/input.go | 17 +++++++++++------ internal/corecmd/input_test.go | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/internal/corecmd/input.go b/internal/corecmd/input.go index e534246b..c1909d15 100644 --- a/internal/corecmd/input.go +++ b/internal/corecmd/input.go @@ -81,16 +81,21 @@ func inputSupports(flag FlagSpec, source string) bool { } // explicitInputFlagName picks the declared name that carries the user's -// explicit token, mirroring rawValue's order: the main flag wins only when -// changed and non-empty, then declared aliases in order. EnvVar fallback and +// explicit token, mirroring rawValue's order and usability judgement exactly: +// the main flag wins only when changed and usable (trim-judged only when +// Trim is set), then declared aliases in order. Diverging here could rewrite +// an alias that rawValue would shadow (or vice versa). EnvVar fallback and // registration defaults are never input-resolved — @file only applies to what // the user literally typed. func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string { - for _, name := range append([]string{flag.Name}, flag.Aliases...) { - if !cmd.Flags().Changed(name) { - continue + usable := func(v string) bool { + if flag.Trim { + v = strings.TrimSpace(v) } - if strings.TrimSpace(cmdutil.MustGetFlag(cmd, name)) != "" { + return v != "" + } + for _, name := range append([]string{flag.Name}, flag.Aliases...) { + if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) { return name } } diff --git a/internal/corecmd/input_test.go b/internal/corecmd/input_test.go index 6f7a69bd..984f9598 100644 --- a/internal/corecmd/input_test.go +++ b/internal/corecmd/input_test.go @@ -160,6 +160,26 @@ func TestResolveInputFlags_AliasResolved(t *testing.T) { } } +// When the main flag shadows a changed alias (rawValue usability order), the +// shadowed alias must not be input-resolved: resolution targets exactly the +// name the fallback chain will read. The whitespace main value is usable for +// a non-Trim flag, and the alias path does not exist on purpose — a resolver +// that wrongly picked the alias would fail the read instead of shipping " ". +func TestResolveInputFlags_ShadowedAliasNotResolved(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}}, + }, &got) + missing := filepath.Join(t.TempDir(), "missing.txt") + + if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil { + t.Fatalf("execute: %v", err) + } + if got["content"] != " " { + t.Fatalf("toolArgs[content] = %q", got["content"]) + } +} + func TestResolveInputFlags_FileNotSupported(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ From e4daddf9cfe8b17ae002f869ed5a8b2e88a649dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 12:23:54 +0800 Subject: [PATCH 5/7] test(corecmd): name Input tests for the platform coverage gate Third-review fix for a CI blocker: run-platform-coverage-gate.sh only executes ^(TestAllShortcuts|TestCrossPlatformCoverage) yet enforces 100% coverage of changed production lines, so the TestResolveInputFlags names left every new input.go statement reported as uncovered. Rename them to the gate prefix, drop three unreachable pflag Set error branches that no test could ever cover, and add the reachable stdin read-failure case. Verified: changed code coverage 100.0000% (67 statements). --- internal/corecmd/input.go | 16 +++-------- internal/corecmd/input_test.go | 49 ++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/internal/corecmd/input.go b/internal/corecmd/input.go index c1909d15..a3bbbe18 100644 --- a/internal/corecmd/input.go +++ b/internal/corecmd/input.go @@ -155,17 +155,12 @@ func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error { return apperrors.NewValidation( fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err)) } - if err := cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)); err != nil { - return apperrors.NewValidation( - fmt.Sprintf("参数 --%s 写入解析值失败:%v", flag.Name, err)) - } + // Setting a registered string flag cannot fail. + _ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)) case strings.HasPrefix(raw, "@@"): // Escape: strip the first @, keep the rest as a literal inline value. - if err := cmd.Flags().Set(name, raw[1:]); err != nil { - return apperrors.NewValidation( - fmt.Sprintf("参数 --%s 写入解析值失败:%v", flag.Name, err)) - } + _ = cmd.Flags().Set(name, raw[1:]) case strings.HasPrefix(raw, "@"): if !inputSupports(flag, InputFile) { @@ -189,10 +184,7 @@ func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error { return apperrors.NewValidation( fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...) } - if err := cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)); err != nil { - return apperrors.NewValidation( - fmt.Sprintf("参数 --%s 写入解析值失败:%v", flag.Name, err)) - } + _ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM)) } } return nil diff --git a/internal/corecmd/input_test.go b/internal/corecmd/input_test.go index 984f9598..3edab9ab 100644 --- a/internal/corecmd/input_test.go +++ b/internal/corecmd/input_test.go @@ -51,7 +51,7 @@ func writeInputFile(t *testing.T, content string) string { return path } -func TestResolveInputFlags_File(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, @@ -66,7 +66,7 @@ func TestResolveInputFlags_File(t *testing.T) { } } -func TestResolveInputFlags_Stdin(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, @@ -83,7 +83,7 @@ func TestResolveInputFlags_Stdin(t *testing.T) { // The @@ escape keeps a literal leading @ inline and must not be treated as a // source reference even when Input is declared. -func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, @@ -97,7 +97,7 @@ func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) { } } -func TestResolveInputFlags_BOMStripped(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, @@ -114,7 +114,7 @@ func TestResolveInputFlags_BOMStripped(t *testing.T) { // Required must be satisfied by the resolved payload, proving resolution runs // before the required stage. -func TestResolveInputFlags_SatisfiesRequired(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}}, @@ -130,7 +130,7 @@ func TestResolveInputFlags_SatisfiesRequired(t *testing.T) { } // Enum validation sees the resolved content, not the "@path" token. -func TestResolveInputFlags_EnumValidatesResolvedContent(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}}, @@ -145,7 +145,7 @@ func TestResolveInputFlags_EnumValidatesResolvedContent(t *testing.T) { // A value passed through a declared alias is resolved exactly like the main // name (fallback-chain parity). -func TestResolveInputFlags_AliasResolved(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}}, @@ -165,7 +165,7 @@ func TestResolveInputFlags_AliasResolved(t *testing.T) { // name the fallback chain will read. The whitespace main value is usable for // a non-Trim flag, and the alias path does not exist on purpose — a resolver // that wrongly picked the alias would fail the read instead of shipping " ". -func TestResolveInputFlags_ShadowedAliasNotResolved(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}}, @@ -180,7 +180,7 @@ func TestResolveInputFlags_ShadowedAliasNotResolved(t *testing.T) { } } -func TestResolveInputFlags_FileNotSupported(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, @@ -192,7 +192,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) { } } -func TestResolveInputFlags_StdinNotSupported(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, @@ -205,7 +205,7 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) { } } -func TestResolveInputFlags_SingleStdinConsumer(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}}, @@ -219,7 +219,22 @@ func TestResolveInputFlags_SingleStdinConsumer(t *testing.T) { } } -func TestResolveInputFlags_FileNotFound(t *testing.T) { +// failingReader (corecmd_test.go) fails every read, making the stdin error +// branch reachable. +func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) { + var got map[string]any + cmd := newInputCommand([]FlagSpec{ + {Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}}, + }, &got) + cmd.SetIn(failingReader{}) + + err := runInputCommand(t, cmd, "--content", "-") + if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") { + t.Fatalf("expected stdin read failure, got %v", err) + } +} + +func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}}, @@ -231,7 +246,7 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) { } } -func TestResolveInputFlags_EmptyPathAfterAt(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}}, @@ -245,7 +260,7 @@ func TestResolveInputFlags_EmptyPathAfterAt(t *testing.T) { // A flag without Input keeps its literal value even when it looks like a // source reference — resolution is strictly opt-in per declaration. -func TestResolveInputFlags_NoInputSpecPassthrough(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "token", Usage: "T", Bind: "token"}, @@ -261,7 +276,7 @@ func TestResolveInputFlags_NoInputSpecPassthrough(t *testing.T) { // Registration defaults and env fallback are never input-resolved: @file only // applies to what the user literally typed on the command line. -func TestResolveInputFlags_DefaultNotResolved(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}}, @@ -277,7 +292,7 @@ func TestResolveInputFlags_DefaultNotResolved(t *testing.T) { // Trim flags judge usability on the trimmed value (rawValue), so a leading // whitespace before @ must still resolve instead of shipping as a literal. -func TestResolveInputFlags_TrimmedLeadingSpace(t *testing.T) { +func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) { var got map[string]any cmd := newInputCommand([]FlagSpec{ {Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}}, @@ -292,7 +307,7 @@ func TestResolveInputFlags_TrimmedLeadingSpace(t *testing.T) { } } -func TestValidateInputSpecs_Panics(t *testing.T) { +func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) { cases := []struct { name string flag FlagSpec From bf2c0653ed6681881d2781341d13ba522b0347c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 12:29:26 +0800 Subject: [PATCH 6/7] docs: record Input in the flag/help/schema homology field table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth-review fix: the FlagSpec sub-field table in the homology doc is the named authority for "what each field does and whether it reaches Schema parameters", and RFC §5.0.2 asserts declaration fields embed into dws.schema.*. Input satisfied neither entry, leaving its deliberate non-projection indistinguishable from an oversight. Add the table row and the §5.0.2 exception note so the capability stays a declared fact (Usage prose) rather than inviting an invented annotation. --- docs/flag-help-schema-homology.md | 1 + docs/rfc-command-framework-convergence.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/flag-help-schema-homology.md b/docs/flag-help-schema-homology.md index 47b65466..591d543d 100644 --- a/docs/flag-help-schema-homology.md +++ b/docs/flag-help-schema-homology.md @@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷 | `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) | | `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) | | `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) | +| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) | #### 1.2.2 编排 / 执行字段(不算声明) diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index 30d6b7e3..55aa6646 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -292,7 +292,7 @@ Definition(仅声明;不可编译) 下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`): -- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面) +- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3) - `Constraints` - **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`) - `ConstParams`(载荷声明;不上用户 flag 表) From 5ee80cdb97c5487ff079a112a93f6b49b86bf7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 15 Aug 2026 12:34:33 +0800 Subject: [PATCH 7/7] docs(rfc): warn about Input value-space collisions Fifth-review addition: declaring InputFile silently claims the whole @-prefixed value space, which matters in this product because at-mention style values are common (--at-user @zhangsan would report a file read failure), and declaring InputStdin makes a literal "-" unreachable. Both are decided at declaration time and cannot be fixed downstream, so record them next to the confirmation rule in the author rules. --- docs/rfc-command-framework-convergence.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index 55aa6646..cb12c50b 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -708,6 +708,7 @@ Flags: []shortcut.Flag{ - 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。 - `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。 - `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。 +- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。 - 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。 **今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):