mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
351 lines
9.0 KiB
Go
351 lines
9.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package rules
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"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"
|
|
)
|
|
|
|
var errUnknownCommand = errors.New("unknown command")
|
|
|
|
type ParsedExample struct {
|
|
CommandPath string
|
|
Flags []string
|
|
Positional []string
|
|
}
|
|
|
|
type ReferencePolicy struct {
|
|
Incremental bool
|
|
ChangedFiles map[string]bool
|
|
CommandSurfaceAffected bool
|
|
BaseManifest *manifest.Manifest
|
|
BaseManifestComplete bool
|
|
}
|
|
|
|
func CheckReferences(m manifest.Manifest, examples []skillscan.Example) ([]report.Diagnostic, []facts.SkillFact) {
|
|
return CheckReferencesWithPolicy(m, examples, ReferencePolicy{})
|
|
}
|
|
|
|
func CheckReferencesWithPolicy(m manifest.Manifest, examples []skillscan.Example, policy ReferencePolicy) ([]report.Diagnostic, []facts.SkillFact) {
|
|
index := indexManifest(m)
|
|
var diags []report.Diagnostic
|
|
var skillFacts []facts.SkillFact
|
|
for _, ex := range examples {
|
|
fact := facts.SkillFact{SourceFile: ex.SourceFile, Line: ex.Line, Raw: ex.Raw}
|
|
parsed, err := parseAgainstManifest(m, ex.Raw)
|
|
if err != nil {
|
|
if errors.Is(err, errUnknownCommand) && commandPathContainsPlaceholder(ex.Raw) {
|
|
skillFacts = append(skillFacts, fact)
|
|
continue
|
|
}
|
|
if errors.Is(err, errUnknownCommand) {
|
|
fact.ReferencesInvalidCommand = true
|
|
diags = append(diags, applyReferencePolicy(rejectUnknownCommand(ex, unknownCommandPath(ex.Raw)), ex, policy))
|
|
} else {
|
|
diags = append(diags, parseWarning(ex, err))
|
|
}
|
|
skillFacts = append(skillFacts, fact)
|
|
continue
|
|
}
|
|
fact.CommandPath = parsed.CommandPath
|
|
for _, flag := range parsed.Flags {
|
|
if index.hasFlag(parsed.CommandPath, flag) {
|
|
continue
|
|
}
|
|
fact.ReferencesInvalidCommand = true
|
|
diags = append(diags, applyReferencePolicy(rejectUnknownFlag(ex, parsed.CommandPath, flag), ex, policy))
|
|
}
|
|
skillFacts = append(skillFacts, fact)
|
|
}
|
|
return diags, skillFacts
|
|
}
|
|
|
|
func applyReferencePolicy(diag report.Diagnostic, ex skillscan.Example, policy ReferencePolicy) report.Diagnostic {
|
|
if diag.Action != report.ActionReject || !policy.Incremental {
|
|
return diag
|
|
}
|
|
sourceFile := normalizeReferencePath(ex.SourceFile)
|
|
if !strings.HasPrefix(sourceFile, "skills/") || policy.ChangedFiles[sourceFile] {
|
|
return diag
|
|
}
|
|
if referenceBecameInvalid(ex, policy) {
|
|
return diag
|
|
}
|
|
diag.Action = report.ActionWarning
|
|
diag.Suggestion = "unchanged legacy skill reference; fix in a skill-specific PR or update the changed skill file before this becomes blocking"
|
|
return diag
|
|
}
|
|
|
|
func referenceBecameInvalid(ex skillscan.Example, policy ReferencePolicy) bool {
|
|
if !policy.CommandSurfaceAffected {
|
|
return false
|
|
}
|
|
if policy.BaseManifest == nil {
|
|
return false
|
|
}
|
|
if _, err := parseAgainstManifest(*policy.BaseManifest, ex.Raw); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeReferencePath(value string) string {
|
|
return strings.TrimPrefix(strings.ReplaceAll(value, "\\", "/"), "./")
|
|
}
|
|
|
|
func parseAgainstManifest(m manifest.Manifest, raw string) (ParsedExample, error) {
|
|
argv, err := splitShellWords(raw)
|
|
if err != nil {
|
|
return ParsedExample{}, err
|
|
}
|
|
if len(argv) == 0 || argv[0] != "lark-cli" {
|
|
return ParsedExample{}, fmt.Errorf("not a lark-cli command")
|
|
}
|
|
idx := indexManifest(m)
|
|
for end := len(argv); end > 1; end-- {
|
|
candidate := strings.Join(argv[1:end], " ")
|
|
cmd := idx.commands[candidate]
|
|
if cmd == nil {
|
|
continue
|
|
}
|
|
remaining := argv[end:]
|
|
if idx.children[candidate] && startsWithCommandToken(remaining) {
|
|
continue
|
|
}
|
|
flags, positional, err := consumeFlags(remaining, cmd)
|
|
if err != nil {
|
|
return ParsedExample{}, err
|
|
}
|
|
return ParsedExample{CommandPath: candidate, Flags: flags, Positional: positional}, nil
|
|
}
|
|
return ParsedExample{}, errUnknownCommand
|
|
}
|
|
|
|
func commandPathContainsPlaceholder(raw string) bool {
|
|
argv, err := splitShellWords(raw)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, arg := range argv[1:] {
|
|
if isShellOperator(arg) || strings.HasPrefix(arg, "-") {
|
|
return false
|
|
}
|
|
if skillscan.HasPlaceholder(arg) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func startsWithCommandToken(args []string) bool {
|
|
return len(args) > 0 && args[0] != "--" && !strings.HasPrefix(args[0], "-")
|
|
}
|
|
|
|
func consumeFlags(args []string, cmd *manifest.Command) ([]string, []string, error) {
|
|
var flags []string
|
|
var positional []string
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
if isShellOperator(arg) {
|
|
break
|
|
}
|
|
if arg == "--" {
|
|
positional = append(positional, args[i+1:]...)
|
|
break
|
|
}
|
|
if strings.HasPrefix(arg, "--") {
|
|
name := strings.TrimPrefix(arg, "--")
|
|
hasInlineValue := false
|
|
if eq := strings.IndexByte(name, '='); eq >= 0 {
|
|
name = name[:eq]
|
|
hasInlineValue = true
|
|
}
|
|
flag := findManifestFlag(cmd, name)
|
|
flags = append(flags, name)
|
|
if flag != nil && !hasInlineValue && flag.TakesValue && i+1 < len(args) {
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
|
name := strings.TrimPrefix(arg, "-")
|
|
if name == "h" {
|
|
name = "help"
|
|
}
|
|
if flag := findManifestFlag(cmd, name); flag != nil {
|
|
name = flag.Name
|
|
if flag.TakesValue && i+1 < len(args) {
|
|
i++
|
|
}
|
|
}
|
|
flags = append(flags, name)
|
|
continue
|
|
}
|
|
positional = append(positional, arg)
|
|
}
|
|
return flags, positional, nil
|
|
}
|
|
|
|
func isShellOperator(arg string) bool {
|
|
return arg == "#" || arg == "|" || arg == "&&" || arg == "||" || arg == ">" || arg == "2>" || arg == "<"
|
|
}
|
|
|
|
func findManifestFlag(cmd *manifest.Command, name string) *manifest.Flag {
|
|
for i := range cmd.Flags {
|
|
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name {
|
|
return &cmd.Flags[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func unknownCommandPath(raw string) string {
|
|
argv, err := splitShellWords(raw)
|
|
if err != nil || len(argv) <= 1 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for _, arg := range argv[1:] {
|
|
if strings.HasPrefix(arg, "-") {
|
|
break
|
|
}
|
|
parts = append(parts, arg)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
type manifestIndex struct {
|
|
commands map[string]*manifest.Command
|
|
children map[string]bool
|
|
flags map[string]map[string]bool
|
|
}
|
|
|
|
func indexManifest(m manifest.Manifest) manifestIndex {
|
|
index := manifestIndex{
|
|
commands: make(map[string]*manifest.Command, len(m.Commands)),
|
|
children: make(map[string]bool, len(m.Commands)),
|
|
flags: make(map[string]map[string]bool, len(m.Commands)),
|
|
}
|
|
for i := range m.Commands {
|
|
cmd := &m.Commands[i]
|
|
index.commands[cmd.Path] = cmd
|
|
flagSet := make(map[string]bool, len(cmd.Flags))
|
|
for _, fl := range cmd.Flags {
|
|
flagSet[fl.Name] = true
|
|
}
|
|
index.flags[cmd.Path] = flagSet
|
|
}
|
|
for _, cmd := range m.Commands {
|
|
parts := strings.Fields(cmd.Path)
|
|
for n := 1; n < len(parts); n++ {
|
|
parent := strings.Join(parts[:n], " ")
|
|
if index.commands[parent] != nil {
|
|
index.children[parent] = true
|
|
}
|
|
}
|
|
}
|
|
return index
|
|
}
|
|
|
|
func (i manifestIndex) hasFlag(commandPath, flag string) bool {
|
|
if flag == "help" {
|
|
return true
|
|
}
|
|
return i.flags[commandPath][flag]
|
|
}
|
|
|
|
func splitShellWords(raw string) ([]string, error) {
|
|
var words []string
|
|
var b strings.Builder
|
|
var quote rune
|
|
escaped := false
|
|
inWord := false
|
|
for _, r := range raw {
|
|
if escaped {
|
|
b.WriteRune(r)
|
|
escaped = false
|
|
inWord = true
|
|
continue
|
|
}
|
|
if quote != '\'' && r == '\\' {
|
|
escaped = true
|
|
inWord = true
|
|
continue
|
|
}
|
|
if quote != 0 {
|
|
if r == quote {
|
|
quote = 0
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
inWord = true
|
|
continue
|
|
}
|
|
switch {
|
|
case r == '\'' || r == '"':
|
|
quote = r
|
|
inWord = true
|
|
case unicode.IsSpace(r):
|
|
if inWord {
|
|
words = append(words, b.String())
|
|
b.Reset()
|
|
inWord = false
|
|
}
|
|
default:
|
|
b.WriteRune(r)
|
|
inWord = true
|
|
}
|
|
}
|
|
if escaped {
|
|
b.WriteRune('\\')
|
|
}
|
|
if quote != 0 {
|
|
return nil, fmt.Errorf("unterminated quote")
|
|
}
|
|
if inWord {
|
|
words = append(words, b.String())
|
|
}
|
|
return words, nil
|
|
}
|
|
|
|
func parseWarning(ex skillscan.Example, err error) report.Diagnostic {
|
|
return report.Diagnostic{
|
|
Rule: "skill_command_parse",
|
|
Action: report.ActionWarning,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: fmt.Sprintf("cannot parse lark-cli example: %v", err),
|
|
}
|
|
}
|
|
|
|
func rejectUnknownCommand(ex skillscan.Example, commandPath string) report.Diagnostic {
|
|
return report.Diagnostic{
|
|
Rule: "skill_command_reference",
|
|
Action: report.ActionReject,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: fmt.Sprintf("example references unknown command %q", commandPath),
|
|
Suggestion: "update the example to use a command present in the command manifest",
|
|
}
|
|
}
|
|
|
|
func rejectUnknownFlag(ex skillscan.Example, commandPath, flag string) report.Diagnostic {
|
|
return report.Diagnostic{
|
|
Rule: "skill_command_reference",
|
|
Action: report.ActionReject,
|
|
File: ex.SourceFile,
|
|
Line: ex.Line,
|
|
Message: fmt.Sprintf("example references unknown flag --%s on %s", flag, commandPath),
|
|
Suggestion: "update the example flag or add the flag to the command implementation",
|
|
}
|
|
}
|