Files
larksuite-cli/internal/qualitygate/rules/output.go
2026-06-17 16:29:33 +08:00

119 lines
3.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package rules
import (
"strings"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
)
func CheckDefaultOutput(m manifest.Manifest) ([]report.Diagnostic, []facts.OutputFact) {
var diags []report.Diagnostic
var out []facts.OutputFact
for _, cmd := range m.Commands {
if !cmd.Runnable || !looksLikeListCommand(cmd.Path) {
continue
}
fact := facts.OutputFact{
Command: cmd.Path,
Fields: cmd.DefaultFields,
IsList: true,
HasDefaultLimit: hasBoundedDefaultLimit(cmd),
HasFieldSelector: hasAnyFlag(cmd, "fields", "field", "field-id", "select-fields"),
HasDecisionField: hasDecisionField(cmd.DefaultFields),
}
if len(cmd.DefaultFields) > 0 && (!fact.HasDefaultLimit || !fact.HasDecisionField) {
diags = append(diags, report.Diagnostic{
Rule: "default_output_contract",
Action: report.ActionReject,
File: "command-manifest",
Message: cmd.Path + " default output must include a default limit and agent decision fields",
Suggestion: "add a default page-size/page-limit and include fields such as id, name, status, url, or time in default output",
SubjectType: "output",
CommandPath: cmd.Path,
})
}
if !fact.HasDefaultLimit {
diags = append(diags, report.Diagnostic{
Rule: "default_output",
Action: report.ActionWarning,
File: "command-manifest",
Message: cmd.Path + " looks like a list command without an explicit default limit flag",
Suggestion: "add a default page-size/page-limit or document why the command is bounded",
SubjectType: "output",
CommandPath: cmd.Path,
})
}
out = append(out, fact)
}
return diags, out
}
func looksLikeListCommand(path string) bool {
parts := strings.Fields(path)
if len(parts) == 0 {
return false
}
last := parts[len(parts)-1]
return last == "list" ||
last == "search" ||
strings.HasSuffix(last, "-list") ||
strings.HasSuffix(last, "-search") ||
strings.HasSuffix(last, "_list") ||
strings.HasSuffix(last, "_search")
}
func hasAnyFlag(cmd manifest.Command, names ...string) bool {
for _, fl := range cmd.Flags {
for _, name := range names {
if fl.Name == name {
return true
}
}
}
return false
}
func hasBoundedDefaultLimit(cmd manifest.Command) bool {
for _, fl := range cmd.Flags {
switch fl.Name {
case "page-size", "page-limit", "limit", "max":
if fl.DefValue != "" && fl.DefValue != "0" {
return true
}
}
}
return false
}
var decisionFieldNames = []string{"id", "name", "status", "url", "time", "created_at", "updated_at", "message_id", "file_token"}
func hasDecisionField(fields []string) bool {
want := make(map[string]bool, len(decisionFieldNames))
for _, name := range decisionFieldNames {
want[name] = true
}
for _, field := range fields {
normalized := normalizeFieldName(field)
if want[normalized] {
return true
}
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool { return r == '_' }) {
if want[part] {
return true
}
}
}
return false
}
func normalizeFieldName(field string) string {
normalized := strings.ToLower(field)
replacer := strings.NewReplacer("-", "_", ".", "_", "/", "_", " ", "_")
return replacer.Replace(normalized)
}