Files
larksuite--cli/shortcuts/apps/apps_skill_consistency_test.go
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

328 lines
12 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// frameworkGlobalFlags are injected by shortcuts/common/runner.go for every (or
// many) shortcuts, so they are always allowed in skill docs regardless of which
// command they are attached to. See registerShortcutFlagsWithContext in
// shortcuts/common/runner.go: --dry-run, --format, --json, --jq/-q are injected
// unconditionally; --as via the identity flag; --yes for high-risk-write;
// --print-schema/--flag-name for shortcuts that opt into schema introspection;
// --help/-h are cobra built-ins.
var frameworkGlobalFlags = map[string]bool{
"dry-run": true, "format": true, "json": true, "yes": true,
"jq": true, "q": true, "as": true,
"print-schema": true, "flag-name": true, "help": true, "h": true,
}
// cmdRef is one apps command invocation extracted from a skill doc.
type cmdRef struct {
cmd string // registered command form, includes the leading '+'
flags []string // long flag names without '--', short flags without '-'
}
var (
// cmdTokenRe matches a shortcut command token. The leading '+' is the
// reliable signal; the body is a-z plus digits/hyphens. A real command
// never ends in '-', so a trailing hyphen (from a glob like `+db-*`) is
// stripped/rejected separately.
cmdTokenRe = regexp.MustCompile(`\+[a-z][a-z0-9-]*`)
longFlagRe = regexp.MustCompile(`^--([a-z][a-z0-9-]*)`)
shortFlagRe = regexp.MustCompile(`^-([a-z])$`)
// bareWordRe matches a plain lowercase word (a CLI service/qualifier token
// like "apps", "contact", "im", "code") with no markdown decoration.
bareWordRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
)
// documentedNonexistentExamples are apps-prefixed command tokens the lark-apps
// docs deliberately cite as NOT being apps commands (negative examples that
// warn agents away from inventing them). They are intentionally absent from
// Shortcuts(); excluding them here is narrow and explicit, unlike skipping any
// line containing "不存在" (a common Chinese word meaning "does not exist")
// which would mask real drift on unrelated lines.
//
// Source lines (both files carry the identical sentence):
// - skills/lark-apps/references/lark-apps-local-dev.md:52
// - skills/lark-apps/references/lark-apps-git-credential.md:35
// "...不存在 `apps +pull` / `apps +push` / `apps code +read` 这类...shortcut..."
//
// Only `+pull` and `+push` are apps-prefixed and thus need an explicit entry
// here. `apps code +read` is preceded by the bare qualifier word "code" (not
// "apps"), so the cross-service filter already rejects it; adding it would be
// redundant double-coverage, so it is intentionally omitted.
var documentedNonexistentExamples = map[string]bool{
"+pull": true,
"+push": true,
}
// extractCmdRefs joins backslash-continued lines, then for each `+<cmd>` token
// captures the --flags/-q that follow it, stopping at the next `+<cmd>` token, a
// shell separator (| && ;), or the end of the inline-code span the command
// appears in. Flags only attach within the same backtick-delimited segment as
// the command, because skill docs write a real invocation inside one code span
// (`lark-cli apps +create --name x`) while a stray `--flag` discussed in prose
// (e.g. "`+git-credential-list` ... 不需要 `--app-id`") lives in a separate
// span and must not attach.
//
// To avoid false positives it also:
// - skips a `+token` immediately preceded by a bare service/qualifier word
// other than "apps" (e.g. `contact +search-user`, `im +chat-search`,
// `apps code +read`) — those are not apps shortcuts;
// - rejects a token that ends in '-' (a wildcard family like `+db-*`,
// `+release-*`), since no registered command ends in a hyphen.
//
// Deliberate negative examples (documentedNonexistentExamples, e.g. `+pull`)
// are still extracted here; the consistency gate skips them explicitly when an
// unregistered command turns out to be one of those documented examples.
func extractCmdRefs(doc string) []cmdRef {
var refs []cmdRef
for _, logical := range logicalLines(doc) {
// Split the logical line into backtick-delimited segments. A command and
// its flags only travel together within one segment; crossing a backtick
// boundary resets the capture context. Code-block lines (no backticks)
// are a single segment and behave like a normal command line.
var cur *cmdRef
var prevClean string
for _, seg := range strings.Split(logical, "`") {
cur = nil // a new inline span never inherits the previous command
prevClean = ""
for _, tok := range strings.Fields(seg) {
clean := strings.Trim(tok, ",'\"()*")
if tok == "|" || tok == "&&" || tok == ";" {
cur = nil
prevClean = clean
continue
}
if strings.HasPrefix(clean, "+") {
m := cmdTokenRe.FindString(clean)
if m == "" || strings.HasSuffix(m, "-") {
// Not a real command shape (e.g. "+1") or a wildcard
// family like "+db-" from `+db-*`. No capture context.
cur = nil
prevClean = clean
continue
}
// Cross-service reference: nearest preceding bare word is a
// service/qualifier other than "apps".
if bareWordRe.MatchString(prevClean) && prevClean != "apps" && prevClean != "lark-cli" {
cur = nil
prevClean = clean
continue
}
refs = append(refs, cmdRef{cmd: m})
cur = &refs[len(refs)-1]
prevClean = clean
continue
}
if cur != nil {
if m := longFlagRe.FindStringSubmatch(clean); m != nil {
cur.flags = append(cur.flags, m[1])
} else if m := shortFlagRe.FindStringSubmatch(clean); m != nil {
cur.flags = append(cur.flags, m[1])
}
}
prevClean = clean
}
}
}
return refs
}
// logicalLines merges lines ending with a backslash into one logical line.
func logicalLines(doc string) []string {
raw := strings.Split(strings.ReplaceAll(doc, "\r\n", "\n"), "\n")
var out []string
var buf strings.Builder
carrying := false
for _, ln := range raw {
t := strings.TrimRight(ln, " \t")
if strings.HasSuffix(t, "\\") {
buf.WriteString(strings.TrimSuffix(t, "\\"))
buf.WriteString(" ")
carrying = true
continue
}
buf.WriteString(ln)
out = append(out, buf.String())
buf.Reset()
carrying = false
}
if carrying || buf.Len() > 0 {
out = append(out, buf.String())
}
return out
}
func TestExtractCmdRefs_Unit(t *testing.T) {
doc := "`lark-cli apps +create --name x --app-type html`\n" +
"`+db-table-list`, `+db-table-get`\n" +
"lark-cli apps +session-list --app-id x | jq '.y --post-pipe-flag'\n" +
"lark-cli apps +foo --bar baz \\\n --qux 1\n" +
"人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`\n" +
"改库走 `+db-*`;发布走 `+release-*`\n" +
"不存在 `apps +pull` / `apps +push` / `apps code +read` 这类 shortcut,不要臆造。\n" +
"`+git-credential-list` 列出本地凭证,不需要 `--app-id`。\n"
refs := extractCmdRefs(doc)
got := map[string][]string{}
for _, r := range refs {
got[r.cmd] = append(got[r.cmd], r.flags...)
}
// Full invocation: command + both flags captured.
if _, ok := got["+create"]; !ok {
t.Fatalf("missing +create; got %+v", refs)
}
if !contains(got["+create"], "name") || !contains(got["+create"], "app-type") {
t.Errorf("+create flags wrong: %v", got["+create"])
}
// Comma-separated command list: no flags attach to either command.
if _, ok := got["+db-table-list"]; !ok {
t.Errorf("missing +db-table-list; got %+v", refs)
}
if len(got["+db-table-list"]) != 0 || len(got["+db-table-get"]) != 0 {
t.Errorf("comma-separated commands must carry no flags: %v", got)
}
// Pipe stops capture within a SINGLE span (no surrounding backticks), so the
// pipe `|` is the only boundary that can stop flag capture here: --app-id
// (before the pipe) attaches, but the post-pipe --post-pipe-flag must NOT.
if !contains(got["+session-list"], "app-id") {
t.Errorf("pre-pipe flag should attach to +session-list: %v", got["+session-list"])
}
if contains(got["+session-list"], "post-pipe-flag") {
t.Errorf("pipe did not stop flag capture: %v", got["+session-list"])
}
// Backslash continuation joins --qux onto +foo (same logical line).
if !contains(got["+foo"], "bar") || !contains(got["+foo"], "qux") {
t.Errorf("continuation join failed: %v", got["+foo"])
}
// Cross-service commands must NOT be attributed to apps.
if _, ok := got["+search-user"]; ok {
t.Errorf("contact +search-user must not be extracted as an apps command: %+v", refs)
}
if _, ok := got["+chat-search"]; ok {
t.Errorf("im +chat-search must not be extracted as an apps command: %+v", refs)
}
// Wildcard family references must NOT be extracted as commands.
if _, ok := got["+db-"]; ok {
t.Errorf("`+db-*` wildcard must not be extracted as a command: %+v", refs)
}
if _, ok := got["+release-"]; ok {
t.Errorf("`+release-*` wildcard must not be extracted as a command: %+v", refs)
}
// Deliberate negative examples are no longer line-skipped: the apps-prefixed
// `+pull` / `+push` ARE extracted here (the consistency gate later excludes
// them via documentedNonexistentExamples). `apps code +read` is preceded by
// the bare qualifier "code", so the cross-service filter still drops it.
for _, tok := range []string{"+pull", "+push"} {
if _, ok := got[tok]; !ok {
t.Errorf("negative example %s should still be extracted (gate excludes it, not the extractor): %+v", tok, refs)
}
if !documentedNonexistentExamples[tok] {
t.Errorf("%s must be in documentedNonexistentExamples allowlist", tok)
}
}
if _, ok := got["+read"]; ok {
t.Errorf("`apps code +read` is cross-service (preceded by `code`) and must not be extracted: %+v", refs)
}
// A --flag discussed in prose, in a separate inline-code span from the
// command, must NOT attach to the command (backtick-span boundary stops
// capture).
if contains(got["+git-credential-list"], "app-id") {
t.Errorf("prose flag in a separate backtick span must not attach: %v", got["+git-credential-list"])
}
}
func TestSkillDocsCommandsConsistentWithShortcuts(t *testing.T) {
// Source of truth: the registered shortcuts and their flags.
validCmd := map[string]map[string]bool{}
for _, s := range Shortcuts() {
fl := map[string]bool{}
for _, f := range s.Flags {
fl[f.Name] = true
}
validCmd[s.Command] = fl
}
docs := skillDocFiles(t)
if len(docs) == 0 {
t.Fatal("no lark-apps skill docs found; gate cannot run")
}
for _, path := range docs {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
rel := filepath.Base(path)
for _, ref := range extractCmdRefs(string(raw)) {
flags, ok := validCmd[ref.cmd]
if !ok {
// A deliberate negative example (documented as NOT existing) is
// expected to be absent from Shortcuts(); skip only those.
if documentedNonexistentExamples[ref.cmd] {
continue
}
t.Errorf("%s: references `apps %s` which is not a registered shortcut", rel, ref.cmd)
continue
}
for _, fl := range ref.flags {
if flags[fl] || frameworkGlobalFlags[fl] {
continue
}
t.Errorf("%s: `apps %s --%s`: --%s is not a flag of %s (have: %s)",
rel, ref.cmd, fl, fl, ref.cmd, sortedFlags(flags))
}
}
}
}
// skillDocFiles returns SKILL.md + references/*.md for lark-apps, relative to
// this package dir (go test cwd = shortcuts/apps/).
func skillDocFiles(t *testing.T) []string {
t.Helper()
base := filepath.Join("..", "..", "skills", "lark-apps")
var out []string
if _, err := os.Stat(filepath.Join(base, "SKILL.md")); err == nil {
out = append(out, filepath.Join(base, "SKILL.md"))
}
refs, _ := filepath.Glob(filepath.Join(base, "references", "*.md"))
out = append(out, refs...)
return out
}
func sortedFlags(m map[string]bool) string {
names := make([]string, 0, len(m))
for n := range m {
names = append(names, n)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}