mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
887 lines
24 KiB
Go
887 lines
24 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package rules
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/qualitygate/facts"
|
|
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
|
"github.com/larksuite/cli/internal/qualitygate/report"
|
|
"github.com/larksuite/cli/internal/qualitygate/skillscan"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
const dryRunTimeout = 20 * time.Second
|
|
const dryRunStdoutLimit = 256 * 1024
|
|
|
|
var errNoDryRunAPI = errors.New("dry-run output does not contain api request")
|
|
|
|
var placeholderValuePattern = regexp.MustCompile(`\b([a-z]{1,8})_x+\b`)
|
|
var dryRunAngleTokenPattern = regexp.MustCompile(`<([^>\n]+)>`)
|
|
var dryRunXMLTagNamePattern = regexp.MustCompile(`^[a-z][a-z0-9:_-]*$`)
|
|
|
|
func RunDryRuns(ctx context.Context, cliBin string, m manifest.Manifest, examples []skillscan.Example) ([]report.Diagnostic, []facts.CommandExample) {
|
|
index := indexManifest(m)
|
|
var diags []report.Diagnostic
|
|
var out []facts.CommandExample
|
|
for _, ex := range examples {
|
|
fact := classifyExample(ex)
|
|
if !fact.Executable {
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
parsed, err := parseAgainstManifest(m, ex.Raw)
|
|
if err != nil {
|
|
if ex.HasPlaceholder || skillscan.HasPlaceholder(ex.Raw) {
|
|
fact.Executable = false
|
|
fact.SkipReason = "placeholder"
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
if errors.Is(err, errUnknownCommand) && commandPathContainsPlaceholder(ex.Raw) {
|
|
fact.Executable = false
|
|
fact.SkipReason = "placeholder"
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
diags = append(diags, parseWarning(ex, err))
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
commandPath := parsed.CommandPath
|
|
cmd := index.commands[commandPath]
|
|
fact.CommandPath = commandPath
|
|
if cmd == nil {
|
|
fact.SkipReason = "unknown_command"
|
|
fact.Executable = false
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
if hasUnknownParsedFlag(index, parsed) {
|
|
fact.SkipReason = "invalid_reference"
|
|
fact.Executable = false
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
runRaw := ex.Raw
|
|
if ex.HasPlaceholder || skillscan.HasPlaceholder(ex.Raw) {
|
|
materialized, ok := materializePlaceholderExample(ex.Raw, *cmd)
|
|
if !ok {
|
|
fact.Executable = false
|
|
fact.SkipReason = "placeholder"
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
runRaw = materialized.Raw
|
|
}
|
|
if skip := dryRunIdentitySkip(*cmd, runRaw); skip != "" {
|
|
fact.SkipReason = skip
|
|
fact.Executable = false
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
if !index.hasFlag(commandPath, "dry-run") {
|
|
fact.SkipReason = "no_dry_run_flag"
|
|
fact.Executable = false
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
argv, err := appendDryRunArg(runRaw)
|
|
if err != nil {
|
|
diags = append(diags, parseWarning(ex, err))
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
result := runCommand(ctx, cliBin, argv)
|
|
fact.ExitCode = result.ExitCode
|
|
fact.StdoutBytes = len(result.Stdout)
|
|
if result.TimedOut {
|
|
fact.Executable = false
|
|
fact.SkipReason = "timeout"
|
|
diags = append(diags, dryRunFailureDiagnostic(ex, result))
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
if result.Err != nil || result.ExitCode != 0 {
|
|
diags = append(diags, dryRunFailureDiagnostic(ex, result))
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
preview, apiCallCount, err := extractDryRunJSON(result.Stdout)
|
|
if err != nil {
|
|
if errors.Is(err, errNoDryRunAPI) {
|
|
if fact.ExpectedRequest != nil {
|
|
diags = append(diags, validateDryRunShape(fact)...)
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
fact.SkipReason = "non_api_dry_run"
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
if result.StdoutTruncated {
|
|
fact.Executable = false
|
|
fact.SkipReason = "stdout_truncated"
|
|
}
|
|
diags = append(diags, dryRunMalformedDiagnostic(ex, err, result))
|
|
out = append(out, fact)
|
|
continue
|
|
}
|
|
fact.APICallCount = apiCallCount
|
|
fact.DryRun = &preview
|
|
diags = append(diags, validateDryRunShape(fact)...)
|
|
out = append(out, fact)
|
|
}
|
|
return diags, out
|
|
}
|
|
|
|
func classifyExample(ex skillscan.Example) facts.CommandExample {
|
|
fact := facts.CommandExample{Raw: ex.Raw, SourceFile: ex.SourceFile, Line: ex.Line, Executable: true}
|
|
argv, _ := splitShellWords(ex.Raw)
|
|
switch {
|
|
case hasSubcommand(argv, "auth", "login"):
|
|
fact.Executable, fact.SkipReason = false, "interactive"
|
|
case hasSubcommand(argv, "config", "init"):
|
|
fact.Executable, fact.SkipReason = false, "local_state"
|
|
case hasAtFileArg(argv):
|
|
fact.Executable, fact.SkipReason = false, "file_input"
|
|
case hasExactArg(argv, "-") || hasExactArg(argv, "|"):
|
|
fact.Executable, fact.SkipReason = false, "stdin"
|
|
case strings.Contains(ex.Raw, "--help") || strings.Contains(ex.Raw, " -h"):
|
|
fact.Executable, fact.SkipReason = false, "help"
|
|
case strings.Contains(ex.Raw, "--yes") || strings.Contains(ex.Raw, "--force"):
|
|
fact.Executable, fact.SkipReason = false, "high_risk"
|
|
case hasAnyExactArg(argv, "&&", "||", ">", "2>", "<"):
|
|
fact.Executable, fact.SkipReason = false, "shell_operator"
|
|
}
|
|
return fact
|
|
}
|
|
|
|
type materializedExample struct {
|
|
Raw string
|
|
}
|
|
|
|
type placeholderContext struct {
|
|
FlagName string
|
|
FlagUsage string
|
|
}
|
|
|
|
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
|
|
if strings.Contains(raw, "$") || strings.Contains(raw, "...") {
|
|
return materializedExample{}, false
|
|
}
|
|
argv, err := splitShellWords(raw)
|
|
if err != nil || len(argv) == 0 {
|
|
return materializedExample{}, false
|
|
}
|
|
commandArgEnd := 1 + len(strings.Fields(cmd.Path))
|
|
for i := 1; i < len(argv); i++ {
|
|
arg := argv[i]
|
|
if isShellOperator(arg) {
|
|
break
|
|
}
|
|
if strings.HasPrefix(arg, "--") {
|
|
name := strings.TrimPrefix(arg, "--")
|
|
if eq := strings.IndexByte(name, '='); eq >= 0 {
|
|
flagName := name[:eq]
|
|
flag := findManifestFlag(&cmd, flagName)
|
|
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(flagName, flag))
|
|
if !ok {
|
|
return materializedExample{}, false
|
|
}
|
|
argv[i] = "--" + flagName + "=" + value
|
|
continue
|
|
}
|
|
flag := findManifestFlag(&cmd, name)
|
|
if flag != nil && flag.TakesValue && i+1 < len(argv) {
|
|
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(name, flag))
|
|
if !ok {
|
|
return materializedExample{}, false
|
|
}
|
|
argv[i+1] = value
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
|
name := strings.TrimPrefix(arg, "-")
|
|
flag := findManifestFlag(&cmd, name)
|
|
if flag != nil && flag.TakesValue && i+1 < len(argv) {
|
|
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(flag.Name, flag))
|
|
if !ok {
|
|
return materializedExample{}, false
|
|
}
|
|
argv[i+1] = value
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if i >= commandArgEnd {
|
|
value, ok := materializePlaceholderValue(arg, placeholderContext{})
|
|
if !ok {
|
|
return materializedExample{}, false
|
|
}
|
|
argv[i] = value
|
|
}
|
|
}
|
|
materializedRaw := shellJoinArgs(argv)
|
|
if hasUnresolvedDryRunPlaceholder(materializedRaw) {
|
|
return materializedExample{}, false
|
|
}
|
|
return materializedExample{Raw: materializedRaw}, true
|
|
}
|
|
|
|
func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderContext {
|
|
ctx := placeholderContext{FlagName: name}
|
|
if flag != nil {
|
|
ctx.FlagUsage = flag.Usage
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func materializePlaceholderValue(value string, ctx placeholderContext) (string, bool) {
|
|
if !hasUnresolvedDryRunPlaceholder(value) {
|
|
return value, true
|
|
}
|
|
if strings.Contains(value, "$") || strings.Contains(value, "...") {
|
|
return "", false
|
|
}
|
|
if ctx.FlagName == "params" && !looksLikeJSONValue(value) {
|
|
return "", false
|
|
}
|
|
out := placeholderValuePattern.ReplaceAllString(value, "${1}_test123")
|
|
var ok bool
|
|
out, ok = replaceAnglePlaceholders(out, ctx)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func looksLikeJSONValue(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
return strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")
|
|
}
|
|
|
|
func replaceAnglePlaceholders(value string, ctx placeholderContext) (string, bool) {
|
|
var b strings.Builder
|
|
for {
|
|
start := strings.IndexByte(value, '<')
|
|
if start < 0 {
|
|
b.WriteString(value)
|
|
return b.String(), true
|
|
}
|
|
end := strings.IndexByte(value[start+1:], '>')
|
|
if end < 0 {
|
|
return "", false
|
|
}
|
|
end += start + 1
|
|
b.WriteString(value[:start])
|
|
literal := value[start : end+1]
|
|
if !hasUnresolvedDryRunPlaceholder(literal) {
|
|
b.WriteString(literal)
|
|
value = value[end+1:]
|
|
continue
|
|
}
|
|
replacement, ok := fakeValueForPlaceholder(value[start+1:end], ctx)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
b.WriteString(replacement)
|
|
value = value[end+1:]
|
|
}
|
|
}
|
|
|
|
func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool) {
|
|
name := normalizePlaceholderName(raw)
|
|
if name == "" {
|
|
return "", false
|
|
}
|
|
if value, ok := fakeValueFromPlaceholderName(name); ok {
|
|
return value, true
|
|
}
|
|
if isGenericPlaceholderName(name) {
|
|
return fakeValueFromUsageHint(ctx.FlagUsage)
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func fakeValueFromPlaceholderName(name string) (string, bool) {
|
|
if isGenericPlaceholderName(name) || isLikelyEnumPlaceholder(name) {
|
|
return "", false
|
|
}
|
|
tokens := placeholderTokenSet(name)
|
|
switch {
|
|
case hasPlaceholderToken(tokens, "chat", "container", "feed"):
|
|
return "oc_test123", true
|
|
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
|
|
return "ou_test123", true
|
|
case hasPlaceholderToken(tokens, "department", "dept"):
|
|
return "od_test123", true
|
|
case hasPlaceholderToken(tokens, "message"):
|
|
return "om_test123", true
|
|
case name == "file_key":
|
|
return "file_test123", true
|
|
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
|
|
return "file_test123", true
|
|
case hasPlaceholderToken(tokens, "image", "img"):
|
|
return "img_test123", true
|
|
case hasPlaceholderToken(tokens, "app"):
|
|
return "app_test123", true
|
|
case hasPlaceholderToken(tokens, "doc", "document"):
|
|
return "doc_test123", true
|
|
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
|
|
return "shtcn_test123", true
|
|
case hasPlaceholderToken(tokens, "base"):
|
|
return "base_test123", true
|
|
case hasPlaceholderToken(tokens, "table"):
|
|
return "tbl_test123", true
|
|
case hasPlaceholderToken(tokens, "view"):
|
|
return "viw_test123", true
|
|
case hasPlaceholderToken(tokens, "record"):
|
|
return "rec_test123", true
|
|
case hasPlaceholderToken(tokens, "field"):
|
|
return "fld_test123", true
|
|
case hasPlaceholderToken(tokens, "wiki", "node", "obj"):
|
|
return "wiki_test123", true
|
|
case hasPlaceholderToken(tokens, "meeting"):
|
|
return "meeting_test123", true
|
|
case hasPlaceholderToken(tokens, "minute"):
|
|
return "obcn_test123", true
|
|
case hasPlaceholderToken(tokens, "task"):
|
|
return "task_test123", true
|
|
case hasPlaceholderToken(tokens, "item"):
|
|
return "item_test123", true
|
|
case hasPlaceholderToken(tokens, "page") && hasPlaceholderToken(tokens, "token"):
|
|
return "page_test123", true
|
|
case hasPlaceholderToken(tokens, "date"):
|
|
return "2026-01-02", true
|
|
case hasPlaceholderToken(tokens, "time", "start", "end"):
|
|
return "2026-01-02T00:00:00+08:00", true
|
|
case hasPlaceholderToken(tokens, "url", "link"):
|
|
return "https://example.test/resource", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func fakeValueFromUsageHint(usage string) (string, bool) {
|
|
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
|
|
if len(match) != 2 || !knownTokenPrefix(match[1]) {
|
|
return "", false
|
|
}
|
|
return match[1] + "_test123", true
|
|
}
|
|
|
|
func knownTokenPrefix(prefix string) bool {
|
|
switch prefix {
|
|
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isGenericPlaceholderName(name string) bool {
|
|
switch name {
|
|
case "code", "command", "id", "key", "method", "name", "resource", "service", "token", "type", "value":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isLikelyEnumPlaceholder(name string) bool {
|
|
return strings.HasSuffix(name, "_type") ||
|
|
strings.HasSuffix(name, "_name") ||
|
|
strings.HasSuffix(name, "_mode") ||
|
|
strings.HasSuffix(name, "_status")
|
|
}
|
|
|
|
func placeholderTokenSet(name string) map[string]bool {
|
|
tokens := map[string]bool{}
|
|
for _, token := range strings.FieldsFunc(name, func(r rune) bool {
|
|
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
|
|
}) {
|
|
if token != "" {
|
|
tokens[token] = true
|
|
}
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
|
|
for _, want := range wants {
|
|
if tokens[want] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasUnresolvedDryRunPlaceholder(value string) bool {
|
|
if skillscan.HasPlaceholder(value) {
|
|
return true
|
|
}
|
|
for _, match := range dryRunAngleTokenPattern.FindAllStringSubmatch(value, -1) {
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
if isDryRunTemplateAngle(match[1], value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isDryRunTemplateAngle(inner, raw string) bool {
|
|
inner = strings.TrimSpace(inner)
|
|
if inner == "" || strings.HasPrefix(inner, "/") || strings.HasPrefix(inner, "!") || strings.HasPrefix(inner, "?") {
|
|
return false
|
|
}
|
|
name := inner
|
|
if cut := strings.IndexAny(name, " \t/"); cut >= 0 {
|
|
name = name[:cut]
|
|
}
|
|
lower := strings.ToLower(strings.TrimPrefix(name, "/"))
|
|
if dryRunMarkupTag(lower) {
|
|
return false
|
|
}
|
|
if strings.Contains(inner, "=") || strings.HasSuffix(strings.TrimSpace(inner), "/") {
|
|
return false
|
|
}
|
|
if dryRunXMLTagNamePattern.MatchString(lower) && strings.Contains(strings.ToLower(raw), "</"+lower+">") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func dryRunMarkupTag(name string) bool {
|
|
switch name {
|
|
case "a", "b", "br", "code", "content", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
"i", "img", "li", "ol", "p", "span", "strong", "table", "tbody", "td", "th", "thead",
|
|
"title", "tr", "ul":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizePlaceholderName(raw string) string {
|
|
name := strings.TrimSpace(raw)
|
|
if cut := strings.Index(name, "|"); cut >= 0 {
|
|
name = name[:cut]
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if cut := strings.IndexAny(name, " \t/"); cut >= 0 {
|
|
name = name[:cut]
|
|
}
|
|
name = strings.Trim(name, "[]{}()")
|
|
name = strings.ReplaceAll(name, "-", "_")
|
|
return strings.ToLower(name)
|
|
}
|
|
|
|
func hasUnknownParsedFlag(index manifestIndex, parsed ParsedExample) bool {
|
|
for _, flag := range parsed.Flags {
|
|
if !index.hasFlag(parsed.CommandPath, flag) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func shellJoinArgs(argv []string) string {
|
|
out := make([]string, 0, len(argv))
|
|
for _, arg := range argv {
|
|
out = append(out, shellSingleQuote(arg))
|
|
}
|
|
return strings.Join(out, " ")
|
|
}
|
|
|
|
func shellSingleQuote(arg string) string {
|
|
if arg == "" {
|
|
return "''"
|
|
}
|
|
if strings.IndexFunc(arg, func(r rune) bool {
|
|
return !(r == '_' || r == '-' || r == '+' || r == '.' || r == '/' || r == ':' || r == '=' ||
|
|
('0' <= r && r <= '9') || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z'))
|
|
}) < 0 {
|
|
return arg
|
|
}
|
|
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
|
|
}
|
|
|
|
func hasSubcommand(argv []string, parts ...string) bool {
|
|
for i := 0; i+len(parts) <= len(argv); i++ {
|
|
match := true
|
|
for j, part := range parts {
|
|
if argv[i+j] != part {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasExactArg(argv []string, want string) bool {
|
|
for _, arg := range argv {
|
|
if arg == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasAnyExactArg(argv []string, wants ...string) bool {
|
|
for _, want := range wants {
|
|
if hasExactArg(argv, want) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasAtFileArg(argv []string) bool {
|
|
for _, arg := range argv {
|
|
if strings.HasPrefix(arg, "@") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func dryRunIdentitySkip(cmd manifest.Command, raw string) string {
|
|
if len(cmd.Identities) == 0 {
|
|
return ""
|
|
}
|
|
as, hasAs := explicitAs(raw)
|
|
if hasAs && as == "bot" && supportsIdentity(cmd.Identities, "bot") {
|
|
return ""
|
|
}
|
|
if hasAs && as == "user" && supportsIdentity(cmd.Identities, "user") {
|
|
return ""
|
|
}
|
|
if supportsIdentity(cmd.Identities, "user") && !supportsIdentity(cmd.Identities, "bot") {
|
|
return "requires_user_identity"
|
|
}
|
|
if supportsIdentity(cmd.Identities, "user") && supportsIdentity(cmd.Identities, "bot") && !hasAs {
|
|
return "identity_auto_requires_state"
|
|
}
|
|
if !supportsIdentity(cmd.Identities, "bot") {
|
|
return "unsupported_identity"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func supportsIdentity(ids []string, want string) bool {
|
|
for _, id := range ids {
|
|
if id == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func explicitAs(raw string) (string, bool) {
|
|
argv, err := splitShellWords(raw)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
for i, arg := range argv {
|
|
if strings.HasPrefix(arg, "--as=") {
|
|
return strings.TrimPrefix(arg, "--as="), true
|
|
}
|
|
if arg == "--as" && i+1 < len(argv) {
|
|
return argv[i+1], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func appendDryRunArg(raw string) ([]string, error) {
|
|
argv, err := splitShellWords(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(argv) == 0 || argv[0] != "lark-cli" {
|
|
return nil, fmt.Errorf("not a lark-cli command")
|
|
}
|
|
argv = truncateShellTail(argv)
|
|
hasDryRunArg := false
|
|
dryRunEnabled := false
|
|
for _, arg := range argv[1:] {
|
|
if arg == "--dry-run" {
|
|
hasDryRunArg = true
|
|
dryRunEnabled = true
|
|
continue
|
|
}
|
|
if strings.HasPrefix(arg, "--dry-run=") {
|
|
hasDryRunArg = true
|
|
dryRunEnabled = dryRunFlagExplicitlyTrue(arg)
|
|
}
|
|
}
|
|
if hasDryRunArg && dryRunEnabled {
|
|
return argv[1:], nil
|
|
}
|
|
return append(argv[1:], "--dry-run"), nil
|
|
}
|
|
|
|
func truncateShellTail(argv []string) []string {
|
|
for i, arg := range argv {
|
|
if i == 0 {
|
|
continue
|
|
}
|
|
if isShellOperator(arg) {
|
|
return argv[:i]
|
|
}
|
|
}
|
|
return argv
|
|
}
|
|
|
|
func dryRunFlagExplicitlyTrue(arg string) bool {
|
|
value, ok := strings.CutPrefix(arg, "--dry-run=")
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch strings.ToLower(value) {
|
|
case "1", "t", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type commandResult struct {
|
|
ExitCode int
|
|
Stdout []byte
|
|
Stderr []byte
|
|
Err error
|
|
TimedOut bool
|
|
StdoutTruncated bool
|
|
}
|
|
|
|
func runCommand(ctx context.Context, cliBin string, argv []string) commandResult {
|
|
tempDir, err := vfs.MkdirTemp("", "lark-cli-quality-gate-")
|
|
if err != nil {
|
|
return commandResult{Err: err, ExitCode: 1}
|
|
}
|
|
defer vfs.RemoveAll(tempDir)
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, dryRunTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(runCtx, cliBin, argv...)
|
|
cmd.Env = append(os.Environ(),
|
|
"LARKSUITE_CLI_CONFIG_DIR="+tempDir,
|
|
"LARKSUITE_CLI_APP_ID=dry-run",
|
|
"LARKSUITE_CLI_APP_SECRET=dry-run",
|
|
"LARKSUITE_CLI_BRAND=feishu",
|
|
"LARKSUITE_CLI_REMOTE_META=off",
|
|
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
|
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
|
)
|
|
stdout := limitedBuffer{N: dryRunStdoutLimit}
|
|
stderr := limitedBuffer{N: 32 * 1024}
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
runErr := cmd.Run()
|
|
result := commandResult{
|
|
Stdout: stdout.Bytes(),
|
|
Stderr: stderr.Bytes(),
|
|
Err: runErr,
|
|
TimedOut: runCtx.Err() == context.DeadlineExceeded,
|
|
StdoutTruncated: stdout.Truncated(),
|
|
}
|
|
if runErr == nil {
|
|
return result
|
|
}
|
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
} else {
|
|
result.ExitCode = 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
type limitedBuffer struct {
|
|
N int
|
|
buf bytes.Buffer
|
|
truncated bool
|
|
}
|
|
|
|
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
|
remaining := b.N - b.buf.Len()
|
|
if remaining <= 0 {
|
|
if len(p) > 0 {
|
|
b.truncated = true
|
|
}
|
|
return len(p), nil
|
|
}
|
|
if len(p) > remaining {
|
|
b.truncated = true
|
|
_, _ = b.buf.Write(p[:remaining])
|
|
return len(p), nil
|
|
}
|
|
_, _ = b.buf.Write(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (b *limitedBuffer) Bytes() []byte {
|
|
return b.buf.Bytes()
|
|
}
|
|
|
|
func (b *limitedBuffer) Truncated() bool {
|
|
return b.truncated
|
|
}
|
|
|
|
func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
|
|
start := bytes.IndexByte(raw, '{')
|
|
if start < 0 {
|
|
return facts.DryRunRequest{}, 0, fmt.Errorf("dry-run output does not contain JSON")
|
|
}
|
|
var firstErr error
|
|
for start >= 0 {
|
|
var preview struct {
|
|
API []facts.DryRunRequest `json:"api"`
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
|
|
if err := dec.Decode(&preview); err == nil {
|
|
if len(preview.API) == 0 {
|
|
if firstErr == nil {
|
|
firstErr = errNoDryRunAPI
|
|
}
|
|
} else {
|
|
return preview.API[0], len(preview.API), nil
|
|
}
|
|
} else if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
next := bytes.IndexByte(raw[start+1:], '{')
|
|
if next < 0 {
|
|
break
|
|
}
|
|
start += next + 1
|
|
}
|
|
if firstErr != nil {
|
|
return facts.DryRunRequest{}, 0, firstErr
|
|
}
|
|
return facts.DryRunRequest{}, 0, fmt.Errorf("dry-run output does not contain JSON")
|
|
}
|
|
|
|
func validateDryRunShape(f facts.CommandExample) []report.Diagnostic {
|
|
if f.ExpectedRequest == nil {
|
|
return nil
|
|
}
|
|
if f.DryRun == nil || f.APICallCount != 1 {
|
|
return []report.Diagnostic{{
|
|
Rule: "example_dry_run_request_shape",
|
|
Action: report.ActionReject,
|
|
File: f.SourceFile,
|
|
Line: f.Line,
|
|
Message: fmt.Sprintf("dry-run emitted %d API requests for %q; expected exactly one", f.APICallCount, f.Raw),
|
|
Suggestion: "update the example or command implementation so the request preview is unambiguous",
|
|
}}
|
|
}
|
|
if f.ExpectedRequest.Method != f.DryRun.Method || f.ExpectedRequest.URL != f.DryRun.URL ||
|
|
!reflect.DeepEqual(f.ExpectedRequest.Query, f.DryRun.Query) ||
|
|
!expectedParamsMatch(f.ExpectedRequest.Params, f.DryRun.Params) ||
|
|
!expectedBodyMatches(f.ExpectedRequest.Body, f.DryRun.Body) {
|
|
return []report.Diagnostic{{
|
|
Rule: "example_dry_run_request_shape",
|
|
Action: report.ActionReject,
|
|
File: f.SourceFile,
|
|
Line: f.Line,
|
|
Message: fmt.Sprintf("dry-run request shape mismatch for %q", f.Raw),
|
|
Suggestion: "update the example or expected endpoint metadata",
|
|
}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func expectedParamsMatch(expected, actual map[string]any) bool {
|
|
if len(expected) == 0 {
|
|
return len(actual) == 0
|
|
}
|
|
return reflect.DeepEqual(expected, actual)
|
|
}
|
|
|
|
func expectedBodyMatches(expected, actual json.RawMessage) bool {
|
|
if len(expected) == 0 {
|
|
return len(actual) == 0
|
|
}
|
|
return jsonEqual(expected, actual)
|
|
}
|
|
|
|
func jsonEqual(a, b json.RawMessage) bool {
|
|
var av any
|
|
var bv any
|
|
if err := json.Unmarshal(a, &av); err != nil {
|
|
return false
|
|
}
|
|
if err := json.Unmarshal(b, &bv); err != nil {
|
|
return false
|
|
}
|
|
return reflect.DeepEqual(av, bv)
|
|
}
|
|
|
|
func dryRunFailureDiagnostic(ex skillscan.Example, result commandResult) report.Diagnostic {
|
|
if result.TimedOut {
|
|
return report.Diagnostic{
|
|
Rule: "example_dry_run",
|
|
Action: report.ActionWarning,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: fmt.Sprintf("example dry-run timed out after %s", dryRunTimeout),
|
|
Suggestion: "inspect the command locally; timeout and local process hangs are not treated as deterministic example failures",
|
|
}
|
|
}
|
|
message := fmt.Sprintf("example dry-run exited with code %d", result.ExitCode)
|
|
if len(result.Stderr) > 0 {
|
|
message += ": " + strings.TrimSpace(string(result.Stderr))
|
|
}
|
|
return report.Diagnostic{
|
|
Rule: "example_dry_run",
|
|
Action: report.ActionReject,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: message,
|
|
Suggestion: "update the example so it can run locally with --dry-run, or mark placeholders explicitly",
|
|
}
|
|
}
|
|
|
|
func dryRunMalformedDiagnostic(ex skillscan.Example, err error, result commandResult) report.Diagnostic {
|
|
if result.StdoutTruncated {
|
|
return report.Diagnostic{
|
|
Rule: "example_dry_run",
|
|
Action: report.ActionWarning,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: fmt.Sprintf("example dry-run output exceeded %d bytes and was truncated before JSON validation completed", dryRunStdoutLimit),
|
|
Suggestion: "reduce dry-run preview size or inspect the command locally; truncated local output is not treated as a deterministic example failure",
|
|
}
|
|
}
|
|
return report.Diagnostic{
|
|
Rule: "example_dry_run",
|
|
Action: report.ActionReject,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: fmt.Sprintf("example dry-run output is not valid dry-run JSON: %v", err),
|
|
Suggestion: "ensure --dry-run prints a JSON request preview",
|
|
}
|
|
}
|