Compare commits

...

14 Commits

Author SHA1 Message Date
zhanghuanxu
d7be4205d0 fix(slides): lint unsupported slide font families 2026-06-30 21:28:56 +08:00
zhanghuanxu
9b05a71de3 feat(slides):template rewrite 2026-06-30 14:45:20 +08:00
zhanghuanxu
c906fcac7e feat: support PDF imports as slides 2026-06-26 15:42:09 +08:00
zhanghuanxu
6ddbbafc4f feat: expose slides presentation url 2026-06-26 14:46:47 +08:00
zhanghuanxu
bf9264c901 fix: stop advertising slides screenshot scope 2026-06-26 14:46:46 +08:00
zhanghuanxu
e9f8d1d94b feat: add slides xml get shortcut 2026-06-26 14:46:46 +08:00
zhanghuanxu
a520b7ca93 feat: add slides replace-pages shortcut 2026-06-26 14:46:46 +08:00
taojieyeta-design
806e8679f6 feat: sync approval skill for meta api commands (#1499)
* feat: sync approval skill for meta api commands

* docs: fix approval skill reference links

* docs: restore approval reference links

* docs: align approval skill with review guidelines

* docs: clarify approval skill boundaries

* docs: remove implementation detail from approval description
2026-06-25 20:40:59 +08:00
Public Content Screenshot
d69761e205 fix(ci): reduce public content false positives 2026-06-25 20:23:15 +08:00
SunPeiYang996
7346de30b1 docs(lark-doc): restore style requirements (#1579)
Change-Id: I5c75a06ccac07586615c40db69b94d515f85d200
2026-06-25 19:18:05 +08:00
hanshaoshuai
cf93ee051c feat(ci): add public content safeguards 2026-06-25 19:03:14 +08:00
SunPeiYang996
fe32a6e0a9 feat(docs): support create title option (#1536)
* feat: support docs create title option

Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf

* docs: refine docs title guidance

Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc

* docs: keep lark doc skill create example

Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4

* test: allow docs create title flag in help

Change-Id: I0226e20c6bf2187eb6c4f0d2d5e37ab9225d4171
2026-06-25 18:05:47 +08:00
zhaojiaxing-coding
af9835c288 feat(drive): add +member-add shortcut with wiki space member collection collaborator support (#1204) 2026-06-25 17:45:42 +08:00
shifengjuan-dev
2e3073a532 docs(im): document chat.nickname get/update/delete (#1378) 2026-06-25 17:04:31 +08:00
106 changed files with 11042 additions and 261 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
@@ -70,6 +71,7 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -87,6 +89,23 @@ jobs:
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -109,8 +128,28 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: make quality-gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -220,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,7 +280,7 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
@@ -333,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -345,6 +384,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,6 +401,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \

28
.github/workflows/comment-audit.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

View File

@@ -88,31 +88,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -121,6 +134,11 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
@@ -299,31 +317,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -332,6 +363,16 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
@@ -389,6 +430,10 @@ jobs:
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ app.log
cover*.out
lark-env.sh
/automations/

View File

@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -69,7 +70,8 @@ integration-test: build
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
@@ -89,6 +91,7 @@ quality-gate: build
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
flag.Parse()
if *eventPath == "" {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
report.Print(os.Stderr, diags)
os.Exit(report.ExitCode(diags))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
switch {
case payload.Comment != nil:
return payload.Comment.Body, nil
case payload.Review != nil:
return payload.Review.Body, nil
default:
return "", nil
}
}
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
t.Fatal(err)
}
_, err := commentBody(path)
problem, ok := errs.ProblemOf(err)
if err == nil || !ok {
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
}
}
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
got := auditFailureSummary(2)
want := "post-publication audit found public content findings: 2"
if got != want {
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
}
}
func writeTestFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/rules"
"github.com/larksuite/cli/internal/validate"
)
func main() {
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
if err := fs.Parse(args); err != nil {
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
return 2
}
if opts.PublicContentMetadataPath != "" {
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
if err != nil {
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
return 2
}
opts.PublicContentMetadataPath = safePath
}
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
return 2

View File

@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
}
}
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", ".tmp/quality-gate/pr.json",
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if strings.Contains(stderr, "flag provided but not defined") {
t.Fatalf("public content metadata flag was not registered: %s", stderr)
}
if !strings.Contains(stderr, "--manifest and --command-index are required") {
t.Fatalf("stderr = %s", stderr)
}
}
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
}
}
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "command-manifest.json")

View File

@@ -56,6 +56,14 @@ func run(args []string) int {
_ = semantic.WriteMarkdown(markdownOut, decision)
return 0
}
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
@@ -72,6 +80,15 @@ func run(args []string) int {
return 0
}
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
decision = finalizeDecision(block, waiverDiags, decision)
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
decision.BlockMode = block
if !block && len(decision.Blockers) > 0 {
for i := range decision.Blockers {
@@ -81,15 +98,21 @@ func run(args []string) int {
decision.Blockers = nil
}
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
return decision
}
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
return 2
return fmt.Errorf("write decision: %w", err)
}
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
return 2
return fmt.Errorf("write markdown: %w", err)
}
if block && len(decision.Blockers) > 0 {
return nil
}
func decisionExitCode(decision semantic.Decision) int {
if decision.BlockMode && len(decision.Blockers) > 0 {
return 1
}
return 0

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
}
}
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
t.Setenv("ARK_API_KEY", "")
t.Setenv("ARK_BASE_URL", "")
t.Setenv("ARK_MODEL", "")
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
"default_enforcement": "observe",
"block_categories": ["skill_quality"]
}`, `{
"allowed": ["semantic-review-v1"],
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
f := facts.Facts{
SchemaVersion: 1,
Commands: []facts.CommandFact{{
Path: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
}},
Outputs: []facts.OutputFact{{
Command: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
IsList: true,
HasDefaultLimit: true,
HasDecisionField: true,
}},
}
if semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must not contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
if code != 0 {
t.Fatalf("run() = %d, want clean pass", code)
}
decision := readDecision(t, decisionPath)
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
t.Fatalf("expected non-degraded pass decision: %#v", decision)
}
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
}
data, err := os.ReadFile(markdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
markdown := string(data)
if !strings.Contains(markdown, "No semantic blockers.") {
t.Fatalf("markdown missing pass summary: %s", markdown)
}
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
}
}
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
t.Setenv("ARK_API_KEY", "test-key")
t.Setenv("ARK_BASE_URL", "")
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")

View File

@@ -5,7 +5,8 @@
"error_hint",
"default_output",
"naming",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"rollout_groups": [
{
@@ -16,7 +17,8 @@
},
"categories": [
"error_hint",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"owner": "cli-owner",
"reason": "first semantic blocking rollout only affects changed facts"

View File

@@ -13,14 +13,15 @@ import (
)
type Facts struct {
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
PublicContent []PublicContentFact `json:"public_content,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
}
type CommandFact struct {
@@ -109,6 +110,17 @@ type OutputFact struct {
HasDecisionField bool `json:"has_decision_field,omitempty"`
}
type PublicContentFact struct {
Rule string `json:"rule"`
Action report.Action `json:"action"`
File string `json:"file"`
Line int `json:"line"`
Source string `json:"source,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Message string `json:"message,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}
type DryRunRequest struct {
Method string `json:"method"`
URL string `json:"url"`
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
}
}
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
f.PublicContent = publicContent
return f
}
type commandScope struct {
Domain string
Source string

View File

@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
}
data, err := json.Marshal(f)
if err != nil {
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal facts: %v", err)
}
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
if !got.Errors[0].RequiredHint ||
got.Outputs[0].Fields[0] != "message_id" ||
!got.Skills[0].ScopeConflict ||
got.PublicContent[0].Rule != "public_content_generic_credential" {
t.Fatalf("facts lost gatekeeper fields: %#v", got)
}
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
metadata, err := LoadMetadata(opts.MetadataPath)
if err != nil {
return nil, err
}
var out []Finding
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
if err != nil {
return nil, err
}
patches := map[string][]changedChunk{}
if base != "" {
patches, err = changedPatches(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
}
for _, file := range changedFiles {
if !scanChangedFile(file) {
continue
}
for _, chunk := range patches[file] {
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
for i := range findings {
findings[i].Line += chunk.StartLine - 1
}
out = append(out, findings...)
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
}
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
if err != nil {
return nil, err
}
out = appendUniqueFindings(out, privateKeyFindings...)
}
if base != "" {
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
out = append(out, commitFindings...)
}
branchName := opts.BranchName
if branchName == "" {
branchName = metadata.Branch
}
if branchName == "" {
branchName = branchFromEnv()
}
if branchName == "" {
branchName = currentBranch(ctx, opts.Repo)
}
if branchName != "" {
out = append(out, scanText("branch", "branch", branchName, false)...)
}
out = append(out, scanMetadata(metadata)...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func currentBranch(ctx context.Context, repo string) string {
data, err := gitOutput(ctx, repo, "branch", "--show-current")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func branchFromEnv() string {
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
if changedFrom == "" {
return nil, "", nil
}
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
if err != nil {
return nil, "", err
}
base := strings.TrimSpace(string(baseBytes))
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, "", err
}
sort.Strings(files)
return files, base, nil
}
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
var files []string
for _, file := range bytes.Split(data, []byte{0}) {
if len(file) == 0 {
continue
}
files = append(files, filepath.ToSlash(string(file)))
}
return files, nil
}
var detectorFixtureExclusions = map[string]bool{
"internal/qualitygate/publiccontent/collect_test.go": true,
"internal/qualitygate/publiccontent/rules.go": true,
"internal/qualitygate/publiccontent/scan.go": true,
"internal/qualitygate/publiccontent/scan_test.go": true,
}
func scanChangedFile(file string) bool {
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
return !detectorFixtureExclusions[normalized]
}
type changedChunk struct {
StartLine int
Text string
}
func (c changedChunk) endLine() int {
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
if lines < 1 {
lines = 1
}
return c.StartLine + lines - 1
}
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, err
}
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
out := map[string][]changedChunk{}
var file string
var chunk *changedChunk
nextLine := 0
nextFile := 0
flush := func() {
if file == "" || chunk == nil || chunk.Text == "" {
chunk = nil
return
}
out[file] = append(out[file], *chunk)
chunk = nil
}
for _, raw := range strings.Split(string(data), "\n") {
switch {
case strings.HasPrefix(raw, "diff --git "):
flush()
file = ""
if nextFile < len(files) {
file = files[nextFile]
nextFile++
}
case strings.HasPrefix(raw, "@@ "):
flush()
start, ok := parseNewHunkStart(raw)
if !ok {
nextLine = 0
continue
}
nextLine = start
chunk = &changedChunk{StartLine: start}
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
if chunk == nil {
chunk = &changedChunk{StartLine: max(nextLine, 1)}
}
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
nextLine++
case strings.HasPrefix(raw, "-"):
continue
default:
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
continue
}
flush()
}
}
flush()
return out, nil
}
func parseNewHunkStart(header string) (int, bool) {
parts := strings.Split(header, " ")
for _, part := range parts {
if !strings.HasPrefix(part, "+") {
continue
}
raw := strings.TrimPrefix(part, "+")
if before, _, ok := strings.Cut(raw, ","); ok {
raw = before
}
start, err := strconv.Atoi(raw)
return start, err == nil && start > 0
}
return 0, false
}
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
if err != nil {
return nil, err
}
parts := bytes.Split(data, []byte{0})
var out []Finding
for i := 0; i+1 < len(parts); i += 2 {
sha := strings.TrimSpace(string(parts[i]))
body := string(parts[i+1])
if sha == "" || body == "" {
continue
}
short := sha
if len(short) > 12 {
short = short[:12]
}
out = append(out, scanText("commit:"+short, "commit", body, false)...)
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
}
return out, nil
}
type lineRange struct {
Start int
End int
}
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
if len(chunks) == 0 {
return nil, nil
}
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
if err != nil {
return nil, err
}
var added []lineRange
for _, chunk := range chunks {
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
}
var out []Finding
for _, block := range privateKeyBlocks(string(data)) {
if !rangesIntersectAny(block, added) {
continue
}
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
}
return out, nil
}
func privateKeyBlocks(text string) []lineRange {
lines := strings.Split(text, "\n")
var out []lineRange
inPrivateKey := false
start := 0
for i, line := range lines {
lineNo := i + 1
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
start = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, lineRange{Start: start, End: lineNo})
inPrivateKey = false
}
}
return out
}
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
for _, r := range ranges {
if block.Start <= r.End && r.Start <= block.End {
return true
}
}
return false
}
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
for _, addition := range additions {
duplicate := false
for _, item := range items {
if item.Rule == addition.Rule &&
item.File == addition.File &&
item.Line == addition.Line &&
item.Source == addition.Source {
duplicate = true
break
}
}
if !duplicate {
items = append(items, addition)
}
}
return items
}
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repo
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
}
return stdout.Bytes(), nil
}

View File

@@ -0,0 +1,885 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
`)
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
rules := findingRules(got)
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
} {
if !rules[want] {
t.Fatalf("missing rule %s in findings %#v", want, got)
}
}
for _, item := range got {
if item.File == "baseline.md" {
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
}
}
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "add public line")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
for _, item := range got {
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
}
}
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var found bool
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
found = true
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
}
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
t.Fatalf("semantic candidate leaked credential value: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
}
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
}
}
if !found {
t.Fatalf("missing semantic candidate in findings %#v", got)
}
}
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
}
return
}
t.Fatalf("missing semantic candidate in findings %#v", got)
}
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
token := "abcdefghijklmnopqrstuvwxyz"
raw := "private launch plan for internal rollout on Friday with " +
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add json bearer")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
for _, item := range got {
if item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, token) {
t.Fatalf("finding leaked JSON bearer token: %#v", item)
}
}
}
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
}
if count != 6 {
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
}
}
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"tokenizer":"cl100k_base"}`,
`{"token_count": 42}`,
`{"page_token":"next"}`,
`{"next_page_token":"next"}`,
`{"file_token":"file-example"}`,
`{"doc_token":"doc-example"}`,
`{"node_token":"node-example"}`,
`{"wiki_token":"wikcn_public_doc_example"}`,
`{"folder_token":"folder-example"}`,
`{"obj_token":"obj-example"}`,
`{"spreadsheet_token":"sheet-example"}`,
`{"parent_node_token":"parent-example"}`,
`{"origin_node_token":"origin-example"}`,
`{"drive_route_token":"route-example"}`,
`{"token":"<wiki_token>"}`,
`{"token":"wiki_token"}`,
`{"token_url":"https://example.com/oauth/token"}`,
`{"token_endpoint":"https://example.com/oauth/token"}`,
`{"token_format":"Bearer"}`,
`{"secret_name":"public-example-secret"}`,
`{"base_token":"base-example"}`,
`{"app_token":"app-example"}`,
`{"sync_token":"sync-example"}`,
`{"parent_token":"parent-example"}`,
`{"target_token":"target-example"}`,
`{"parent_file_token":"parent-file-example"}`,
`{"refresh_token_expires_in": 7200}`,
`{"access_token_expires_in": 7200}`,
`{"token_expires_in": 7200}`,
`{"token_status":"active"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add benign json token fields")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY: " + accessKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add access key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 3 {
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"PRIVATE_KEY: " + privateKey,
"SSH_PRIVATE_KEY: " + privateKey,
"JWT_PRIVATE_KEY: " + privateKey,
"SIGNING_PRIVATE_KEY: " + privateKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add private key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, privateKey) {
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 4 {
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
}
}
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"tokens: 128",
"token_type: bearer",
"max_tokens: 2000",
"completion_tokens: 200",
"prompt_tokens: 100",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add benign token config")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
if count != 5 {
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "add body")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "docs update")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported untouched historical private key: %#v", got)
}
}
}
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "remove body")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
}
}
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var foundOrdinaryTestLeak bool
for _, item := range got {
switch item.File {
case "internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
"internal/qualitygate/publiccontent/rules.go":
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
}
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
foundOrdinaryTestLeak = true
}
}
if !foundOrdinaryTestLeak {
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
}
}
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
excluded := []string{
"internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/rules.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
}
for _, file := range excluded {
if scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
}
}
included := []string{
"internal/qualitygate/publiccontent/new_test.go",
"tests/e2e/new-public-workflow.test.sh",
"docs/public.md",
}
for _, file := range included {
if !scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = false, want true", file)
}
}
}
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
t.Fatalf("branch findings = %#v", got)
}
}
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
BranchName: "bot/public-doc-update",
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "add auth docs")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
t.Fatalf("changed-line attribution = %#v", item)
}
return
}
}
t.Fatalf("missing bearer finding: %#v", got)
}
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
got := appendUniqueFindings(base,
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
)
if len(got) != 2 {
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
}
}
func newGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
return repo
}
func privateKeyBegin() string {
return privateKeyBeginPrefix + privateKeyMarker + "\n"
}
func privateKeyEnd() string {
return privateKeyEndPrefix + privateKeyMarker + "\n"
}
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
t.Helper()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
return got
}
func requireFinding(t *testing.T, got []Finding, file, rule string) {
t.Helper()
for _, item := range got {
if item.File == file && item.Rule == rule {
return
}
}
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
t.Fatalf("Collect() error = %v, want metadata parse error", err)
}
}
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
return out
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
func ScanComment(kind, body string) []Finding {
if kind == "" {
kind = "comment"
}
return scanText(kind, "comment", body, false)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
rules := findingRules(got)
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
t.Fatalf("comment audit findings = %#v", got)
}
for _, item := range got {
if item.File != "issue_comment" {
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
func LoadMetadata(path string) (Metadata, error) {
if path == "" {
return Metadata{}, nil
}
data, err := vfs.ReadFile(path)
if err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
if len(data) == 0 {
return Metadata{}, nil
}
var out Metadata
if err := json.Unmarshal(data, &out); err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
return out, nil
}
func scanMetadata(m Metadata) []Finding {
text := ""
if m.Title != "" {
text += "title: " + m.Title + "\n"
}
if m.Body != "" {
text += "body:\n" + m.Body + "\n"
}
if text == "" {
return nil
}
out := scanText("pull_request_metadata", "metadata", text, false)
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
return out
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"path/filepath"
"testing"
)
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
got, err := LoadMetadata(path)
if err != nil {
t.Fatalf("LoadMetadata() error = %v", err)
}
if got.Title != "public change" || got.Body == "" {
t.Fatalf("metadata = %#v", got)
}
}

View File

@@ -0,0 +1,441 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/internal/qualitygate/report"
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
)
func actionForRule(rule string) report.Action {
switch rule {
case "public_content_generic_credential",
"public_content_private_key_block",
"public_content_jwt_like_token",
"public_content_bearer_header",
"public_content_credential_url",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
"public_content_provenance_marker",
"public_content_detector_fingerprint",
"public_content_harness_metadata",
"public_content_ccm_harness_trailer":
return report.ActionReject
case "public_content_private_ipv4",
"public_content_automation_branch":
return report.ActionWarning
default:
return report.ActionWarning
}
}
func isPlaceholderValue(value string) bool {
trimmed := strings.Trim(value, `"'`)
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
isCredentialReferenceValue(trimmed) {
return true
}
return namedPlaceholderValue(normalized)
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
}
func allXPlaceholder(value string) bool {
if len(value) < 4 {
return false
}
for _, r := range value {
if r != 'x' {
return false
}
}
return true
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||
!strings.Contains(value, ">") {
return false
}
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
}
func removeAnglePlaceholders(value string) string {
var out strings.Builder
for len(value) > 0 {
start := strings.Index(value, "<")
if start < 0 {
out.WriteString(value)
break
}
out.WriteString(value[:start])
end := strings.Index(value[start+1:], ">")
if end < 0 {
out.WriteString(value[start:])
break
}
value = value[start+end+2:]
}
return out.String()
}
func urlRemainderLooksCredentialLike(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{
"secret",
"token",
"password",
"passwd",
"api_key",
"apikey",
"private_key",
"privatekey",
"client_secret",
"clientsecret",
} {
if strings.Contains(normalized, marker) {
return true
}
}
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func longCredentialSegment(value string) bool {
if len(value) < 16 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-':
default:
return false
}
}
return hasLetter || hasDigit
}
func isCredentialReferenceValue(value string) bool {
normalized := strings.ToLower(value)
switch {
case strings.HasPrefix(normalized, "${{"):
return githubExpressionReference(normalized)
case strings.HasPrefix(normalized, "$("):
return !commandSubstitutionLooksCredentialLike(normalized)
case strings.HasPrefix(normalized, "process.env."):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
case strings.HasPrefix(normalized, "${"):
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
case strings.HasPrefix(value, "$"):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
default:
return false
}
}
func commandSubstitutionLooksCredentialLike(value string) bool {
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
return false
}
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func githubExpressionReference(value string) bool {
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
return false
}
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
switch {
case strings.HasPrefix(expr, "secrets."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
case strings.HasPrefix(expr, "env."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
case strings.HasPrefix(expr, "vars."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
case expr == "github.token":
return true
default:
return false
}
}
func dottedReferenceIdentifier(value string) bool {
if value == "" {
return false
}
for _, part := range strings.Split(value, ".") {
if !referenceIdentifier(part) {
return false
}
}
return true
}
func credentialReferenceIdentifier(value string) bool {
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
}
func referenceIdentifier(value string) bool {
if value == "" {
return false
}
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9' && i > 0:
case r == '_' && i > 0:
default:
return false
}
}
return true
}
func angleWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
return false
}
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
}
func percentWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
return false
}
inner := strings.Trim(value, "%")
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
}
func delimitedPlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func anglePlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
if credentialShapedIdentifier(value) {
return false
}
switch value {
case "token",
"id",
"userid",
"openid",
"key",
"secret",
"password",
"api-key",
"user-id",
"open-id",
"client-secret",
"access-token",
"refresh-token",
"auth-token",
"bearer-token",
"session-token",
"service-token":
return true
}
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
func credentialShapedValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
switch {
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
return true
default:
return false
}
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {
case "wiki_token",
"folder_token",
"obj_token",
"spreadsheet_token",
"file_token",
"doc_token",
"node_token",
"parent_node_token",
"origin_node_token",
"drive_route_token":
return true
default:
return minuteTokenFixturePlaceholder(normalized)
}
}
func minuteTokenFixturePlaceholder(value string) bool {
if value == "minute_no_meta" {
return true
}
suffix, ok := strings.CutPrefix(value, "minute_")
if !ok || suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
func provenanceMarker(line string) bool {
normalized := strings.ToLower(line)
markers := []string{
"generat" + "ed by tool",
"creat" + "ed by tool",
"generat" + "ed by automation",
"creat" + "ed by automation",
"machine-" + "generated",
"generated with automated",
"generated with automation",
"🤖 generated",
}
for _, marker := range markers {
if strings.Contains(normalized, marker) {
return true
}
}
if strings.HasPrefix(normalized, "co-authored-by:") &&
(strings.Contains(normalized, "<bot@") ||
strings.Contains(normalized, " bot@") ||
strings.Contains(normalized, "[bot]") ||
strings.Contains(normalized, "automation") ||
strings.Contains(normalized, "automated-code-assistant")) {
return true
}
return false
}
// Detector fingerprint checks are intentionally scoped to public rule/config
// files. They do not try to hide this package's implementation; they prevent
// publishing reusable detector identifiers in external-facing rule bundles.
func isDetectorRuleFile(path string) bool {
normalized := filepath.ToSlash(path)
base := filepath.Base(normalized)
return base == ".gitleaks.toml" ||
strings.Contains(normalized, "public-rules/") ||
strings.Contains(normalized, "public_rules/")
}
func detectorFingerprint(line string) bool {
normalized := strings.ToLower(line)
fingerprints := []string{
strings.Join([]string{"public", "content", "leakage"}, "-"),
strings.Join([]string{"public", "content", "detector"}, "-"),
"publiccontent",
}
for _, fingerprint := range fingerprints {
if strings.Contains(normalized, fingerprint) {
return true
}
}
return false
}
func redactCredentialURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return "<credential-url>"
}
u.User = url.UserPassword("<user>", "<redacted>")
return u.String()
}

View File

@@ -0,0 +1,797 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"fmt"
"path/filepath"
"sort"
"strings"
"unicode"
)
const (
privateKeyBeginPrefix = "-----" + "BEGIN "
privateKeyEndPrefix = "-----" + "END "
privateKeyMarker = "PRIVATE " + "KEY-----"
)
func ScanFile(path string, data []byte) []Finding {
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
}
func semanticCandidate(file, source, text string, line int) []Finding {
excerpt := redactedSemanticExcerpt(text)
if excerpt == "" {
return nil
}
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
}
func scanText(file, source, text string, detectorFile bool) []Finding {
var out []Finding
lines := strings.Split(text, "\n")
inPrivateKey := false
privateKeyLine := 0
for i, line := range lines {
lineNo := i + 1
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
privateKeyLine = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isPlaceholderValue(value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if isSchemaDottedIdentifier(line, match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for range bearerHeaderRE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
}
switch {
case changeIDTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
case reviewedOnTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
case ccmHarnessTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
}
if provenanceMarker(line) {
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
}
if strings.Contains(line, "/tmp/harness-agent") {
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
}
if detectorFile && detectorFingerprint(line) {
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out
}
func isCredentialAssignmentMatch(match string) bool {
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
key, _, ok := normalizedCredentialAssignment(match)
return key, ok
}
func normalizedCredentialAssignment(match string) (string, string, bool) {
key, ok := credentialAssignmentKey(match)
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
if key == "" {
return "", "", false
}
submatches := credentialAssignmentRE.FindStringSubmatch(match)
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
}
func normalizedCredentialKey(key string) string {
key = strings.TrimSpace(key)
var out []rune
var prev rune
for i, r := range key {
if r == '-' {
r = '_'
}
if i > 0 && isCredentialKeyBoundary(prev, r) {
out = append(out, '_')
}
out = append(out, unicode.ToLower(r))
prev = r
}
key = string(out)
key = strings.ReplaceAll(key, "-", "_")
return key
}
func isCredentialKeyBoundary(prev, current rune) bool {
if prev == '_' || current == '_' {
return false
}
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
}
func isBenignTokenField(key string) bool {
if isTokenMetricField(key) ||
isTokenMetadataField(key) ||
isResourceTokenField(key) ||
isPaginationOrSyncTokenField(key) {
return true
}
return false
}
func isTokenMetricField(key string) bool {
switch key {
case "tokenizer",
"token_count",
"tokens",
"max_tokens",
"completion_tokens",
"prompt_tokens":
return true
default:
return false
}
}
func isTokenMetadataField(key string) bool {
switch key {
case "access_token_expires_in",
"refresh_token_expires_in",
"token_expires_in",
"token_status",
"token_type",
"token_url",
"token_endpoint",
"token_format",
"secret_name":
return true
default:
return false
}
}
func isPaginationOrSyncTokenField(key string) bool {
switch key {
case "page_token",
"next_page_token",
"sync_token":
return true
default:
return false
}
}
func isResourceTokenField(key string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
prefix := strings.TrimSuffix(key, "_token")
switch prefix {
case "app",
"base",
"board",
"doc",
"drive_route",
"file",
"folder",
"host_node",
"minute",
"node",
"obj",
"origin_node",
"parent",
"parent_file",
"parent_node",
"share",
"spreadsheet",
"target",
"wiki":
return true
default:
return false
}
}
func isResourceTokenPlaceholderAssignment(key, value string) bool {
switch {
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
return true
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
default:
return false
}
}
func tokenLikePlaceholderKey(key string) bool {
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
return resourceTokenPlaceholderValue(value) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
strings.Contains(normalized, "xxx") ||
strings.Contains(normalized, "_or_") ||
strings.HasSuffix(normalized, "_token") ||
strings.HasPrefix(normalized, ".")
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
func uuidStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
parts := strings.Split(normalized, "-")
if len(parts) != 5 {
return false
}
for i, part := range parts {
want := []int{8, 4, 4, 4, 12}[i]
if len(part) != want {
return false
}
for _, r := range part {
if (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'f') ||
(r >= 'A' && r <= 'F') {
continue
}
return false
}
}
return true
}
func numericStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
if normalized == "" {
return false
}
for _, r := range normalized {
if r < '0' || r > '9' {
return false
}
}
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
return false
}
return codeReferenceExpression(normalized)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".py":
return true
default:
return false
}
}
func quotedLiteral(value string) bool {
normalized := strings.TrimSpace(value)
return len(normalized) >= 2 &&
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func codeReferenceExpression(value string) bool {
if value == "" {
return false
}
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
if strings.Contains(value, marker) {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
}
func codeIdentifier(value string) bool {
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r == '_' && i > 0:
case r >= '0' && r <= '9' && i > 0:
default:
return false
}
}
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isSchemaDottedIdentifier(line, match string) bool {
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
}
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
func webhookAssignmentValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
return false
}
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
}
func isExplicitCredentialKey(key string) bool {
compact := strings.ReplaceAll(key, "_", "")
switch compact {
case "token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"secret",
"secretkey",
"clientsecret",
"password",
"passwd":
return true
}
for _, phrase := range []string{
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secretkey",
} {
if strings.Contains(compact, phrase) {
return true
}
}
parts := credentialKeyParts(key)
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "key"},
{"access", "key"},
{"private", "key"},
{"api", "secret"},
{"client", "secret"},
{"secret", "key"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
for _, part := range parts {
switch part {
case "token", "secret", "password", "passwd":
return true
}
}
for _, suffix := range []string{
"token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secret",
"secretkey",
"password",
"passwd",
} {
if strings.HasSuffix(compact, suffix) {
return true
}
}
for _, suffix := range []string{
"_access_token",
"_refresh_token",
"_auth_token",
"_bearer_token",
"_session_token",
"_service_token",
"_api_key",
"_access_key",
"_private_key",
"_api_secret",
"_client_secret",
"_secret",
"_secret_key",
"_password",
"_passwd",
} {
if strings.HasSuffix(key, suffix) {
return true
}
}
return false
}
func credentialKeyParts(key string) []string {
var parts []string
for _, part := range strings.Split(key, "_") {
if part != "" {
parts = append(parts, part)
}
}
return parts
}
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
for i := 0; i+1 < len(parts); i++ {
if parts[i] == first && parts[i+1] == second {
return true
}
}
return false
}
func credentialAssignmentValue(match []string) string {
for _, value := range match[1:] {
if value != "" {
return value
}
}
return ""
}
func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
}
_, password, ok := strings.Cut(userInfo, ":")
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
normalized := strings.ToLower(password)
decoded := strings.ReplaceAll(normalized, "%3c", "<")
decoded = strings.ReplaceAll(decoded, "%3e", ">")
switch decoded {
case "placeholder", "redacted", "<redacted>", "xxxx":
return true
}
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {
return "", false
}
rest := raw[schemeIdx+len("://"):]
atIdx := strings.Index(rest, "@")
if atIdx < 0 {
return "", false
}
return rest[:atIdx], true
}
func newFinding(rule, file string, line int, source, excerpt string) Finding {
return Finding{
Rule: rule,
Action: actionForRule(rule),
File: file,
Line: line,
Source: source,
Excerpt: excerpt,
Message: messageForRule(rule),
Suggestion: suggestionForRule(rule),
}
}
func messageForRule(rule string) string {
switch rule {
case "public_content_generic_credential":
return "public contribution contains a generic credential assignment"
case "public_content_private_key_block":
return "public contribution contains a private key block"
case "public_content_jwt_like_token":
return "public contribution contains a JWT-like token"
case "public_content_bearer_header":
return "public contribution contains an Authorization bearer token"
case "public_content_credential_url":
return "public contribution contains credentials embedded in a URL"
case "public_content_private_ipv4":
return "public contribution contains a private-network IP address"
case "public_content_automation_branch":
return "public contribution uses an automation-shaped branch name"
case "public_content_change_id_trailer":
return "public contribution contains a Change-Id trailer"
case "public_content_reviewed_on_trailer":
return "public contribution contains a Reviewed-on trailer"
case "public_content_provenance_marker":
return "public contribution contains a prohibited provenance marker"
case "public_content_detector_fingerprint":
return "public rule/config content exposes public detector fingerprints"
case "public_content_harness_metadata":
return "public contribution contains visible harness pipeline metadata"
case "public_content_ccm_harness_trailer":
return "public contribution contains a CCM-Harness trailer"
case "public_content_semantic_candidate":
return "public contribution contains text for semantic public content review"
default:
return "public contribution contains content that should not be published"
}
}
func suggestionForRule(rule string) string {
switch actionForRule(rule) {
case "REJECT":
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
default:
return "remove private workflow metadata before publishing the public contribution"
}
}
func redactAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
}
func credentialAssignmentKey(match string) (string, bool) {
idx := -1
for _, sep := range []string{":", "="} {
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
idx = candidate
}
}
if idx < 0 {
return "", false
}
return match[:idx], true
}
func redactToken(_ string) string {
return "<jwt-like-token>"
}
func redactedSemanticExcerpt(text string) string {
normalized := strings.Join(strings.Fields(text), " ")
if normalized == "" {
return ""
}
signals := semanticSignals(normalized)
if len(signals) == 0 {
return ""
}
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
}
func semanticSignals(normalized string) []string {
lower := strings.ToLower(normalized)
var signals []string
add := func(signal string) {
for _, existing := range signals {
if existing == signal {
return
}
}
signals = append(signals, signal)
}
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
add("private_scope")
add("request_metadata")
add("trust_boundary_detail")
}
if hasRoadmap && (hasPrivateScope || hasTiming) {
add("roadmap_detail")
if hasPrivateScope {
add("private_scope")
}
if hasTiming {
add("roadmap_timing")
}
}
if hasPrivateScope && hasImplementation && hasTrustBoundary {
add("private_scope")
add("implementation_detail")
add("trust_boundary_detail")
}
return signals
}
func sanitizeSemanticExcerpt(text string) string {
text = redactPrivateKeyBlocks(text)
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}
func redactPrivateKeyBlocks(text string) string {
lines := strings.Split(text, "\n")
var out []string
inPrivateKey := false
for _, line := range lines {
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, "<private-key-block>")
inPrivateKey = true
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
if inPrivateKey {
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func sanitizeCredentialAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return strings.TrimSpace(key) + "=<redacted>"
}
func sanitizeCredentialURL(raw string) string {
redacted := redactCredentialURL(raw)
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
return redacted
}
func truncateRunes(text string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "..."
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "github.com/larksuite/cli/internal/qualitygate/report"
type Options struct {
Repo string
ChangedFrom string
MetadataPath string
BranchName string
}
type Metadata struct {
Title string `json:"title"`
Body string `json:"body"`
Branch string `json:"branch"`
}
type Finding struct {
Rule string
Action report.Action
File string
Line int
Source string
Excerpt string
Message string
Suggestion string
}

View File

@@ -174,8 +174,9 @@ type materializedExample struct {
}
type placeholderContext struct {
FlagName string
FlagUsage string
FlagName string
FlagUsage string
FlagDefault string
}
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
ctx := placeholderContext{FlagName: name}
if flag != nil {
ctx.FlagUsage = flag.Usage
ctx.FlagDefault = flag.DefValue
}
return ctx
}
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
if name == "" {
return "", false
}
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeValueFromPlaceholderName(name); ok {
return value, true
}
if isGenericPlaceholderName(name) {
return fakeValueFromUsageHint(ctx.FlagUsage)
return fakeValueFromContextHint(ctx)
}
return "", false
}
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
return "file_test123", true
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
return "file_test123", true
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
return "fld_test123", true
case hasPlaceholderToken(tokens, "image", "img"):
return "img_test123", true
case hasPlaceholderToken(tokens, "app"):
return "app_test123", true
case hasPlaceholderToken(tokens, "draft"):
return "draft_test123", true
case hasPlaceholderToken(tokens, "label"):
return "label_test123", true
case hasPlaceholderToken(tokens, "share"):
return "share_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, "space"):
return "space_test123", true
case hasPlaceholderToken(tokens, "table"):
return "tbl_test123", true
case hasPlaceholderToken(tokens, "view"):
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
}
}
func fakeValueFromUsageHint(usage string) (string, bool) {
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
return value, true
}
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
if len(match) != 2 || !knownTokenPrefix(match[1]) {
return "", false
}
return match[1] + "_test123", true
}
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
return "", false
}
usage := strings.ToLower(ctx.FlagUsage)
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
return "https://example.feishu.cn/docx/doc_test123", true
}
return "", false
}
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
usage := strings.ToLower(ctx.FlagUsage)
switch {
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
return "123456789", true
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
return "123456789", true
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
default:
return "", false
}
}
func numericPlaceholderName(tokens map[string]bool) bool {
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
return false
}
return hasPlaceholderToken(tokens,
"amount", "count", "depth", "height", "index", "length", "limit", "max",
"number", "revision", "size", "width",
)
}
func numericUsageHint(usage string) bool {
if usage == "" {
return false
}
return strings.Contains(usage, "positive integer") ||
strings.Contains(usage, "decimal integer") ||
strings.Contains(usage, "number of ") ||
strings.Contains(usage, "(number)")
}
func defaultPositiveInteger(raw, fallback string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
return fallback
}
for _, r := range raw {
if r < '0' || r > '9' {
return fallback
}
}
return raw
}
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":
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
return true
default:
return false
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
return false
}
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
return tokens[first] && tokens[second]
}
func hasUnresolvedDryRunPlaceholder(value string) bool {
if skillscan.HasPlaceholder(value) {
return true
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
return append(argv[1:], "--dry-run"), nil
}
func forceDryRunJSONFormat(argv []string) []string {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--format" {
if i+1 < len(argv) && argv[i+1] == "pretty" {
argv[i+1] = "json"
}
return argv
}
if arg == "--format=pretty" {
argv[i] = "--format=json"
return argv
}
}
return argv
}
func truncateShellTail(argv []string) []string {
for i, arg := range argv {
if i == 0 {

View File

@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "vc +meeting-events",
Runnable: true,
Flags: []manifest.Flag{
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
SourceFile: "skills/lark-vc-agent/SKILL.md",
Line: 120,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "api GET",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
SourceFile: "skills/lark-demo/SKILL.md",
Line: 20,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "drive +inspect",
Runnable: true,
Flags: []manifest.Flag{
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli drive +inspect --url '<url>' --format json",
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
Line: 15,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "wiki +node-list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
{Name: "page-token", TakesValue: true, Usage: "page token"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
Line: 24,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "mail user_mailbox.drafts send",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "data", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
Line: 172,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
}
}
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",
"lark-cli export +events --format=ndjson --dry-run",
"lark-cli docs +fetch --format table",
} {
got, err := appendDryRunArg(raw)
if err != nil {
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
}
for _, arg := range got {
if arg == "--format=json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
}
}
for i, arg := range got {
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
}
}
}
}
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
if err != nil {

View File

@@ -15,18 +15,20 @@ import (
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/skillscan"
"github.com/larksuite/cli/internal/vfs"
)
type Options struct {
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
PublicContentMetadataPath string
}
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
if opts.ChangedFrom != "" {
diags = append(diags, errorDiags...)
}
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
Repo: opts.Repo,
ChangedFrom: opts.ChangedFrom,
MetadataPath: opts.PublicContentMetadataPath,
})
if err != nil {
return nil, facts.Facts{}, err
}
diags = append(diags, publicContentDiagnostics(publicContent)...)
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
}
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
if len(items) == 0 {
return nil
}
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
if item.Rule == "public_content_semantic_candidate" {
continue
}
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
if len(items) == 0 {
return nil
}
out := make([]facts.PublicContentFact, 0, len(items))
for _, item := range items {
out = append(out, facts.PublicContentFact{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Source: item.Source,
Excerpt: item.Excerpt,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
}
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
if strings.HasPrefix(diag.Rule, "public_content_") {
return true
}
file := normalizeDiagnosticFile(repo, diag.File)
if file != "" && changedFiles[file] {
return true

View File

@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
}
}
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
publicDoc := "api_" + "key = \"example-public-key\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc")
metadataPath := filepath.Join(repo, "pr-metadata.json")
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
Path: "docs +fetch",
CanonicalPath: "docs +fetch",
Domain: "docs",
Source: manifest.SourceShortcut,
}}}
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
t.Fatal(err)
}
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
idx.Commands = append(idx.Commands, manifest.Command{
Path: "drive files get",
CanonicalPath: "drive files get",
Domain: "drive",
Source: manifest.SourceService,
Generated: true,
Runnable: true,
})
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
t.Fatal(err)
}
diags, gotFacts, err := Run(context.Background(), Options{
Repo: repo,
CLIBin: "./lark-cli",
ChangedFrom: "HEAD~1",
ManifestPath: manifestPath,
CommandIndexPath: indexPath,
PublicContentMetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
actions := map[string]report.Action{}
for _, diag := range diags {
actions[diag.Rule] = diag.Action
}
if actions["public_content_generic_credential"] != report.ActionReject {
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
}
if actions["public_content_change_id_trailer"] != report.ActionReject {
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
}
if actions["public_content_semantic_candidate"] != "" {
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
}
factRules := map[string]bool{}
for _, item := range gotFacts.PublicContent {
factRules[item.Rule] = true
}
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_semantic_candidate",
} {
if !factRules[want] {
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
}
}
if len(gotFacts.PublicContent) < 3 {
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
}
}
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
"properties": map[string]any{
"category": map[string]any{
"type": "string",
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
"enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
},
"severity": map[string]any{
"type": "string",

View File

@@ -10,9 +10,10 @@ import (
"strings"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/report"
)
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
func Decide(f facts.Facts, r Review, p Policy) Decision {
return DecideWithWaivers(f, r, p, Waivers{})
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
}, ":")
case "public_content":
item := f.PublicContent[idx]
return strings.Join([]string{
"public_content",
"rule:" + item.Rule,
"action:" + string(item.Action),
"file:" + item.File,
"line:" + strconv.Itoa(item.Line),
"source:" + item.Source,
}, ":")
default:
return "ref:" + ev
}
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
func allowedCategory(category string) bool {
switch category {
case "error_hint", "default_output", "naming", "skill_quality":
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
return true
default:
return false
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
}
skill := f.Skills[idx]
return skill.ReferencesInvalidCommand
case "public_content_leakage":
if kind != "public_content" {
return false
}
item := f.PublicContent[idx]
return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
default:
return false
}
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
return idx < len(f.Errors)
case "outputs":
return idx < len(f.Outputs)
case "public_content":
return idx < len(f.PublicContent)
default:
return false
}

View File

@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
}
for _, tc := range []struct {
category string
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
{"default_output", "facts.outputs[0]"},
{"naming", "facts.commands[0]"},
{"skill_quality", "facts.skills[0]"},
{"public_content_leakage", "facts.public_content[0]"},
} {
t.Run(tc.category, func(t *testing.T) {
r := Review{Findings: []Finding{{
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
}
}
func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_" + "pri" + "vate_ipv4",
Action: "WARNING",
File: "docs/network.md",
Line: 1,
Source: "file",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "minor",
Evidence: []string{"facts.public_content[0]"},
Message: "pri" + "vate network address appears in public docs",
SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
t.Fatalf("public content warning should not become a blocker: %#v", got)
}
if got.Warnings[0].ReviewAction != ReviewActionObserve {
t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
}
}
func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: "WARNING",
File: "docs/public.md",
Line: 1,
Source: "file",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "major",
Evidence: []string{"facts.public_content[0]"},
Message: "semantic review found pri" + "vate rollout detail",
SuggestedAction: "remove pri" + "vate rollout detail from public docs",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 1 {
t.Fatalf("semantic candidate should remain blockable, got %#v", got)
}
}
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,

View File

@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
"Use only the provided JSON view.",
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
"Use fact_ref values exactly when writing finding evidence.",
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
"Review public content leakage findings and semantic candidates without private dictionaries.",
"Do not reveal internal rule lists when explaining public content leakage.",
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Report each distinct issue as a separate finding.",
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",

View File

@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
"Review public content leakage findings and semantic candidates without private dictionaries.",
"Do not reveal internal rule lists when explaining public content leakage.",
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report each distinct issue as a separate finding.",

View File

@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
return Policy{
SchemaVersion: 1,
DefaultEnforcement: "observe",
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
RolloutGroups: []RolloutGroup{{
ID: "all",
Enforcement: "blocking",
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
Owner: "test",
Reason: "default in-memory policy",
}},

View File

@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
Source: item.Source,
CommandPath: item.Command,
}, true
case "public_content":
item := f.PublicContent[idx]
return FactScope{
FactKind: "public_content",
Changed: true,
Source: item.Source,
SourceFile: item.File,
Line: item.Line,
}, true
default:
return FactScope{}, false
}
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
func allowedFactKind(kind string) bool {
switch kind {
case "skill", "command", "error", "output":
case "skill", "command", "error", "output", "public_content":
return true
default:
return false

View File

@@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) {
}
}
func TestGatekeeperUsesPublicContentEvidence(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_generic_credential",
Action: "REJECT",
File: "docs/public.md",
Line: 12,
Source: "metadata",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "critical",
Evidence: []string{"facts.public_content[0]"},
Message: "public content finding needs review",
SuggestedAction: "remove the sensitive public content",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" {
t.Fatalf("expected public content blocker, got %#v", got)
}
}
func TestGatekeeperAppliesSharedWaiverID(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,

View File

@@ -13,27 +13,29 @@ import (
)
type InputView struct {
SchemaVersion int `json:"schema_version"`
ChangedSummary ChangedSummary `json:"changed_summary"`
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
Commands []CommandInput `json:"commands,omitempty"`
Skills []SkillInput `json:"skills,omitempty"`
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
Errors []ErrorInput `json:"errors,omitempty"`
Outputs []OutputInput `json:"outputs,omitempty"`
Examples []ExampleInput `json:"examples,omitempty"`
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
ChangedSummary ChangedSummary `json:"changed_summary"`
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
Commands []CommandInput `json:"commands,omitempty"`
Skills []SkillInput `json:"skills,omitempty"`
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
Errors []ErrorInput `json:"errors,omitempty"`
Outputs []OutputInput `json:"outputs,omitempty"`
Examples []ExampleInput `json:"examples,omitempty"`
PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"`
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
}
type ChangedSummary struct {
Commands int `json:"commands,omitempty"`
Skills int `json:"skills,omitempty"`
SkillQuality int `json:"skill_quality,omitempty"`
Errors int `json:"errors,omitempty"`
Outputs int `json:"outputs,omitempty"`
Examples int `json:"examples,omitempty"`
Domains []string `json:"domains,omitempty"`
Sources []string `json:"sources,omitempty"`
Commands int `json:"commands,omitempty"`
Skills int `json:"skills,omitempty"`
SkillQuality int `json:"skill_quality,omitempty"`
Errors int `json:"errors,omitempty"`
Outputs int `json:"outputs,omitempty"`
Examples int `json:"examples,omitempty"`
PublicContent int `json:"public_content,omitempty"`
Domains []string `json:"domains,omitempty"`
Sources []string `json:"sources,omitempty"`
}
type RuleSummaryItem struct {
@@ -86,6 +88,22 @@ type ExampleInput struct {
facts.CommandExample
}
type PublicContentInput struct {
FactRef string `json:"fact_ref"`
facts.PublicContentFact
}
func (v InputView) HasReviewableFacts() bool {
return len(v.Commands) > 0 ||
len(v.Skills) > 0 ||
len(v.SkillQuality) > 0 ||
len(v.Errors) > 0 ||
len(v.Outputs) > 0 ||
len(v.Examples) > 0 ||
len(v.PublicContentLeakage) > 0 ||
len(v.Diagnostics) > 0
}
func BuildInputView(f facts.Facts) InputView {
selected := newInputSelection(f)
selected.addChangedReviewCandidates()
@@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView {
}
return InputView{
SchemaVersion: f.SchemaVersion,
ChangedSummary: changedSummary(f),
RuleSummary: ruleSummary(f.Diagnostics),
Commands: selected.commandInputs(),
Skills: selected.skillInputs(),
SkillQuality: selected.skillQualityInputs(),
Errors: selected.errorInputs(),
Outputs: selected.outputInputs(),
Examples: selected.exampleInputs(),
Diagnostics: viewDiagnostics,
SchemaVersion: f.SchemaVersion,
ChangedSummary: changedSummary(f),
RuleSummary: ruleSummary(f.Diagnostics),
Commands: selected.commandInputs(),
Skills: selected.skillInputs(),
SkillQuality: selected.skillQualityInputs(),
Errors: selected.errorInputs(),
Outputs: selected.outputInputs(),
Examples: selected.exampleInputs(),
PublicContentLeakage: selected.publicContentInputs(),
Diagnostics: viewDiagnostics,
}
}
@@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() {
s.outputs[i] = true
}
}
for i, item := range s.f.PublicContent {
if publicContentReviewCandidate(item) {
s.publicContent[i] = true
}
}
}
func commandReviewCandidate(cmd facts.CommandFact) bool {
@@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool {
return false
}
func publicContentReviewCandidate(item facts.PublicContentFact) bool {
return item.Rule == "public_content_semantic_candidate"
}
type inputSelection struct {
f facts.Facts
commands []bool
skills []bool
skillQuality []bool
errors []bool
outputs []bool
examples []bool
f facts.Facts
commands []bool
skills []bool
skillQuality []bool
errors []bool
outputs []bool
examples []bool
publicContent []bool
}
func newInputSelection(f facts.Facts) *inputSelection {
return &inputSelection{
f: f,
commands: make([]bool, len(f.Commands)),
skills: make([]bool, len(f.Skills)),
skillQuality: make([]bool, len(f.SkillQuality)),
errors: make([]bool, len(f.Errors)),
outputs: make([]bool, len(f.Outputs)),
examples: make([]bool, len(f.Examples)),
f: f,
commands: make([]bool, len(f.Commands)),
skills: make([]bool, len(f.Skills)),
skillQuality: make([]bool, len(f.SkillQuality)),
errors: make([]bool, len(f.Errors)),
outputs: make([]bool, len(f.Outputs)),
examples: make([]bool, len(f.Examples)),
publicContent: make([]bool, len(f.PublicContent)),
}
}
@@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
s.addDiagnosticExamples(out, diag)
case diag.Rule == "no_bare_helper_error":
s.addDiagnosticErrors(out, diag)
case strings.HasPrefix(diag.Rule, "public_content_"):
s.addDiagnosticPublicContent(out, diag)
}
return out
}
@@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D
}
}
func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) {
for i, item := range s.f.PublicContent {
if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) ||
diag.Rule == item.Rule {
out.publicContent[i] = true
}
}
}
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
if diag.Action == report.ActionReject {
return true
@@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) {
mergeSelections(s.errors, other.errors)
mergeSelections(s.outputs, other.outputs)
mergeSelections(s.examples, other.examples)
mergeSelections(s.publicContent, other.publicContent)
}
func (s *inputSelection) intersects(other *inputSelection) bool {
@@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool {
selectionsIntersect(s.skillQuality, other.skillQuality) ||
selectionsIntersect(s.errors, other.errors) ||
selectionsIntersect(s.outputs, other.outputs) ||
selectionsIntersect(s.examples, other.examples)
selectionsIntersect(s.examples, other.examples) ||
selectionsIntersect(s.publicContent, other.publicContent)
}
func (s *inputSelection) commandInputs() []CommandInput {
@@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput {
return out
}
func (s *inputSelection) publicContentInputs() []PublicContentInput {
out := make([]PublicContentInput, 0, countSelected(s.publicContent))
for i, ok := range s.publicContent {
if ok {
out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]})
}
}
return out
}
func changedSummary(f facts.Facts) ChangedSummary {
domains := map[string]bool{}
sources := map[string]bool{}
@@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary {
addNonEmpty(domains, example.Domain)
addNonEmpty(sources, example.Source)
}
for _, item := range f.PublicContent {
out.PublicContent++
addNonEmpty(sources, item.Source)
}
out.Domains = sortedViewSetKeys(domains)
out.Sources = sortedViewSetKeys(sources)
return out
@@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool {
strings.HasPrefix(rule, "default_output") ||
strings.HasPrefix(rule, "skill_") ||
strings.HasPrefix(rule, "example_dry_run") ||
rule == "no_bare_helper_error"
rule == "no_bare_helper_error" ||
strings.HasPrefix(rule, "public_content_")
}
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {

View File

@@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) {
}
}
func TestInputViewIncludesPublicContentLeakage(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_generic_credential",
Action: report.ActionReject,
File: "docs/public.md",
Line: 4,
Excerpt: "api_key = <redacted>",
Message: "generic credential assignment",
}},
Diagnostics: []facts.DiagnosticFact{{
Rule: "public_content_generic_credential",
Action: report.ActionReject,
File: "docs/public.md",
Line: 4,
Message: "generic credential assignment",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 1 {
t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage))
}
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
t.Fatalf("public content fact ref = %q", got)
}
if len(view.Diagnostics) != 1 {
t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics))
}
}
func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: report.ActionWarning,
File: "docs/public.md",
Line: 1,
Source: "file",
Excerpt: "public prose that needs semantic review",
Message: "public contribution contains text for semantic public content review",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 1 {
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
}
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
t.Fatalf("semantic candidate fact ref = %q", got)
}
if len(view.Diagnostics) != 0 {
t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics)
}
}
func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) {
scopeText := "pri" + "vate rollout"
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: report.ActionWarning,
File: "docs/public.md",
Line: 1,
Source: "file",
Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token=<redacted>"`,
Message: "public contribution contains text for semantic public content review",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 1 {
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
}
if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=<redacted>") {
t.Fatalf("semantic candidate excerpt missing from view: %q", got)
}
messages := BuildPrompt(f)
if len(messages) != 2 {
t.Fatalf("messages len = %d, want 2", len(messages))
}
if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") {
t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content)
}
if strings.Contains(messages[1].Content, "real-"+"secret-value") {
t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content)
}
}
func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_" + "pri" + "vate_ipv4",
Action: report.ActionWarning,
File: "docs/network.md",
Line: 1,
Source: "file",
Excerpt: "192.168." + "0.10",
Message: "public contribution contains a pri" + "vate-network IP address",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 0 {
t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage)
}
if len(view.Diagnostics) != 0 {
t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics)
}
}
func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) {
f := broadChangedFacts(434, 44)

View File

@@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) {
if item.SourceFile == "" || item.Line == 0 {
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind)
}
case "public_content":
if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" {
return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo)
}
case "command", "output":
if item.CommandPath == "" {
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind)

View File

@@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) {
writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n")
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
"public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n")
w, diags, err = LoadWaivers(repo, now)
if err != nil {
t.Fatalf("LoadWaivers() error = %v", err)
}
if len(diags) != 0 || len(w.Items) != 2 {
if len(diags) != 0 || len(w.Items) != 3 {
t.Fatalf("LoadWaivers() = %#v %#v", w, diags)
}
for name, body := range map[string]string{
"bad columns": "one\ttoo-few\n",
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
"bad columns": "one\ttoo-few\n",
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n",
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
} {
t.Run(name, func(t *testing.T) {
writeSemanticFile(t, repo, "waivers.txt", body)

View File

@@ -5609,6 +5609,21 @@
"final_score": "80.0587",
"recommend": "false"
},
{
"scope_name": "im:chat.nickname:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "im:chat.nickname:write",
"final_score": "79.5982",
"recommend": "true"
},
{
"scope_name": "im:chat.user_setting:write",
"final_score": "83.6587",
"recommend": "true"
},
{
"scope_name": "im:chat.user_setting:read",
"final_score": "88.0587",

View File

@@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
repo: context.repo.repo,
pull_number: target.pr,
});
if (pr.state !== "open") {
core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
return false;
}
if (pr.head.sha !== target.headSha) {
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
return false;

View File

@@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => {
});
});
it("does not publish a summary when the PR closes before comment creation", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
pullResponses: [
currentPullResponse(),
currentPullResponse({ state: "closed" }),
],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 0);
assert.match(calls.notices.join("\n"), /PR is no longer open/);
});
});
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
@@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) {
function currentPullResponse(overrides = {}) {
return {
data: {
state: overrides.state || "open",
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
base: {
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,

View File

@@ -5,26 +5,42 @@
set -euo pipefail
workflow=".github/workflows/ci.yml"
job_section() {
local job="$1"
awk -v job="$job" '
$0 == " " job ":" { in_job = 1; print; next }
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
in_job { print }
' "$workflow"
}
workflow_permissions="$(awk '
/^permissions:/ { in_permissions = 1; print; next }
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
/^ lint:/ { in_job = 1 }
in_job { print }
/^ deterministic-gate:/ { exit }
/^ script-test:/ { exit }
' "$workflow")"
script_test_section="$(job_section script-test)"
deterministic_section="$(awk '
/^ deterministic-gate:/ { in_job = 1 }
in_job { print }
/^ coverage:/ { exit }
' "$workflow")"
coverage_job_section="$(job_section coverage)"
deadcode_section="$(job_section deadcode)"
dry_run_section="$(job_section e2e-dry-run)"
section="$(awk '
/^ e2e-live:/ { in_job = 1 }
in_job { print }
/^ security:/ { exit }
' "$workflow")"
security_section="$(job_section security)"
license_header_section="$(job_section license-header)"
results_section="$(awk '
/^ results:/ { in_job = 1 }
in_job { print }
@@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
exit 1
fi
if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
echo "deterministic-gate should write PR title/body metadata before quality-gate"
exit 1
fi
if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
exit 1
fi
if ! grep -Fq "script-test:" <<<"$script_test_section"; then
echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
exit 1
fi
if ! grep -Fq "make script-test" <<<"$script_test_section"; then
echo "script-test job should invoke make script-test"
exit 1
fi
if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
echo "script-test job should install Node for JavaScript workflow tests"
exit 1
fi
if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
echo "script-test must not reference secrets"
exit 1
fi
if grep -Fq "metadata-gate:" "$workflow"; then
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
exit 1
fi
if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
exit 1
fi
for full_job in \
"$unit_test_section" \
"$lint_section" \
"$script_test_section" \
"$deterministic_section" \
"$coverage_job_section" \
"$dry_run_section" \
"$security_section"; do
if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
exit 1
fi
done
for pull_request_job in "$deadcode_section" "$license_header_section"; do
if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
echo "pull_request-only CI jobs must run on edited events"
exit 1
fi
done
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
echo "deterministic-gate must not reference secrets"
exit 1
fi
if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
echo "deterministic-gate should pass public content metadata into make quality-gate"
exit 1
fi
if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
echo "deterministic-gate should pass the pull request branch into public content metadata"
exit 1
fi
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
echo "E2E jobs should wait for deterministic-gate"
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
echo "E2E jobs should wait for script-test and deterministic-gate"
exit 1
fi
if ! grep -Fq "script-test" <<<"$results_section"; then
echo "results job should include script-test"
exit 1
fi
@@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp
exit 1
fi
if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
echo "quality-gate check should consume public content metadata"
exit 1
fi
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
echo "quality-gate check should consume both exported command snapshots"

View File

@@ -175,7 +175,7 @@ function inlineCode(value) {
}
function parseEvidenceRef(ref) {
const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
if (!match) {
return null;
}
@@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) {
return { kind: parsed.kind, command: item.path, label: item.path };
}
return null;
case "public_content":
if (item.file && Number.isInteger(item.line) && item.line > 0) {
const label = `${item.file}:${item.line}`;
if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
return { kind: parsed.kind, label };
}
return {
kind: parsed.kind,
path: item.file,
line: item.line,
label,
};
}
return null;
default:
return null;
}
@@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
repo: context.repo.repo,
pull_number: target.pr,
});
if (pr.state !== "open") {
core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
return false;
}
if (pr.head.sha !== target.headSha) {
core.notice(`semantic review skipped: PR head changed before ${phase}`);
return false;

View File

@@ -202,6 +202,100 @@ describe("semantic-review-publish", () => {
assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null);
});
it("maps public content evidence to changed files but not virtual metadata", () => {
const restrictedScope = "pri" + "vate";
const facts = {
public_content: [
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "docs/public-roadmap.md",
line: 4,
source: "file",
},
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "pull_request_metadata",
line: 1,
source: "metadata",
},
{
rule: "public_content_automation_branch",
action: "WARNING",
file: "branch",
line: 1,
source: "branch",
},
{
rule: "public_content_change_id_trailer",
action: "REJECT",
file: "commit:1234abc",
line: 3,
source: "commit",
},
],
};
const changedLineIndex = buildChangedLineIndex([{
filename: "docs/public-roadmap.md",
patch: [
"@@ -3,2 +3,3 @@",
" context",
"+Specific " + restrictedScope + " roadmap detail",
].join("\n"),
}]);
assert.deepEqual(
selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex),
{ path: "docs/public-roadmap.md", line: 4 },
);
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null);
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null);
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null);
const markdown = buildSummaryMarkdown({
block_mode: true,
blockers: [{
category: "public_content_leakage",
severity: "major",
review_action: "must_fix",
evidence: ["facts.public_content[1]"],
fingerprint: "public-content-metadata",
message: "PR metadata contains " + restrictedScope + " rollout detail",
suggested_action: "Move " + restrictedScope + " detail to an internal channel.",
}],
warnings: [],
}, facts);
assert.match(markdown, /pull_request_metadata:1/);
const virtualMarkdown = buildSummaryMarkdown({
block_mode: true,
blockers: [
{
category: "public_content_leakage",
severity: "major",
review_action: "must_fix",
evidence: ["facts.public_content[2]"],
fingerprint: "public-content-branch",
message: "Branch name looks automation-owned.",
suggested_action: "Use a maintainer-owned public branch name.",
},
{
category: "public_content_leakage",
severity: "major",
review_action: "must_fix",
evidence: ["facts.public_content[3]"],
fingerprint: "public-content-commit",
message: "Commit trailer contains " + restrictedScope + " review metadata.",
suggested_action: "Remove " + restrictedScope + " review metadata from commits.",
},
],
warnings: [],
}, facts);
assert.match(virtualMarkdown, /branch:1/);
assert.match(virtualMarkdown, /commit:1234abc:3/);
});
it("builds finding markers from stable fingerprints and evidence identity", () => {
const factsA = {
skills: [{
@@ -615,6 +709,35 @@ describe("semantic-review-publish", () => {
});
});
it("skips publishing when the PR closes after verification", async () => {
await withPublishTempDir(async ({ calls }) => {
fs.writeFileSync("decision.json", JSON.stringify({
block_mode: true,
blockers: [],
warnings: [],
}), "utf8");
await publish({
github: fakeGithub(calls, {
currentPullRequest: {
state: "closed",
head: { sha: "0123456789abcdef0123456789abcdef01234567" },
base: {
sha: "fedcba9876543210fedcba9876543210fedcba98",
repo: { id: 123 },
},
},
}),
context: workflowRunContext(),
core: silentCore(calls),
});
assert.equal(calls.checks.length, 0);
assert.equal(calls.comments.length, 0);
assert.match(calls.notices[0], /PR is no longer open before publishing/);
});
});
it("rejects publishing when the PR base repo changed after verification", async () => {
await withPublishTempDir(async ({ calls }) => {
fs.writeFileSync("decision.json", JSON.stringify({
@@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) {
},
},
pulls: {
get: async () => ({
data: Array.isArray(options.currentPullRequests)
get: async () => {
const pull = Array.isArray(options.currentPullRequests)
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
: options.currentPullRequest || {
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
@@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) {
sha: process.env.SEMANTIC_REVIEW_BASE_SHA,
repo: { id: 123 },
},
},
}),
};
return { data: { state: "open", ...pull } };
},
listFiles() {},
listReviewComments() {},
createReviewComment: async (args) => {

View File

@@ -229,6 +229,36 @@ function requireSafePath(value, path) {
return file;
}
function requirePublicContentFile(value, path) {
const file = requireString(value, path);
if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
return file;
}
if (file.startsWith("commit:")) {
throw new Error(`facts JSON ${path} must be a valid public content location`);
}
requireSafePath(file, path);
if (
file === "" ||
file === "." ||
file.startsWith("./") ||
file.includes("\\") ||
file.includes("\0") ||
file.split("/").includes(".git") ||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
) {
throw new Error(`facts JSON ${path} must be a repository-relative path`);
}
return file;
}
function requirePositiveLine(value, path) {
requireLine(value, path);
if (value === 0) {
throw new Error(`facts JSON ${path} must be a positive line number`);
}
}
function requireStringArray(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
@@ -421,6 +451,20 @@ function verifyFactsJSON(data) {
for (const [i, value] of requireArray(facts, "examples").entries()) {
verifyCommandExample(value, `examples[${i}]`);
}
for (const [i, value] of requireArray(facts, "public_content").entries()) {
const item = requireObject(value, `public_content[${i}]`);
requireString(item.rule, `public_content[${i}].rule`);
const action = requireString(item.action, `public_content[${i}].action`);
if (!VALID_ACTIONS.has(action)) {
throw new Error(`facts JSON public_content[${i}].action is invalid`);
}
requirePublicContentFile(item.file, `public_content[${i}].file`);
requirePositiveLine(item.line, `public_content[${i}].line`);
requireString(item.source, `public_content[${i}].source`, { optional: true });
requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
requireString(item.message, `public_content[${i}].message`, { optional: true });
requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
}
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
const item = requireObject(value, `diagnostics[${i}]`);
requireString(item.rule, `diagnostics[${i}].rule`);

View File

@@ -67,7 +67,43 @@ describe("verifyZipEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
const facts = Buffer.from('{"schema_version":1}\n');
const restrictedScope = "pri" + "vate";
const facts = Buffer.from(JSON.stringify({
schema_version: 1,
public_content: [
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "pull_request_metadata",
line: 1,
source: "metadata",
excerpt: "public release notes mention an internal rollout plan",
message: "public contribution may contain sensitive implementation detail",
suggestion: "move internal detail to " + restrictedScope + " discussion",
},
{
rule: "public_content_change_id_trailer",
action: "REJECT",
file: "commit:1234abc",
line: 3,
source: "commit",
},
{
rule: "public_content_automation_branch",
action: "WARNING",
file: "branch",
line: 1,
source: "branch",
},
{
rule: "public_content_" + "pri" + "vate_ipv4",
action: "WARNING",
file: "docs/public-network.md",
line: 7,
source: "file",
},
],
}) + "\n");
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
fs.writeFileSync(zipPath, zip);
@@ -103,6 +139,19 @@ describe("verifyZipEntries", () => {
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
]) {

View File

@@ -184,6 +184,10 @@ require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing"
require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs"
require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure"
require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
@@ -212,7 +216,12 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
@@ -260,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"

View File

@@ -203,6 +203,13 @@ func TestValidateCreateV2Contract(t *testing.T) {
}
}
func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) {
rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil)
if err := validateCreateV2(context.Background(), rt); err != nil {
t.Fatalf("validateCreateV2() error = %v, want nil", err)
}
}
func TestValidateFetchV2Contract(t *testing.T) {
cases := []struct {
name string

View File

@@ -282,7 +282,6 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
@@ -292,8 +291,7 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
for _, want := range []string{
"docs +create is v2-only",
"the old v1 interface has been shut down",
"legacy v1 flag(s) --title, --markdown are no longer supported",
"--title -> put the title in --content",
"legacy v1 flag(s) --markdown are no longer supported",
"--markdown -> use --content with --doc-format markdown",
"lark-cli skills read lark-doc references/lark-doc-create.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",

View File

@@ -4,7 +4,9 @@
package doc
import (
"bytes"
"context"
"encoding/xml"
"strings"
"github.com/larksuite/cli/errs"
@@ -14,6 +16,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
@@ -25,8 +28,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
return err
}
if runtime.Str("content") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
title := strings.TrimSpace(runtime.Str("title"))
if runtime.Changed("title") && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
@@ -66,7 +73,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"content": runtime.Str("content"),
"content": buildCreateContent(runtime),
}
if v := runtime.Str("parent-token"); v != "" {
body["parent_token"] = v
@@ -78,6 +85,26 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
func buildCreateContent(runtime *common.RuntimeContext) string {
content := runtime.Str("content")
title := strings.TrimSpace(runtime.Str("title"))
if title == "" {
return content
}
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
if content == "" {
return titleTag
}
return titleTag + "\n" + content
}
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))
return buf.String()
}
// augmentDocsCreatePermission grants full_access to the current CLI user when
// the document was created with bot identity.
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {

View File

@@ -43,6 +43,23 @@ func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
}
}
func TestBuildCreateBodyPrependsTitleToContent(t *testing.T) {
t.Parallel()
runtime := newCreateBodyTestRuntime(context.Background())
if err := runtime.Cmd.Flags().Set("title", "A & B <C>"); err != nil {
t.Fatalf("set title: %v", err)
}
if err := runtime.Cmd.Flags().Set("content", "## Body"); err != nil {
t.Fatalf("set content: %v", err)
}
body := buildCreateBody(runtime)
if got, want := body["content"], "<title>A &amp; B &lt;C&gt;</title>\n## Body"; got != want {
t.Fatalf("content = %#v, want %q", got, want)
}
}
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
@@ -845,6 +862,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("title", "", "")
cmd.Flags().String("content", "<title>hello</title>", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")

View File

@@ -25,7 +25,6 @@ func docsAPIVersionCompatFlag() common.Flag {
func docsCreateLegacyFlags() []docsLegacyFlag {
return []docsLegacyFlag{
{Name: "title", Replacement: "put the title in --content, for example <title>Title</title>"},
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
{Name: "folder-token", Replacement: "use --parent-token"},
{Name: "wiki-node", Replacement: "use --parent-token"},

View File

@@ -28,7 +28,7 @@ var DriveImport = common.Shortcut{
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},

View File

@@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{
"csv": {"sheet", "bitable"},
"base": {"bitable"},
"pptx": {"slides"},
"pdf": {"slides"},
}
// driveImportSpec contains the user-facing import inputs after normalization.
@@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "pptx":
case "pptx", "pdf":
return driveImport500MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls", "base":
return driveImport20MBFileSizeLimit, true
@@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
}
switch spec.DocType {
@@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
}
typeAllowed := false
@@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
case "base":
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
case "pptx":
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
case "pptx", "pdf":
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}

View File

@@ -41,6 +41,10 @@ func TestValidateDriveImportSpec(t *testing.T) {
name: "pptx slides ok",
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
},
{
name: "pdf slides ok",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
},
{
name: "base non bitable rejected",
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
@@ -51,6 +55,11 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
wantErr: ".pptx files can only be imported as 'slides'",
},
{
name: "pdf non slides rejected",
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
wantErr: ".pdf files can only be imported as 'slides'",
},
{
name: "unknown extension rejected",
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
@@ -138,6 +147,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "pdf exceeds 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit + 1,
wantText: "exceeds 500.0 MB import limit for .pdf",
},
{
name: "pdf within 500mb limit",
filePath: "./deck.pdf",
docType: "slides",
fileSize: driveImport500MBFileSizeLimit,
},
{
name: "base exceeds 20mb limit",
filePath: "./snapshot.base",

View File

@@ -0,0 +1,686 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// driveMemberAddIDTypes covers every user-facing --member-type value accepted
// by the shortcut. Some values are normalized before hitting the API.
var driveMemberAddIDTypes = []string{
"email", "openid", "unionid", "openchat", "opendepartmentid",
"groupid", "appid", "wikispaceid",
}
var driveMemberAddPerms = []string{"view", "edit", "full_access"}
var driveMemberAddPermTypes = []string{"container", "single_page"}
var driveMemberAddWikiSpaceMemberKinds = []string{"wiki_space_member", "wiki_space_viewer", "wiki_space_editor"}
// driveMemberAddPrefixToType maps ID prefixes to their expected member_type
// for conflict validation when --member-type is provided explicitly.
var driveMemberAddPrefixToType = map[string]string{
"ou_": "openid",
"on_": "unionid",
"oc_": "openchat",
"od_": "opendepartmentid",
}
var driveMemberAddURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
}
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
const driveMemberAddBatchLimit = 10
// DriveMemberAdd adds a collaborator/member permission to a Drive resource.
var DriveMemberAdd = common.Shortcut{
Service: "drive",
Command: "+member-add",
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
Risk: "high-risk-write",
Scopes: []string{"docs:permission.member:create"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL; type is auto-inferred from URL path when omitted", Required: true},
{Name: "type", Desc: "target resource type; required when --token is a bare token"},
{Name: "member-id", Desc: "collaborator ID; comma-separated for batch (max 10). Interpretation is decided by --member-type", Required: true},
{Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|unionid|openchat|opendepartmentid|groupid|appid|wikispaceid", Required: true},
{Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"},
{Name: "perm", Desc: "permission role to grant; defaults to view"},
{Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types"},
{Name: "need-notification", Type: "bool", Desc: "send an in-app notification after the grant (user identity only)"},
},
Tips: []string{
"Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.",
"Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid, wikispaceid.",
"When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.",
"--member-type is required; if the ID prefix conflicts with --member-type (e.g. ou_xxx with email), the command rejects it.",
"--perm defaults to view (safest); use --dry-run first when granting edit or full_access.",
"For wiki nodes, --perm-type defaults to container (current page and sub-pages), except --member-type=wikispaceid where --member-kind provides the wiki-space role.",
"Department collaborator (--member-type=opendepartmentid) requires --as user; bot identity is not supported for department authorization.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveMemberAddSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveMemberAddSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveMemberAddDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveMemberAddSpec(runtime)
if err != nil {
return err
}
if len(spec.MemberIDs) == 1 {
return executeDriveMemberAddSingle(runtime, spec)
}
return executeDriveMemberAddBatch(runtime, spec)
},
}
// driveMemberAddSpec is the normalized request model shared by Validate,
// DryRun, Execute, and output shaping so they all observe the same defaults.
type driveMemberAddSpec struct {
Token string
ResourceType string
MemberIDs []string
MemberType string
// MemberKind is the explicit --member-kind value for member_type=wikispaceid.
MemberKind string
Perm string
PermType string
NeedNotification bool
NotificationSet bool
}
// DryRunParams builds the preview query string while preserving the semantic
// difference between an omitted notification flag and an explicit false.
func (spec driveMemberAddSpec) DryRunParams() map[string]interface{} {
params := map[string]interface{}{"type": spec.ResourceType}
if spec.NotificationSet {
params["need_notification"] = spec.NeedNotification
}
return params
}
// APIQueryParams builds the query params for permission.members.create.
func (spec driveMemberAddSpec) APIQueryParams() map[string]interface{} {
params := map[string]interface{}{"type": spec.ResourceType}
if spec.NotificationSet {
params["need_notification"] = strconv.FormatBool(spec.NeedNotification)
}
return params
}
// buildMemberBody builds a single member object for the request body.
func buildMemberBody(memberID, memberType, wikiSpaceMemberKind, perm, permType string) map[string]interface{} {
body := map[string]interface{}{
"member_id": memberID,
"member_type": memberType,
"perm": perm,
}
if bodyType := driveMemberAddBodyType(memberType, wikiSpaceMemberKind); bodyType != "" {
body["type"] = bodyType
}
if permType != "" {
body["perm_type"] = permType
}
return body
}
// readDriveMemberAddSpec parses runtime flags into a normalized request model,
// applying inference, defaults, and cross-field validation in one place.
func readDriveMemberAddSpec(runtime *common.RuntimeContext) (driveMemberAddSpec, error) {
token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveMemberAddSpec{}, err
}
// Parse member-id: comma-separated for batch.
rawMemberID := strings.TrimSpace(runtime.Str("member-id"))
if rawMemberID == "" {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
}
memberIDs := splitAndTrimMembers(rawMemberID)
if len(memberIDs) == 0 {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and must contain at least one non-blank ID").WithParam("--member-id")
}
if len(memberIDs) > driveMemberAddBatchLimit {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)).WithParam("--member-id")
}
if duplicate, first, second, ok := firstDuplicateDriveMemberID(memberIDs); ok {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--member-id contains duplicate collaborator ID %q at positions %d and %d; remove duplicates before retrying",
duplicate, first+1, second+1,
).WithParam("--member-id")
}
memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type"))
if err != nil {
return driveMemberAddSpec{}, err
}
memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind"))
if err != nil {
return driveMemberAddSpec{}, err
}
// perm: default to view.
perm, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm"), driveMemberAddPerms, "--perm")
if err != nil {
return driveMemberAddSpec{}, err
}
if perm == "" {
perm = "view"
}
// perm-type: only meaningful for wiki; default container except for wiki-space collaborators.
permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type")
if err != nil {
return driveMemberAddSpec{}, err
}
if resourceType == "wiki" && memberType == "wikispaceid" {
if runtime.Changed("perm-type") {
return driveMemberAddSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor to set the wiki-space role",
).WithParam("--perm-type")
}
permType = ""
} else if resourceType == "wiki" && permType == "" {
permType = driveMemberAddDefaultPermType(resourceType)
} else if resourceType != "wiki" && runtime.Changed("perm-type") {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
} else if resourceType != "wiki" {
permType = ""
}
spec := driveMemberAddSpec{
Token: token,
ResourceType: resourceType,
MemberIDs: memberIDs,
MemberType: memberType,
MemberKind: memberKind,
Perm: perm,
PermType: permType,
NeedNotification: runtime.Bool("need-notification"),
NotificationSet: runtime.Changed("need-notification"),
}
if runtime.As().IsBot() && spec.NotificationSet {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--need-notification is only valid with --as user; omit it when using --as bot").WithParam("--need-notification")
}
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type=opendepartmentid requires --as user; bot identity does not support adding department collaborators").WithParam("--member-type")
}
return spec, nil
}
// resolveDriveMemberAddTarget extracts (token, type) from a user-supplied
// --token value that may be either a bare token or a full resource URL, plus an
// optional explicit --type. Explicit --type wins over URL inference.
func resolveDriveMemberAddTarget(raw, explicitType string) (token, resourceType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.Hostname() == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
}
urlToken, urlType, ok := parseDriveMemberAddResourceURLPath(parsed.Path)
if !ok {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported URL path %q: expected one of %s followed by a token",
parsed.Path, strings.Join(driveMemberAddSupportedURLPaths(), ", "),
).WithParam("--token")
}
token = urlToken
if explicitType == "" {
resourceType = urlType
}
} else {
token = raw
}
if explicitType != "" {
if !isSupportedDriveMemberAddResourceType(explicitType) {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type must be one of: %s", strings.Join(driveMemberAddResourceTypes, ", ")).WithParam("--type")
}
resourceType = explicitType
}
if resourceType == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(driveMemberAddResourceTypes, ", "),
).WithParam("--type")
}
return token, resourceType, nil
}
func driveMemberAddSupportedURLPaths() []string {
paths := make([]string, 0, len(driveMemberAddURLPathToType))
for _, mapping := range driveMemberAddURLPathToType {
paths = append(paths, mapping.Prefix)
}
return paths
}
func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string, ok bool) {
for _, mapping := range driveMemberAddURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
token := path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return "", "", false
}
return token, mapping.Type, true
}
return "", "", false
}
func isSupportedDriveMemberAddResourceType(resourceType string) bool {
switch resourceType {
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
return true
default:
return false
}
}
func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) {
var err error
explicit, err = normalizeDriveMemberAddEnumValue(explicit, driveMemberAddIDTypes, "--member-type")
if err != nil {
return "", err
}
if explicit == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")).WithParam("--member-type")
}
for i, memberID := range memberIDs {
if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"member-id[%d] %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type",
i+1, memberID, expected, explicit,
).WithParam("--member-id")
}
}
return normalizeDriveMemberAddMemberType(explicit), nil
}
func resolveDriveMemberAddMemberKind(memberType, raw string) (string, error) {
memberKind, err := normalizeDriveMemberAddEnumValue(raw, driveMemberAddWikiSpaceMemberKinds, "--member-kind")
if err != nil {
return "", err
}
if memberType == "wikispaceid" {
if memberKind == "" {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-kind is required when --member-type=wikispaceid; allowed: %s",
strings.Join(driveMemberAddWikiSpaceMemberKinds, ", "),
).WithParam("--member-kind")
}
return memberKind, nil
}
if memberKind != "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-kind only applies when --member-type=wikispaceid").WithParam("--member-kind")
}
return "", nil
}
func normalizeDriveMemberAddMemberType(memberType string) string {
return strings.ToLower(strings.TrimSpace(memberType))
}
func normalizeDriveMemberAddEnumValue(raw string, allowed []string, flagName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
for _, candidate := range allowed {
if strings.EqualFold(value, candidate) {
return candidate, nil
}
}
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for %s, allowed: %s",
value,
flagName,
strings.Join(allowed, ", "),
).WithParam(flagName)
}
// splitAndTrimMembers splits a comma-separated member-id string and trims whitespace.
func splitAndTrimMembers(raw string) []string {
parts := strings.Split(raw, ",")
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
func firstDuplicateDriveMemberID(memberIDs []string) (duplicate string, first, second int, ok bool) {
seen := make(map[string]int, len(memberIDs))
for i, memberID := range memberIDs {
if prev, exists := seen[memberID]; exists {
return memberID, prev, i, true
}
seen[memberID] = i
}
return "", 0, 0, false
}
// inferMemberTypeFromID returns the expected member_type for a member-id
// based on its prefix, or "" if no prefix matches (e.g. groupid).
func inferMemberTypeFromID(memberID string) string {
memberID = strings.TrimSpace(memberID)
if memberID == "" {
return ""
}
if strings.Contains(memberID, "@") {
return "email"
}
for prefix, mtype := range driveMemberAddPrefixToType {
if strings.HasPrefix(memberID, prefix) {
return mtype
}
}
return ""
}
// driveMemberAddDefaultPermType returns the default perm_type for a given
// resource type. For wiki nodes, container is the default for regular
// collaborators. Wiki-space collaborators omit perm_type because their role is
// carried by the body type field.
func driveMemberAddDefaultPermType(resourceType string) string {
switch resourceType {
case "wiki":
return "container"
default:
return ""
}
}
// inferDriveMemberKind derives the request-body collaborator kind from
// member-type for all supported member-type values.
func inferDriveMemberKind(memberType string) string {
switch memberType {
case "email", "openid", "unionid", "userid":
return "user"
case "openchat":
return "chat"
case "opendepartmentid":
return "department"
case "groupid":
return "group"
default:
return ""
}
}
func driveMemberAddBodyType(memberType, wikiSpaceMemberKind string) string {
if memberType == "wikispaceid" {
return wikiSpaceMemberKind
}
return inferDriveMemberKind(memberType)
}
// buildDriveMemberAddDryRun renders the exact request preview for --dry-run.
func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI {
if len(spec.MemberIDs) == 1 {
body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
return common.NewDryRunAPI().
Desc("Add Drive collaborator/member permission").
POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token))).
Params(spec.DryRunParams()).
Body(body)
}
members := buildDriveMemberAddMemberBodies(spec)
return common.NewDryRunAPI().
Desc("Batch add Drive collaborator/member permissions").
POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token))).
Params(spec.DryRunParams()).
Body(map[string]interface{}{"members": members})
}
// executeDriveMemberAddSingle calls the single-member create API.
func executeDriveMemberAddSingle(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n",
common.MaskToken(spec.MemberIDs[0]), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token)),
spec.APIQueryParams(),
body,
)
if err != nil {
return err
}
out := driveMemberAddOutput(spec, spec.MemberIDs[0], common.GetMap(data, "member"))
fmt.Fprintf(runtime.IO().ErrOut, "Added Drive member %s\n", common.MaskToken(common.GetString(out, "member_id")))
runtime.Out(out, nil)
return nil
}
// executeDriveMemberAddBatch calls the batch_create API. A successful HTTP/API
// response is treated as complete only when the server returns every requested
// member_id, regardless of response array order.
func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
members := buildDriveMemberAddMemberBodies(spec)
fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n",
len(spec.MemberIDs), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token)),
spec.APIQueryParams(),
map[string]interface{}{"members": members},
)
if err != nil {
return wrapDriveMemberAddBatchAPIError(err)
}
result := buildDriveMemberAddBatchResult(spec, data)
if common.GetBool(result, "partial") {
return runtime.OutPartialFailure(result, nil)
}
fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", result["succeeded_count"])
runtime.Out(result, nil)
return nil
}
const (
driveMemberAddInvalidParameterCode = 1063001
driveMemberAddInvalidOperationCode = 1063003
)
func wrapDriveMemberAddBatchAPIError(err error) error {
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
return err
}
wrapped := *apiErr
switch apiErr.Code {
case driveMemberAddInvalidOperationCode:
wrapped.Message = "Drive batch member add failed: one or more requested members may already be collaborators on this resource"
wrapped.Hint = "For batch add, remove members that already have access (especially a bot/app being added again), then retry only the missing collaborators."
case driveMemberAddInvalidParameterCode:
wrapped.Message = "Drive batch member add failed: one or more requested members may be invalid for this resource or identity"
wrapped.Hint = "Check whether each --member-id exists, belongs to the same tenant, and is visible to the current identity; remove invalid members and retry only the valid collaborators."
default:
return err
}
wrapped.Cause = err
return &wrapped
}
func buildDriveMemberAddMemberBodies(spec driveMemberAddSpec) []map[string]interface{} {
members := make([]map[string]interface{}, len(spec.MemberIDs))
for i, mid := range spec.MemberIDs {
members[i] = buildMemberBody(mid, spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
}
return members
}
func buildDriveMemberAddBatchResult(spec driveMemberAddSpec, data map[string]interface{}) map[string]interface{} {
rawMembers, _ := data["members"].([]interface{})
// Build set of requested IDs for O(1) lookup.
requestedSet := make(map[string]bool, len(spec.MemberIDs))
for _, id := range spec.MemberIDs {
requestedSet[id] = true
}
// First pass: build returned map and results array.
// Matching is done by member_id, not by array index, so the server may
// return members in any order without causing false partial_failure.
results := make([]map[string]interface{}, 0, len(rawMembers))
succeededIDs := make(map[string]bool, len(rawMembers))
var mismatched []map[string]interface{}
for _, raw := range rawMembers {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
rawMemberID := common.GetString(m, "member_id")
out := driveMemberAddOutputWithOptions(spec, "", m, false)
results = append(results, out)
if rawMemberID != "" {
if requestedSet[rawMemberID] {
succeededIDs[rawMemberID] = true
} else {
mismatched = append(mismatched, map[string]interface{}{
"returned": rawMemberID,
})
}
}
}
// Second pass: find requested IDs missing from the response.
missing := make([]string, 0)
for _, memberID := range spec.MemberIDs {
if !succeededIDs[memberID] {
missing = append(missing, memberID)
}
}
partial := len(results) != len(spec.MemberIDs) || len(missing) > 0 || len(mismatched) > 0
result := map[string]interface{}{
"resource_token": spec.Token,
"resource_type": spec.ResourceType,
"requested_count": len(spec.MemberIDs),
"succeeded_count": len(succeededIDs),
"partial": partial,
"members": results,
"missing_member_ids": missing,
}
if len(mismatched) > 0 {
result["mismatched_member_ids"] = mismatched
}
return result
}
// driveMemberAddOutput flattens the server response into a stable envelope and
// backfills fields from spec when the server omits them.
func driveMemberAddOutput(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}) map[string]interface{} {
return driveMemberAddOutputWithOptions(spec, fallbackMemberID, raw, true)
}
func driveMemberAddOutputWithOptions(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}, allowDefaultMemberID bool) map[string]interface{} {
out := map[string]interface{}{
"resource_token": spec.Token,
"resource_type": spec.ResourceType,
}
if raw != nil {
for _, key := range []string{"member_id", "member_type", "perm", "type"} {
if v, ok := raw[key]; ok {
out[key] = v
}
}
if spec.ResourceType == "wiki" {
if v, ok := raw["perm_type"]; ok {
out["perm_type"] = v
}
}
}
if common.GetString(out, "member_id") == "" {
if fallbackMemberID == "" && allowDefaultMemberID && len(spec.MemberIDs) > 0 {
fallbackMemberID = spec.MemberIDs[0]
}
if fallbackMemberID != "" {
out["member_id"] = fallbackMemberID
}
}
if common.GetString(out, "member_type") == "" {
out["member_type"] = spec.MemberType
}
if common.GetString(out, "perm") == "" {
out["perm"] = spec.Perm
}
if spec.PermType != "" && common.GetString(out, "perm_type") == "" {
out["perm_type"] = spec.PermType
}
if bodyType := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); bodyType != "" && common.GetString(out, "type") == "" {
out["type"] = bodyType
}
if t := common.GetString(out, "type"); t != "" {
out["member_kind"] = t
}
delete(out, "type")
return out
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
DriveSync,
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -33,6 +33,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+sync",
"+task_result",
"+apply-permission",
"+member-add",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -246,8 +246,9 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
shortcutHelp: "Create a Lark document",
visibleFlag: "--content",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"},
hiddenFlags: []string{"markdown", "folder-token", "wiki-node", "wiki-space"},
contentHelp: []string{
"--title",
"AI agents MUST read",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"before writing any --content payload",
@@ -257,7 +258,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
"MUST NOT grep/open local SKILL.md files",
"use --help for the latest command flags",
},
unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"},
unwanted: []string{"--markdown", "--folder-token", "--wiki-node", "--wiki-space"},
},
{
name: "fetch",

View File

@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
}
}

View File

@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
// Prefer the URL returned by presentation.create. Fall back to a local
// brand-standard URL only when the API omits it.
if url := common.GetString(data, "url"); url != "" {
result["url"] = url
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}

View File

@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_abc123",
"revision_id": 1,
"url": "https://tenant.example.com/slides/pres_abc123",
},
},
})
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
// URL is built locally from the token (brand-standard host), not fetched from
// drive metas, so it is deterministic and needs no drive scope.
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
}
}
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
// locally from the token — no drive metas/batch_query call is made, so creation
// works for users who only authorized slides scopes. The httpmock registry has no
// batch_query stub registered; if the shortcut tried to call it, the request would
// fail the test (unregistered stub), proving the URL is built without a drive call.
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
// constructed locally from the token when presentation.create omits url — no
// drive metas/batch_query call is made, so creation works for users who only
// authorized slides scopes. The httpmock registry has no batch_query stub
// registered; if the shortcut tried to call it, the request would fail the test.
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_local_url",
"revision_id": 1,
"url": "",
},
},
})

View File

@@ -0,0 +1,426 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
// It deliberately creates the new page before deleting the old one so a create
// failure cannot remove existing user content. The operation is not atomic.
const replacePagesInitialRevisionID = -1
var SlidesReplacePages = common.Shortcut{
Service: "slides",
Command: "+replace-pages",
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return err
}
return validateReplacePagesInput(pages)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI()
resolved, err := prepareReplacePages(runtime)
if err != nil {
return dry.Set("error", err.Error())
}
appendReplacePagesDryRunCalls(dry, resolved)
return dry.
Set("xml_presentation_id", resolved.PresentationID).
Set("pages_count", len(resolved.Plan)).
Set("plan", replacePagesPlanOutput(resolved.Plan)).
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
resolved, err := prepareReplacePages(runtime)
if err != nil {
return err
}
if runtime.Bool("validate-only") {
runtime.Out(map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"plan": replacePagesPlanOutput(resolved.Plan),
"status": "validated",
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
}, nil)
return nil
}
revisionID := replacePagesInitialRevisionID
results := make([]replacePageResult, 0, len(resolved.Plan))
for i, item := range resolved.Plan {
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
results = append(results, result)
if result.RevisionID != nil {
revisionID = *result.RevisionID
}
if err != nil {
if runtime.Bool("continue-on-error") {
continue
}
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
}
}
out := map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"results": replacePageResultsOutput(results),
"status": "completed",
"summary": replacePagesSummaryOutput(results),
"note": "batch replace is not atomic; each page was created before its old page was deleted",
}
if revisionID != replacePagesInitialRevisionID {
out["revision_id"] = revisionID
}
if hasReplacePageFailures(results) {
out["status"] = "partial_failure"
return runtime.OutPartialFailure(out, nil)
}
runtime.Out(out, nil)
return nil
},
}
type replacePageInput struct {
SlideID string
Content string
}
type replacePagePlanItem struct {
OldSlideID string
Content string
Locator string
}
type replacePagesPrepared struct {
PresentationID string
Plan []replacePagePlanItem
}
type replacePageResult struct {
OldSlideID string
NewSlideID string
Status string
Error string
RevisionID *int
}
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return nil, err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return nil, err
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return nil, err
}
if err := validateReplacePagesInput(pages); err != nil {
return nil, err
}
plan, err := buildReplacePagesPlan(pages)
if err != nil {
return nil, err
}
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
}
func parseReplacePages(raw string) ([]replacePageInput, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
}
var decoded []map[string]interface{}
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
}
out := make([]replacePageInput, 0, len(decoded))
for i, m := range decoded {
p := replacePageInput{}
if v, ok := m["slide_number"]; ok {
_ = v
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
}
if v, ok := m["slide_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
}
p.SlideID = s
}
if v, ok := m["content"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
}
p.Content = s
}
out = append(out, p)
}
return out, nil
}
func validateReplacePagesInput(pages []replacePageInput) error {
if len(pages) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
}
seenIDs := map[string]bool{}
for i, p := range pages {
id := strings.TrimSpace(p.SlideID)
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
}
if seenIDs[id] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
}
seenIDs[id] = true
if strings.TrimSpace(p.Content) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
}
if err := validateCompleteSlideXML(p.Content); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
}
}
return nil
}
func validateCompleteSlideXML(content string) error {
dec := xml.NewDecoder(strings.NewReader(content))
depth := 0
seenRoot := false
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if depth == 0 {
if seenRoot {
return invalidSlideXMLStructureError("multiple root elements")
}
if t.Name.Local != "slide" {
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
}
seenRoot = true
}
depth++
case xml.EndElement:
depth--
case xml.CharData:
if depth == 0 && strings.TrimSpace(string(t)) != "" {
return invalidSlideXMLStructureError("non-whitespace text outside root element")
}
}
}
if !seenRoot {
return invalidSlideXMLStructureError("missing root element")
}
if depth != 0 {
return invalidSlideXMLStructureError("unclosed XML element")
}
return nil
}
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
plan := make([]replacePagePlanItem, 0, len(pages))
for _, page := range pages {
id := strings.TrimSpace(page.SlideID)
plan = append(plan, replacePagePlanItem{
OldSlideID: id,
Content: page.Content,
Locator: "slide_id",
})
}
return plan, nil
}
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
for i, item := range resolved.Plan {
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
})
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": "<revision_returned_by_create>",
})
}
}
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
result := replacePageResult{
OldSlideID: item.OldSlideID,
Status: "pending",
}
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
createData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": revisionID},
map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
},
)
if err != nil {
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
newSlideID := common.GetString(createData, "slide_id")
if newSlideID == "" {
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
result.NewSlideID = newSlideID
if rev, ok := revisionFromData(createData); ok {
revisionID = rev
result.RevisionID = &rev
}
deleteData, err := runtime.CallAPITyped(
"DELETE",
slideURL,
map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": revisionID,
},
nil,
)
if err != nil {
result.Status = "delete_failed"
result.Error = err.Error()
return result, err
}
if rev, ok := revisionFromData(deleteData); ok {
result.RevisionID = &rev
}
result.Status = "replaced"
return result, nil
}
func revisionFromData(data map[string]interface{}) (int, bool) {
if _, ok := data["revision_id"]; !ok {
return 0, false
}
return int(common.GetFloat(data, "revision_id")), true
}
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(plan))
for _, item := range plan {
out = append(out, map[string]interface{}{
"old_slide_id": item.OldSlideID,
"insert_before_slide_id": item.OldSlideID,
"locator": item.Locator,
"action": "create_before_then_delete_old",
})
}
return out
}
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(results))
for _, result := range results {
m := map[string]interface{}{
"old_slide_id": result.OldSlideID,
"status": result.Status,
}
if result.NewSlideID != "" {
m["new_slide_id"] = result.NewSlideID
}
if result.Error != "" {
m["error"] = result.Error
}
if result.RevisionID != nil {
m["revision_id"] = *result.RevisionID
}
out = append(out, m)
}
return out
}
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
replaced := countReplacedPages(results)
return map[string]interface{}{
"replaced": replaced,
"failed": len(results) - replaced,
"total": len(results),
}
}
func countReplacedPages(results []replacePageResult) int {
n := 0
for _, result := range results {
if result.Status == "replaced" {
n++
}
}
return n
}
func hasReplacePageFailures(results []replacePageResult) bool {
for _, result := range results {
if result.Status == "create_failed" || result.Status == "delete_failed" {
return true
}
}
return false
}

View File

@@ -0,0 +1,341 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestReplacePagesDeclaredScopes(t *testing.T) {
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
}
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
}
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
var requestOrder []string
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
},
}
reg.Register(createStub)
var deleteQuery map[string][]string
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
deleteQuery = req.URL.Query()
},
}
reg.Register(deleteStub)
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody struct {
Slide struct {
Content string `json:"content"`
} `json:"slide"`
BeforeSlideID string `json:"before_slide_id"`
}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
}
if createBody.BeforeSlideID != "old2" {
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
}
if !strings.Contains(createBody.Slide.Content, "<slide") {
t.Fatalf("create content = %q", createBody.Slide.Content)
}
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
}
deleteURL := string(deleteStub.CapturedBody)
if deleteURL != "" {
t.Fatalf("delete body = %q, want empty", deleteURL)
}
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
t.Fatalf("delete slide_id = %#v, want old2", got)
}
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
if data["revision_id"] != float64(12) {
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["failed"] != float64(0) {
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
}
results, _ := data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
t.Fatalf("result = %#v", first)
}
}
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
})
pages := `[
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
data := env.Data
if data["status"] != "partial_failure" {
t.Fatalf("status = %v, want partial_failure", data["status"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
}
results, _ := data["results"].([]interface{})
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
first, _ := results[0].(map[string]interface{})
second, _ := results[1].(map[string]interface{})
if first["status"] != "create_failed" {
t.Fatalf("first status = %v, want create_failed", first["status"])
}
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
t.Fatalf("second result = %#v, want replaced with new2", second)
}
}
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
results, _ := env.Data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["status"] != "delete_failed" {
t.Fatalf("status = %v, want delete_failed", first["status"])
}
if first["new_slide_id"] != "new1" {
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
}
}
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
}
if out["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
}
plan, _ := out["plan"].([]interface{})
if len(plan) != 1 {
t.Fatalf("plan len = %d, want 1", len(plan))
}
item, _ := plan[0].(map[string]interface{})
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
t.Fatalf("plan item = %#v", item)
}
api, _ := out["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("api len = %d, want create/delete plan", len(api))
}
}
func TestReplacePagesValidationParam(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pages string
}{
{"empty pages", `[]`},
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
{"no locator", `[{"content":"<slide/>"}]`},
{"empty content", `[{"slide_id":"s1","content":" "}]`},
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", tt.pages,
"--as", "user",
})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *errs.ValidationError", err)
}
if ve.Param != "--pages" {
t.Fatalf("Param = %q, want --pages", ve.Param)
}
})
}
}
type replacePagesEnvelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
t.Helper()
var env replacePagesEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
}
if env.Data == nil {
t.Fatalf("missing data: %#v", env)
}
return env
}

View File

@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
Command: "+replace-slide",
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
@@ -15,6 +16,21 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)
func TestReplaceSlideDeclaredScopes(t *testing.T) {
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
}
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
}
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
// onto the root before sending. The backend returns 3350001 otherwise.

View File

@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},

View File

@@ -17,11 +17,23 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesXMLGet fetches the full XML presentation content and writes it to a
// local file, keeping the terminal output small for large decks.
var SlidesXMLGet = common.Shortcut{
Service: "slides",
Command: "+xml-get",
Description: "Fetch full presentation XML and save it to a local file",
Risk: "read",
Scopes: []string{"slides:presentation:read"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("output")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
}
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
}
if runtime.Int("revision-id") < -1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc("Fetch full presentation XML and save it to a local file")
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
dry.GET(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s",
validate.EncodePathSegment(presentationID),
)).
Params(params)
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
params,
nil,
)
if err != nil {
return err
}
presentation := common.GetMap(data, "xml_presentation")
content := common.GetString(presentation, "content")
if content == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
}
outputPath := runtime.Str("output")
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),
}, bytes.NewReader([]byte(content)))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(outputPath)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
}
out := map[string]interface{}{
"xml_presentation_id": presentationID,
"path": resolvedPath,
"size": result.Size(),
"content_saved": true,
}
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
out["revision_id"] = int(revisionID)
}
if runtime.Bool("remove-attr-id") {
out["remove_attr_id"] = true
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"presentation_id": "pres_abc",
"revision_id": 7,
"content": xml,
},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "readback.xml",
"--revision-id", "7",
"--remove-attr-id",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "readback.xml")
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
}
if strings.Contains(stdout.String(), xml) {
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
}
if got := capturedQuery.Get("revision_id"); got != "7" {
t.Fatalf("revision_id query = %q, want 7", got)
}
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
t.Fatalf("remove_attr_id query = %q, want true", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
}
if data["revision_id"] != float64(7) {
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
}
if data["size"] != float64(len(xml)) {
t.Fatalf("size = %v, want %d", data["size"], len(xml))
}
gotPath, _ := data["path"].(string)
if !filepath.IsAbs(gotPath) {
t.Fatalf("path = %v, want absolute path", gotPath)
}
if !strings.HasSuffix(gotPath, "readback.xml") {
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "pres_real",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": `<presentation/>`,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
"--output", "wiki.xml",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_real" {
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
}
}
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "../readback.xml",
"--as", "user",
})
if err == nil {
t.Fatal("expected unsafe output path error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "--output" {
t.Fatalf("param = %q, want --output", validationErr.Param)
}
}

View File

@@ -0,0 +1,40 @@
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `approvals search` |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
| 发起原生审批实例 | `instances create` |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
处理链:
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
lark-cli approval approvals get --params '{"approval_code":"<code>"}' --as user
lark-cli approval instances create --data '{"approval_code":"<code>","form":"[...]"}' --yes --as user
lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
## 发起原生审批
发起审批属于高风险写操作,按下表处理:
| 规则 | 处理 |
|---|---|
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form``node_approver_list``node_cc_list` |
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code``instance_link` |

View File

@@ -1,7 +1,7 @@
---
name: lark-approval
version: 1.1.0
description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。"
version: 1.2.0
description: "飞书审批:查询和处理审批待办/已办/实例,搜索可发起审批定义、查看定义详情并发起原生审批实例。当用户要处理审批任务、查看审批实例、搜索或发起审批时使用。审批待办不是飞书任务;非审批类待办走 lark-task不负责创建审批定义;三方审批定义不走原生提单。"
metadata:
requires:
bins: ["lark-cli"]
@@ -16,6 +16,9 @@ metadata:
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `approvals search` |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
| 发起原生审批实例 | `instances create` |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
@@ -23,13 +26,31 @@ metadata:
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
处理链:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
处理链:
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
lark-cli approval approvals get --params '{"approval_code":"<code>"}' --as user
lark-cli approval instances create --data '{"approval_code":"<code>","form":"[...]"}' --yes --as user
lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
## 发起原生审批
发起审批属于高风险写操作,按下表处理:
| 规则 | 处理 |
|---|---|
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form``node_approver_list``node_cc_list` |
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code``instance_link` |
## 不在本 skill 范围
创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)

View File

@@ -0,0 +1,196 @@
# 审批提单工作流
## 执行摘要
- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
- **先读控件参数 reference 和值来源 reference再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id``type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
- **节点参数只从 `node_list``schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`
## 适用场景
- “帮我提交一个请假审批”
- “帮我发起报销审批”
- “我想提一个出差审批”
- “先搜可发起的审批,再帮我提单”
## 严禁行为
- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
- **严禁跳过 `approvals.get`。** 未拿到 `form``node_list` 前,不得调用 `instances.create`
- **严禁把姓名直接写进 `node_approver_list``node_cc_list` 或表单人员控件。** 必须先转成 `open_id`
- **严禁对三方定义调用 `instances.create`。**
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
- **严禁在未得到用户确认前直接执行真实提单。**
## 工作流
### 1. 搜索可发起审批定义
先用 `schema` 看参数,再搜索定义:
```bash
lark-cli schema approval.approvals.search
lark-cli approval approvals search --data '{"keyword":"请假"}'
```
处理规则:
- 若结果为空,告诉用户当前关键词下没有可发起定义。
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`
- 只有 `is_external=false` 的原生定义才继续下一步。
### 2. 获取审批定义详情
拿到 `approval_code` 后,读取定义详情:
```bash
lark-cli schema approval.approvals.get
lark-cli approval approvals get \
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
```
重点关注返回:
- `approval_name`: 当前发起的是哪个审批定义。
- `form`: 表单定义快照,用于识别控件 `id``type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
### 3. 组装 `form`
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则:
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
- 提交时必须至少保证每个控件的 `id``type``value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
- `contact``department``fieldList``dateInterval``amount``telephone``document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
- 值本身从哪里拿,优先按 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 处理;不要把“知道结构”误当成“已经拿到可提交值”。
- 若 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 标明某控件不支持通过创建实例 API 提交,则不要硬猜绕过;应明确告诉用户该定义当前无法仅通过 API 提单。
- 若遇到当前 skill 未明确覆盖的复杂控件,不要硬猜;先依据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 判断支持性与传值结构,再向用户确认。
## API 不支持的控件
根据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md),创建审批实例 API 不支持的控件至少包括:
- `text`
- `mutableGroup`
- `account`
- `serialNumber`
- `tripGroup`
- `apaascorehrOnboardingGroup`
- `apaascorehrRegularateGroup`
- `remedyGroupV2`
- `apaascorehrJobAdjustGroup`
- `apaascorehrOffboardingGroup`
如果目标审批定义包含上述控件,不要继续硬拼 `form`;应直接告诉用户该定义不能仅通过当前 API 完整提单。
## 高频控件速查
优先按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装,下面只保留最常用、最容易出错的格式:
- `input` / `textarea`: `value` 是字符串
- `date`: `value` 是 RFC3339 时间字符串
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value关联外部选项时传 `options.id`
- `checkbox` / `checkboxV2`: `value` 是选项值数组
- `number`: `value` 是数字
- `amount`: `value` 是数字,还要带 `currency`
- `formula`: `value` 必须与定义中的公式结果匹配,否则会报错
- `contact`: 只推荐写 `open_ids`,由人员信息先转换成 `open_id`
- `connect`: `value` 是关联审批实例 `instance_code` 数组,当前默认要求用户直接提供 `instance_code`
- `document`: `value` 是对象,至少含 `token``type=docx`
- `attachmentV2` / `image` / `imageV2`: `value` 是 file code 数组,当前默认要求用户直接提供
- `fieldList`: `value` 是二维数组,子项继续按各自控件类型组装
- `department`: `value` 是对象数组,元素字段名为 `open_id`,其值填写部门的 `open_department_id`
- `telephone`: `value` 是对象,包含 `countryCode``nationalNumber`
- `address`: `value` 是对象数组,至少包含地理库 `id`,可选 `detailAddress`;当前默认要求用户直接提供该 `id`
## 特殊控件组
[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 还明确给出了若干特殊控件组的提单格式,至少包括:
- `leaveGroupV2`
- `workGroup`
- `outGroup`
- `shiftGroup`
这类控件组不是简单文本控件,通常内部还嵌套 `radioV2``date``fieldList``image``contact` 等子控件。遇到这些控件组时:
- 先从 `approvals.get.form` 找到控件组及其子控件 ID
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
- 不要把控件组整体当成普通字符串或扁平对象提交
### 4. 组装节点参数
`node_list` 推导节点参数:
- 若某节点 `need_approver=true`,则必须在 `node_approver_list` 中补该节点的审批人。
- `key` 优先取 `custom_node_id`;若不存在,再用 `node_id`
- `value` 是审批人 `open_id` 列表。
-`approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
### 5. 创建审批实例
先看 `schema`,确认最终结构后再执行:
```bash
lark-cli schema approval.instances.create
lark-cli approval instances create \
--data '{
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
"form":"[{\"id\":\"widget1\",\"type\":\"input\",\"value\":\"请假半天\"}]",
"node_approver_list":[
{
"key":"manager_node_id",
"value":["ou_xxx"]
}
]
}' \
--yes
```
执行规则:
- 执行前先向用户确认:目标审批定义、核心表单值、节点审批人/抄送人。
- 若需要幂等,可补 `uuid`
- 成功后回报 `instance_code``instance_link`
## 组装时优先依据的资料
优先级固定如下:
1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。
2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
5. `approvals.get.node_list`:提供节点 key 与是否需要补充审批人/抄送人的线索。
不要反过来把 `approvals.get.form` 当成第一优先级,更不要把它当成可直接提交的 JSON 模板。
## 最小判断表
| 你手上有什么 | 下一步 |
|---|---|
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
| 已经拿到 `approval_code` | 直接 `approvals.get` |
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
## 返回结果
完成创建后,至少向用户返回:
- `approval_name`
- `instance_code`
- `instance_link`

View File

@@ -0,0 +1,606 @@
# 审批实例表单控件参数
> 说明:本文尽量保留上游参数文档的原始结构与示例,用于回答“控件 `value` 长什么样”。
> 当前 `lark-cli` 的推荐取值口径以 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 为准;如果两份文档在“值从哪里拿”上存在差异,以后者为准。
在调用创建审批实例接口时需要使用表单控件参数,你可以通过本文了解审批实例内各表单控件的参数说明。
## 准备工作
审批实例的表单控件参数依据审批定义表单来配置,例如,审批定义的表单设计包括了 **单行文本****日期区间** 控件,则审批实例的表单控件参数就需要为 **单行文本****日期区间** 控件进行赋值。因此,在操作审批实例表单的控件参数前,应先通过审批定义详情确认表单控件结构。
## 审批实例 API 不支持的控件
创建审批实例 API 未完全支持所有的审批表单控件,不支持的控件如下表所示。如果你必须使用 API 不支持的控件,则不能仅通过当前 API 完成提单。
**控件/控件组** | **Type** |
| ---------- | --------------------------- |
| 说明 | text |
| 引用多维表格 | mutableGroup |
| 收款账户 | account |
| 流水号 | serialNumber |
| 出差控件组 | tripGroup |
| 录用控件组 | apaascorehrOnboardingGroup |
| 转正控件组 | apaascorehrRegularateGroup |
| 补卡控件组 | remedyGroupV2 |
| 调岗控件组 | apaascorehrJobAdjustGroup |
| 离职控件组 | apaascorehrOffboardingGroup
## 通用参数
审批实例的表单控件均包含的参数如下表所示。
参数 | 类型 | 是否必填 | 描述
---|---|---|---
id | string | 是 | 控件的 ID需要与审批定义中的控件 ID 保持一致。
type | string | 是 | 控件类型。各控件类型取值参见下文 **不同控件的参数** 章节。
value | 不同控件的类型不同 | 是 | 控件的取值。不同控件 value 数据类型也不同,例如单行文本控件的 value 为字符串、联系人的 value 为数组。详情参见下文 **不同控件的参数** 章节。
## 不同控件的参数
本章节提供不同控件的 type 参数值、JSON 示例以及非通用参数说明。
### 单行文本
控件 type 为 inputJSON 数据示例:
```json
{
"id": "widget1",
"type": "input",
"value": "data" // string 类型
}
```
### 多行文本
控件 type 为 textareaJSON 数据示例:
```json
{
"id": "widget1",
"type": "textarea",
"value": "data" // string 类型
}
```
### 日期
控件 type 为 dateJSON 数据示例:
```json
{
"id": "widget1",
"type": "date",
"value": "2019-10-01T08:12:01+08:00" // 需满足 RFC3339 格式的 string 类型
}
```
### 日期区间
控件 type 为 dateIntervalJSON 数据示例:
```json
{
"id": "widget1",
"type": "dateInterval",
"value": {
"start":"2019-10-01T08:12:01+08:00",
"end":"2019-10-02T08:12:01+08:00",
"interval": 1.0
}
}
```
value 参数为 object 类型,包含参数说明:
参数 | 类型 | 是否必填 | 描述
---|---|---|---
start | string | 是 | 开始时间,需满足 RFC3339 格式。
end | string | 是 | 结束时间,需满足 RFC3339 格式。
interval | float | 是 | 时长(天)。
### 单选
控件 type 为 radio/radioV2JSON 数据示例:
```json
{
"id": "widget1",
"type": "radioV2",
"value": "k2b8mkx0-h71x5gl1234-1" // string 类型
}
```
其中, value 表示选项值,取值范围需要参考相应审批定义中 **单选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取单选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`
### 多选
控件 type 为 checkbox/checkboxV2JSON数据示例
```json
{
"id":"widget1",
"type":"checkboxV2",
"value": ["k2b8mkx0-h71x5gl4321-1"] // string 类型的数组
}
```
其中, value 表示选项值,取值范围需要参考相应审批定义中 **多选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取多选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`
### 数字
控件 type 为 numberJSON 数据示例:
```json
{
"id": "widget1",
"type": "number",
"value": 1234.5678 // float 类型
}
```
### 金额
控件 type 为 amountJSON 数据示例:
```json
{
"id": "widget1",
"type": "amount",
"value": 1234.5678, // float 类型
"currency":"USD"
}
```
其中currency 表示货币种类,取值范围需要参考相应审批定义中 **金额** 控件的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取金额控件可设置的货币种类。
### 计算公式
控件 type 为 formulaJSON 数据示例:
```json
{
"id": "widget1",
"type": "formula",
"value": 1234.5678 // 该值由审批定义内配置的公式计算出取值,若不匹配则返回报错。
}
```
### 联系人
控件 type 为 contactJSON 数据示例:
```json
{
"id":"widget1",
"type":"contact",
"value": ["f8ca557e"], // string 类型的数组
"open_ids": ["ou_12345"] // string 类型的数组
}
```
其中value 包含的是用户 `user_id`open_ids 包含的是用户 `open_id`
### 关联审批
控件 type 为 connectJSON 数据示例:
```json
{
"id":"widget1",
"type":"connect",
"value": ["19EAC829-F1CB-527F-BE2A-1330422E60C0"] // string 类型的数组
}
```
其中value 包含的是被关联的审批实例 Code你可以通过审批实例详情能力根据实例 Code 获取实例详情。
### 文档控件
控件 type 为 documentJSON 数据示例:
```json
{
"id": "widget1",
"type": "document",
"value": {
"token":"TLLKdcpDro9ijQxA33ycNMabcef",
"type":"docx",
}
}
```
value 参数为 object 类型,包含参数说明:
参数 | 类型 | 是否必填 | 描述
---|---|---|---
token | string | 是 | 文档的 document_id。
type | string | 是 | 文档类型,支持 `docx`
### 附件
控件 type 为 attachmentV2JSON 数据示例:
```json
{
"id":"widget1",
"type":"attachmentV2",
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组
}
```
其中value 包含的是上传文件后返回的文件 code。
### 图片
控件 type 为 image/imageV2JSON 数据示例:
```json
{
"id":"widget1",
"type":"image",
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组
}
```
其中value 包含的是上传文件后返回的文件 code。
### 明细/表格
控件 type 为 fieldListJSON 格式示例:
```json
{
"id": "widget1",
"type": "fieldList",
"value": [
[
{
"id": "widget1",
"type": "checkbox",
"value": ["jxpsebqp-0"]
}
]
]
}
```
其中 value 是二维数组,根据审批定义内 **明细/表格** 控件所包含的控件,依次设置控件 JSON 值。
### 部门
控件 type 为 departmentJSON 数据示例:
```json
{
"id":"widget1",
"type":"department",
"value":[
{
"open_id": "od-xxx"
}
]
}
```
其中 value 为对象数组,通过 open_id 设置部门的 open_department_id。
### 电话
控件 type 为 telephoneJSON 数据示例:
```json
{
"id":"widget1",
"type":"telephone",
"value": {
"countryCode":"+86",
"nationalNumber":"13122222222"
}
}
```
value 参数为 object 类型,包含参数说明:
参数 | 类型 | 是否必填 | 描述
---|---|---|---
countryCode | string | 是 | 区号。
nationalNumber | string | 是 | 电话号。
### 地址
控件 type 为 addressJSON 数据示例:
```json
{
"id": "widget1",
"type": "address",
"value": [{
"id": "290557",
"detailAddress": "详细的地址"
}]
}
```
value 参数为 []object 类型,参数说明如下:
参数 | 类型 | 是否必填 | 描述
---|---|---|---
value | []object | 是 | 非出差控件组场景地址控件仅支持单个地址,传入多个时默认只取第一个
└ id | string | 是 | 区域ID, 可通过审批的地理库接口获取
└ detailAddress | string | 否 | 详细的地址,若表单配置中未开启填写详细地址,则会忽略该参数,即使传入也不会生效
### 换班控件组
控件 type 为 shiftGroupJSON 数据示例:
```json
{
"id": "widget1",
"type": "shiftGroup",
"value": {
"shiftTime": "2019-10-01T08:12:01+08:00",
"returnTime": "2019-10-02T08:12:01+08:00",
"reason": "ask for leave"
}
}
```
value 参数为 object 类型,包含参数说明:
参数 | 类型 | 是否必填 | 描述
---|---|---|---
shiftTime | string | 是 | 换班时间,需满足 RFC3339 格式。
returnTime | string | 是 | 对调日期,需满足 RFC3339 格式。
reason | string | 是 | 换班原因。
### 请假控件组
**请假控件组请求示例**
```json
{
"id": "widgetLeaveGroupV2",
"type": "leaveGroupV2",
"value": [
{
"id": "widgetLeaveGroupType",
"type": "radioV2",
"value": "7488925543484620819"
},
{
"id": "widgetLeaveGroupStartTime",
"type": "date",
"value": "2025-08-25T11:30:00+08:00"
},
{
"id": "widgetLeaveGroupEndTime",
"type": "date",
"value": "2025-08-26T11:35:00+08:00"
},
{
"id": "widgetLeaveGroupReason",
"type": "textarea",
"value": "123123"
},
{
"id": "widgetLeaveCertification",
"type": "image",
"value": [
"B69F8E26-0EAA-4A92-9B80-DA613CD36136"
]
},
{
"id":"widgetLeaveCertification",
"type":"image",
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]
},
{
"id": "widgetLeaveGroupFeedingArrivingLate",
"type": "radioV2",
"value": "30"
},
{
"id": "widgetLeaveGroupFeedingOffLeaveEarly",
"type": "radioV2",
"value": "30"
}
]
}
```
**请假控件组包含参数说明:**
id | 类型 | JSON示例 | 描述
---|---|---|---
id | string | 是 | 控件组ID固定为widgetLeaveGroupV2
type | string | 是 | 控件组类型固定为leaveGroupV2
value | object[] | 是 | 控件组的值,值为多个子控件值的列表
value中包含的子控件值说明:
id | 类型 | JSON示例 | 描述
---|---|---|---
widgetLeaveGroupType | radioV2 | ```<br>{<br>"id": "widgetLeaveGroupType",<br>"type": "radioV2",<br>"value": "7488925543484620819"<br>}<br>``` | 假期类型,具体格式可参考单选控件,选项由假勤接口获取,提单时必须包含该控件
widgetLeaveGroupStartTime | date | ```<br>{<br>"id": "widgetLeaveGroupStartTime",<br>"type": "date",<br>"value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型<br>} <br>``` | 请假开始时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午小时假则以半小时为粒度向前取整, 提单时必须包含该控件
widgetLeaveGroupEndTime | date | ```<br>{<br>"id": "widgetLeaveGroupEndTime",<br>"type": "date",<br>"value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型<br>}<br>``` | 请假结束时间具体格式可参考日期控件会根据假期类型自动取整其中半天假小于12点则认为是上午小时假则以半小时为粒度向后取整
widgetLeaveGroupReason | textarea | ```<br>{<br>"id": "widgetLeaveGroupReason",<br>"type": "textarea",<br>"value": "123123"<br>}<br>``` | 请假事由,具体格式可参考多行文本控件,哺乳假无需填写,其他情况则根据控件组配置中该控件是否可见以及必填判断
widgetLeaveCertification | image | ```<br>{<br>"id":"widgetLeaveCertification",<br>"type":"image",<br>"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]<br>}<br>``` | 请假证明,具体格式可参考图片控件,如果所选假期类型配置要求补充证明则必须传递该值,缺失会报错
widgetLeaveGroupFeedingArrivingLate | radioV2 | ```<br>{ <br>"id": "widgetLeaveGroupFeedingArrivingLate",<br>"type": "radioV2",<br>"value": "30"<br>}<br>``` | 上班晚到的分钟数具体格式可参考单选控件仅哺乳假需要填写取值范围是0-120分钟粒度是15分钟选项从审批定义中该控件的option中获取
widgetLeaveGroupFeedingOffLeaveEarly | radioV2 | ```<br>{ <br>"id": "widgetLeaveGroupFeedingOffLeaveEarly",<br>"type": "radioV2",<br>"value": "30"<br>} <br>``` | 下班早走的分钟数具体格式可参考单选控件仅哺乳假需要填写取值范围是0-120分钟粒度是15分钟选项即是分钟对应的字符串
**特殊的参数校验报错信息**
message | 说明 |
| -------------------------------------------------- | ---------------------------- |
| leave type id parse error | 请假类型不是int64 |
| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* |
| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* |
| start time is after end time | 开始时间晚于结束时间 |
| user not in gray | 申请用户不在假勤灰度内 |
| leave type not found | 请假类型不存在 |
| reason is required | 请假原因未填写 |
| leave quote should be bigger than 0 | 请假时长需要大于0 |
| leave is conflict | 所选时间内已有请假记录,请选择其他时间 |
| balance is not enough | 当前假期类型下假期余额不足 |
| certification is required | 需要上传请假证明 |
| arriving late is required | 哺乳假需要填写上班晚到时长 |
| arriving late value is not in the optional items | 晚到时间不在可选范围内 |
| leaving early is required | 哺乳假需要填写下班提前时长 |
| leaving early value is not in the optional items | 下班提前时间不在可选范围内 |
| feeding rest daily is 0 | 哺乳假每日休息时长为0请重新选择 |
| the operation is prohibited by the workforce rules | 当前账户已在假勤侧封账,无法提交
### 加班控件组
**加班控件组请求示例**
```json
{
"id": "widgetWorkGroup",
"type": "workGroup",
"value":[
{
"id":"widgetWorkGroupOvertimeWorkers",
"type":"contact",
"value": ["f8ca557e"],
"open_ids": ["ou_12345"]
},
{
"id": "widgetWorkGroupType",
"type": "radioV2",
"value": "7259635026038505475"
},
{
"id":"widgetWorkGroupTimeRangeFieldList",
"type":"fieldList",
"value":[
[
{
"id":"widgetWorkGroupStartTime",
"type":"date",
"value":"2019-10-01T08:12:01+08:00"
},
{
"id":"widgetWorkGroupEndTime",
"type":"date",
"value":"2019-10-01T08:12:01+08:00"
}
]
]
},
{
"id": "widgetWorkGroupReason",
"type": "textarea",
"value": "111"
}
]
}
```
**加班控件组参数说明:**
参数 | 类型 | 是否必填 | 描述
---|---|---|---
id | string | 是 | 控件组ID固定为widgetWorkGroup
type | string | 是 | 控件组类型固定为workGroup
value | object[] | 是 | 控件组的值,值为多个子控件值的列表
value中包含的子控件值说明:
id | 类型 | JSON示例 | 描述
---|---|---|---
widgetWorkGroupOvertimeWorkers | contact | ```<br>{<br>"id":"widgetWorkGroupOvertimeWorkers",<br>"type":"contact",<br>"value": ["f8ca557e"], <br>"open_ids": ["ou_12345"]<br>}<br>``` | 加班人员列表具体格式可参考联系人控件如果定义中配置「允许代多人提交」则该字段必填如果是提交人给自己提交需填写提交人的ID
widgetWorkGroupType | radioV2 | ```<br>{<br>"id": "widgetWorkGroupType",<br>"type": "radioV2",<br>"value": "7259635026038505475" // 对应的类型选项ID<br>}<br>``` | 加班类型,具体格式可参考单选控件,如果定义中关闭「关联加班规则」则需要填写该字段
widgetWorkGroupTimeRangeFieldList | fieldList | ```<br>{<br>"id":"widgetWorkGroupTimeRangeFieldList",<br>"type":"fieldList",<br>"value":[<br>[<br>{<br>"id":"widgetWorkGroupStartTime",<br>"type":"date",<br>"value":"2019-10-01T08:12:01+08:00"<br>},<br>{<br>"id":"widgetWorkGroupEndTime",<br>"type":"date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>]<br>]<br>}<br>``` | 加班时段具体格式可参考明细控件如果定义中打开「允许提交多个加班时段」则可以传多个最多支持30个否则只会取第一个单次加班时长不可超过两天
widgetWorkGroupReason | textarea | ```<br>{<br>"id": "widgetWorkGroupReason",<br>"type": "textarea",<br>"value": "111"<br>}<br>``` | 加班事由,如果定义中配置了「加班事由」必填,则必须填写该字段
**特殊的参数校验报错信息**
message | 说明 |
| ---------------------------------------------------------------------------------- | ---------------------------- |
| the time range list has more than 30 items | 加班时段数量超过30 |
| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
| overtime type is required | 未关联加班规则时,加班类型必填 |
| work time range is required | 至少需要一个加班时段 |
| start time is after end time | 开始时间晚于结束时间 |
| start time or end time of range is required | 加班时间段的开始时间和结束时间必填 |
| overtime duration is over 2 days | 单次加班时长不可超过两天 |
| overtime date time zone not support | 加班时段的日期时区信息无法识别 |
| {date} can not apply overtime | 所选时间不可申请加班 |
| {date} already apply overtime | 所选时间已经有加班记录 |
| {date} no need approval | 所选日期加班无需申请 |
| apply reason is required | 定义中设置了加班事由为必填,不可为空 |
| {users} user follow different overtime rules, cannot be submitted in the same form | 所选加班人不在同一个考勤组内,无法同时提交加班 |
| invalid overtime work application | 没有有效的加班申请,请重新选择加班日期 |
| the overtime duration cannot be 0 | 加班时长不能是0 |
| the number of apply workers cannot exceed 50 | 单次申请加班人数量不可大于50 |
| apply worker is required | 必须有加班人,配置置可代多人提交时必须指定加班人 |
| resigned worker can not apply | 离职人员不可申请加班 |
| overtime duration is over limit | 加班时长超过限制
### 外出控件组
**外出控件组请求体示例**
```json
{
"id": "widgetOutGroup",
"type": "outGroup",
"value":[
{
"id": "widgetOutGroupType",
"type": "radioV2",
"value": "me15yqrf-gmjgbml2vhp-0"
},
{
"id": "widgetOutGroupStartTime",
"type": "date",
"value":"2019-10-01T08:12:01+08:00"
},
{
"id": "widgetOutGroupEndTime",
"type": "date",
"value":"2019-10-01T08:12:01+08:00"
},
{
"id": "widgetOutGroupReason",
"type": "textarea",
"value":"123213"
},
{
"id":"widgetOutGroupImage",
"type":"image",
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]
}
]
}
```
**外出控件参数说明**
参数 | 类型 | 是否必填 | 描述
---|---|---|---
id | string | 是 | 控件组ID固定为widgetOutGroup
type | string | 是 | 控件组Type固定为outGroup
value | object[] | 是 | 控件组的值,值为多个子控件值的列表
value中包含的子控件值说明:
id | 类型 | JSON示例 | 描述
---|---|---|---
widgetOutGroupType | radioV2 | ```<br>{<br>"id": "widgetOutGroupType",<br>"type": "radioV2",<br>"value": "me15yqrf-gmjgbml2vhp-0" <br>}<br>``` | 外出类型,具体格式可参考单选控件,如果配置了「外出类型」则必填,外出时长单位会选取所选外出类型关联的单位,如果没有配置「外出类型」,则该字段无需填写,计算外出时长时会选取「外出时长」配置的单位
widgetOutGroupStartTime | date | ```<br>{<br>"id": "widgetOutGroupStartTime",<br>"type": "date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>``` | 外出开始时间具体格式可参考日期控件如果外出时长单位是半天假则小于12点则认为是上午否则认为是下午如果单位是小时则会按半小时的粒度向前取整
widgetOutGroupEndTime | date | ```<br>{<br>"id": "widgetOutGroupEndTime",<br>"type": "date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>``` | 外出结束时间具体格式可参考日期控件如果外出时长单位是半天假则小于12点则认为是上午否则认为是下午如果单位是小时则会按半小时的粒度向后取整
widgetOutGroupReason | textarea | ```<br>{<br>"id": "widgetOutGroupReason",<br>"type": "textarea",<br>"value":"123213"<br>}<br>``` | 外出事由,具体格式可参考多行文本控件,如果定义中「外出事由」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件
widgetOutGroupImage | image | ```<br>{<br>"id":"widgetOutGroupImage",<br>"type":"image",<br>"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]<br>} <br>``` | 外出证明,具体格式可参考图片控件,如果定义中「外出拍照」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件
**特殊的参数校验报错信息**
message | 说明 |
| ----------------------------------------------------- | ---------------------------- |
| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* |
| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* |
| start time and end time must be in the same time zone | 开始时间与结束时间必须是同一时区 |
| out type is required | 如果定义中设定了「外出类型」,则外出类型必填 |
| out start time is required | 外出开始时间必填 |
| out end time is required | 外出结束时间必填 |
| out duration must be greater than 0 | 外出间隔不能为0请检查起止时间并重新选择 |
| out reason is empty | 如果定义中勾选「外出事由」同时设定必填,则该字段必填 |
| photo is required | 如果定义中勾选「外出拍照」同时设定必填,则该字段必填 |
| out time is conflict | 外出时间有冲突,请确认是否已在该时段申请外出

View File

@@ -0,0 +1,108 @@
# 审批提单值来源
## 目的
本文用于回答一个固定问题:在调用 `approval instances create` 发起原生审批实例时,**每个要填写的值从哪里拿**。
阅读顺序固定如下:
1. `lark-cli schema approval.instances.create`
2. `approval approvals get` 返回的 `form` / `node_list`
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
4. 本文
## 总原则
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
- `approvals.get.form` 决定控件 `id``type`、选项值范围、子控件结构。
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
- 除非本文明确允许,否则不要猜值来源,不要把展示文案直接当成可提交值。
## 默认来源
- 审批定义、`approval_code``is_external``create_link` 等基础信息,默认从 `approval approvals search` 获取。
- 控件 `id``type`、选项值、子控件结构,默认从 `approval approvals get.form` 获取。
- 节点 key、`need_approver``approver_chosen_multi` 等节点信息,默认从 `approval approvals get.node_list` 获取。
- 本文只补充 **这些默认来源之外** 的取值规则,以及当前必须由用户直接提供的值。
## 控件值来源规则
### 联系人 `contact`
- 只推荐写 `open_ids`
- 不再推荐双写 `value(user_id)` + `open_ids`,避免复杂度继续上升。
- 如果用户给的是姓名、邮箱或账号,先用 `lark-contact` 解析成 `open_id`
### 部门 `department`
- 最优先:用户直接提供 `open_department_id`
- 若用户说“我的部门”或“张三的部门”,先用 `lark-contact` 查询对应人员信息,再取其所属部门里的 `open_department_id`
- 如果查到该人员只有一个部门,可直接使用。
- 如果查到多个部门,不自动猜,必须让用户明确选一个,或直接输入 `open_department_id`
- 如果仍无法确定,则明确告知当前不支持自动决定部门值。
### 附件 `attachmentV2`
- 当前 `lark-approval` 不负责上传文件。
- 用户必须直接提供 file code。
- 如果用户无法提供 file code应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。
### 图片 `image` / `imageV2`
- 当前 `lark-approval` 不负责上传图片。
- 用户必须直接提供 file code。
- 如果用户无法提供 file code应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。
### 文档 `document`
- 用户可直接提供 `token` / `document_id`
- 如果用户给的是飞书文档链接,应先尝试从链接中提取 token。
- 若链接提取失败,再要求用户手动输入 token。
### 关联审批 `connect`
- 用户直接提供目标审批实例的 `instance_code`
- 当前不默认做“搜索关联实例再反查 code”的自动流程。
### 地址 `address`
- 用户直接提供地理库 `id`
- 若用户无法提供该 `id`,当前不支持自动取值。
## 特殊控件组
以下控件组的结构仍按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装:
- `leaveGroupV2`
- `workGroup`
- `outGroup`
- `shiftGroup`
补充规则:
- 控件组自身和子控件的 `id` / `type``approval approvals get.form` 中识别。
- 组内单选/多选或业务枚举值,优先从 `approval approvals get.form` 返回的选项结构中取。
- 不要把控件组整体当成普通字符串或扁平对象提交。
## 不支持自动准备的值
以下值当前不建议由 `lark-approval` 自动准备:
- 文件上传后的 file code
- 图片上传后的 file code
- 地址控件的地理库 `id`
- 无法唯一确定的部门 `open_department_id`
遇到这类值时,应明确告诉用户需要提供什么,而不是继续猜测。
## 最小决策表
| 场景 | 处理 |
|---|---|
| 用户说“找张三当审批人” | 用 `lark-contact` 解析张三,取 `open_id` |
| 用户说“我的部门” | 先查当前用户部门;若多个部门,让用户选 |
| 用户给了文档链接 | 先尝试提取 token |
| 用户要填图片/附件 | 要求直接提供 file code |
| 用户要填关联审批 | 要求直接提供 `instance_code` |
| 用户要填地址 | 要求直接提供地理库 `id` |

View File

@@ -26,8 +26,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch --api-version v2`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md)和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**

View File

@@ -2,9 +2,8 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误。**
@@ -16,16 +15,10 @@
```bash
# 创建 XML 文档(默认格式,推荐)
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><ul><li>目标 1</li><li>目标 2</li></ul>'
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
# 创建到指定文件夹XML
lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<title>标题</title><p>首段内容</p>'
# 创建到个人知识库XML
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
# 仅当用户明确要求时才使用 Markdown文档标题必须是开头唯一的一级标题正文从二级标题开始
lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title正文标题按内容自然组织
lark-cli docs +create --api-version v2 --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
```
## 返回值
@@ -36,9 +29,9 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXXXXXXXXXXXXXXXXX",
"document_id": "docx_token",
"revision_id": 1,
"url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX",
"url": "https://xxx.feishu.cn/docx/docx_token",
"new_blocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" }
]
@@ -66,15 +59,15 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
| 参数 | 必填 | 说明 |
| ------------------- | -- |---------------------------------------------|
| `--api-version` | 是 | 固定传 `v2` |
| `--content` | | 文档内容XML 或 Markdown 格式) |
| `--title` | | 文档标题Markdown 导入时使用XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
| `--content` | 视情况 | 文档内容XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--parent-token` | 否 | 父文件夹或知识库节点 token`--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
## 最佳实践
- 文档标题从内容中自动提取XML 使用 `<title>`Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
## 参考

View File

@@ -99,7 +99,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|------|------|------|
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token支持 `/docx/``/wiki/` |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |

View File

@@ -2,10 +2,6 @@
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。
## 创建文档标题
使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`
## 转义规则
> **⚠️ 当文本中包含以下字符且不想触发 Markdown 语法时**,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。

View File

@@ -3,9 +3,8 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误。**

View File

@@ -10,10 +10,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
## 创建文档标题
使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题</title>`,且每篇文档只写一个 `<title>`
## 容器标签
|标签|说明|关键属性|
|-|-|-|

View File

@@ -29,7 +29,7 @@
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
- `lark-doc-xml.md` 的完整路径Agent 须先读取);仅在需要使用富 block 或用户要求美化时提供 `lark-doc-style.md`
- `lark-doc-xml.md` `lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
### 步骤三:整合审查与画板识别(串行)
@@ -50,7 +50,7 @@
## Agent 子任务要求
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。

View File

@@ -44,7 +44,7 @@
## Agent 子任务要求
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。

View File

@@ -25,7 +25,7 @@ metadata:
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
- 用户要把本地 `.pptx` / `.pdf` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX/PDF 导入上限是 500MB。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
@@ -144,6 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+task_result`](references/lark-drive-task-result.md) | 查询 import/export/move/delete 等异步任务结果。 |
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical tokenwiki URL 会自动解包到底层文档。 |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create真实写入需要 `--yes`。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |

View File

@@ -2,7 +2,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF导入并转换为飞书在线云文档docx、sheet、bitable、slides。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`
> [!IMPORTANT]
> 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。
@@ -45,8 +45,9 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"
# 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB
lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原"
# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB
# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB
lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报"
lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报"
# 导入到指定文件夹,并指定导入后的文件名
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
@@ -94,6 +95,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
| `.base` | `bitable` | 多维表格快照文件 |
| `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 |
| `.pdf` | `slides` | PDF 文档 |
> [!IMPORTANT]
> 用户口头说的 “Base” / “多维表格” / “bitable”在命令里统一对应 `--type bitable`。
@@ -103,7 +105,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
> - `.xls` 文件**只能**导入为 `sheet`
> - `.base` 文件**只能**导入为 `bitable`
> - `.pptx` 文件**只能**导入为 `slides`
> - `.pptx` / `.pdf` 文件**只能**导入为 `slides`
> - 例如:`.csv` 文件不能导入为 `docx``.md` 文件不能导入为 `sheet`
> [!IMPORTANT]
@@ -137,7 +139,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
| `.csv` | `bitable` | 100MB |
| `.xls` | `sheet` | 20MB |
| `.base` | `bitable` | 20MB |
| `.pptx` | `slides` | 500MB |
| `.pptx`, `.pdf` | `slides` | 500MB |
- 如果文件超出对应上限shortcut 会在真正上传前直接返回验证错误。
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart不代表所有格式都允许导入超过 20MB 的文件。

View File

@@ -0,0 +1,66 @@
# drive +member-add添加协作者/授权成员权限)
> 这是高风险写操作。真实执行会修改文档权限,需要显式加 `--yes`
## 命令
```bash
# 批量添加(同一 member-type 和 perm最多 10 人)
lark-cli drive +member-add \
--token "<bare_token_or_url>" \
--type bitable \
--member-id "ou_a,ou_b" \
--member-type openid \
--perm view \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--token` | 是 | 裸 token 或完整 URL。路径支持 `/drive/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`URL 输入可从路径推断 `--type`,裸 token 不做前缀推断 |
| `--type` | 必填 | 目标资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。传 URL 时可省略;裸 token 必须显式传;若同时传 URL 和 `--type`,显式 `--type` 覆盖 URL 推断 |
| `--member-id` | 是 | 协作者 ID逗号分隔可批量添加最多 10 个 |
| `--member-type` | 是 | member-id 的类型;支持 `email` / `openid` / `unionid` / `openchat` / `opendepartmentid` / `groupid` / `appid` / `wikispaceid`。在实际使用里,给当前应用授权仍优先推荐 bot `open_id` + `openid`。 |
| `--member-kind` | 条件必填 | 仅当 `--member-type=wikispaceid` 时填写,映射到请求 body 的 `type` 字段。取值:`wiki_space_member` / `wiki_space_viewer` / `wiki_space_editor`。其他 member-type 禁止传此参数。 |
| `--perm` | 否 | 授权角色:`view`(默认)/ `edit` / `full_access` |
| `--perm-type` | 否 | 只作用 wiki 节点权限范围:`container`(默认,当前页面+子页面)/ `single_page`(仅当前页面) |
| `--need-notification` | 否 | 是否通知对方。仅 `--as user` 可用;未传时不会写入 query`--need-notification=false` 表示显式不通知 |
| `--dry-run` | 否 | 仅打印请求,不实际授权 |
| `--yes` | 真实执行时是 | 确认高风险写操作 |
## 输出
批量成功:
```json
{
"ok": true,
"identity": "user",
"data": {
"resource_token": "doc_token_or_url",
"resource_type": "docx",
"requested_count": 2,
"succeeded_count": 2,
"partial": false,
"members": [
{"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_a", "member_type": "openid", "member_kind": "user", "perm": "view"},
{"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_b", "member_type": "openid", "member_kind": "user", "perm": "view"}
],
"missing_member_ids": []
}
}
```
批量部分失败时,`partial``true`CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
## 行为说明
- **身份支持**`--as user``--as bot` 均可使用。
- **部门协作者**`--member-type=opendepartmentid` 必须配合 `--as user`bot 身份不支持添加部门协作者。
- **通知**`--need-notification``--as user` 时有效;`--as bot` 时传此参数会被拒绝。
- **批量约束**:批量请求共享同一 `--member-type``--perm``--perm-type`;混合用户/群组/部门的场景需拆分为多次调用。
- **Wiki 空间 ID**`--member-type=wikispaceid` 时必须同时传 `--member-kind`,否则 API 会缺少必填的 body `type` 字段。`wiki_space_member` 对应知识库成员角色;若知识库已将成员拆分为可阅读/可编辑成员组,改用 `wiki_space_viewer``wiki_space_editor`
- **ID 解析**:优先用 `open_id` + `--member-type openid`;仅在无法解析 `open_id` 时使用 `email`。群组优先用 `openchat`,部门用 `opendepartmentid`

View File

@@ -149,6 +149,12 @@ lark-cli im <resource> <method> [flags] # 调用 API
- `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
- `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
### chat.nickname
- `get` — 获取自己的群昵称。Get your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`); returns an empty string when no nickname is set.
- `update` — 设置自己的群昵称。Set or update your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`); `nickname` must be a non-empty string (max 300 bytes). Use DELETE to clear it.
- `delete` — 清空自己的群昵称。Clear your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`).
### chat.managers
- `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用;当用户给定 PPTX/PDF/existing Slides 作为模板、底稿或二创对象时,也用本 skill 统筹导入后的二次创作(导入命令本身走 `lark-drive``drive +import --type slides`。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责云文档内容编辑走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive。"
metadata:
requires:
bins: ["lark-cli"]
@@ -14,8 +14,8 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 新建 PPT、从空白生成、明确重设计 | 走 Create Workflow先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 用户提供 PPTX/PDF/slides、existing Slides、模板/底稿/原 PPT 二创 | 走 Template Rewrite Workflow导入/回读成 `source.xml`,从源 XML 生成 replacement slides`pages.json` 执行 `+replace-pages`,再保存 `readback.xml` 验证 | `template-rewrite-workflow.md``xml_presentations.get``lark-slides-replace-pages.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot``lark-slides-screenshot.md` |
@@ -23,31 +23,37 @@ metadata:
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 用户提到模板、主题、版式但没有提供本地/在线模板材料 | 先检索内置模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — Create Workflow新建演示文稿、从空白生成、用户明确要求重设计、没有模板保留诉求MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。**
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — Create Workflow 生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — Create Workflow 规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)**
**CRITICAL — Template Rewrite Workflow用户提供 PPTX/PDF/slides、existing Slides、要求基于模板/底稿/原 PPT 二创或保留原版式MUST 读取 [template-rewrite-workflow.md](references/template-rewrite-workflow.md)。不要读取 `planning-layer.md`、`visual-planning.md`、`asset-planning.md` 来生成二创 plan不要生成 `slide_plan.json`、`page_rewrite_plan.json`、`rewrite_manifest.json`。固定数据流是 `source.xml -> pages.json -> slides +replace-pages -> readback.xml validation`**
**CRITICAL — Template Rewrite 不允许用 `python-pptx` / PowerPoint 自动化清空模板页后从 blank layout 重画,也不允许生成一个只继承模板尺寸/主题色的本地 PPTX 再导入作为最终产物。模板二创必须以 `source.xml` 的每页真实 XML 为骨架;如果 `source.xml` 不可得,停止并说明该工作流被阻塞,不能伪装成模板二创。**
**CRITICAL — Template Rewrite 必须做 source-connected rewrite新内容要进入源页已有 text container、图形标签、节点、箭头、时间线、图表/table 或注释容器。不要把模板当背景再覆盖通用顶栏、三卡片、2x2 卡片、大白卡或重复组件系统;源页 dominant structure 必须继续承载内容。**
**CRITICAL — 创建、Template Rewrite 或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等)MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,但没有提供本地/在线模板材料MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做内置模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
> [!NOTE]
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
**CRITICAL — 使用内置模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。用户提供真实 PPTX/PDF/slides/existing Slides 时不要走内置模板工具,走 Template Rewrite Workflow。**
**编辑已有幻灯片页面**优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序)选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
**编辑已有幻灯片页面**模板二创、页面级重写、导入底稿改写优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内重建页面,避免 `slides +create` 生成新链接;单个标题、文本块、图片或局部元素才用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序)选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
## 身份选择
@@ -74,24 +80,77 @@ lark-cli auth login --domain slides
高频只读:
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
- [planning-layer.md](references/planning-layer.md)新建 / 大幅改写
- [visual-planning.md](references/visual-planning.md)新建 / 大幅改写
- [asset-planning.md](references/asset-planning.md)新建 / 大幅改写
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后
- [planning-layer.md](references/planning-layer.md)Create Workflow新建 / 从空白生成 / 明确重设计
- [visual-planning.md](references/visual-planning.md)Create Workflow
- [asset-planning.md](references/asset-planning.md)Create Workflow
- [template-rewrite-workflow.md](references/template-rewrite-workflow.md)Template Rewrite WorkflowPPTX/PDF/slides/existing Slides 二创
- [validation-checklist.md](references/validation-checklist.md)(创建 / Template Rewrite / 大幅改写后)
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
- 模板[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 内置模板(仅无用户材料时)[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
## Workflow
## Workflow Routing
### A. Create Workflow
适用:
- 新建 PPT。
- 从空白生成。
- 用户明确要求重新设计。
- 没有模板保留诉求。
路由:
- 读取 `planning-layer.md`
- 可读取 `visual-planning.md`
- 可读取 `asset-planning.md`
- 生成 `slide_plan.json`
- 使用 `slides +create` 或对应创建流程。
### B. Template Rewrite Workflow
适用:
- 用户上传 PPTX / PDF / slides。
- 用户给 existing Slides。
- 用户说“基于这个模板”。
- 用户说“保留原版式”。
- 用户说“根据这个底稿生成”。
- 用户说“二次创作 / 改写这个 PPT”。
路由:
- 读取 `template-rewrite-workflow.md`
- 不读取 `planning-layer.md`
- 不读取 `visual-planning.md`
- 不读取 `asset-planning.md`
- 不生成 `slide_plan.json`
- 不生成任何 rewrite plan / manifest。
- 固定执行import/readback -> `source.xml` -> `pages.json` -> `replace-pages` -> readback validation。
- 不用 `python-pptx` 清空模板页、`add_slide(blank)` 重画,或导入新生成的本地 PPTX 作为最终产物。
- 逐页从源页骨架向外改写,把新内容贴回源页已有容器和视觉节点;不能用一套通用卡片层覆盖模板结构。
### C. Switch Back To Create Workflow
只有用户明确表达以下意图,才允许从 Template Rewrite 切回 Create Workflow
- “不要保留模板素材”
- “只参考风格重做”
- “重新设计整套 PPT”
- “原模板只是灵感”
- “完全换一种版式”
## Create Workflow
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
@@ -99,7 +158,7 @@ lark-cli auth login --domain slides
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
以下设计规划只适用于 Create Workflow。开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重1-2 个辅助色承担结构和分区1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
@@ -133,7 +192,7 @@ lark-cli auth login --domain slides
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
### Create Workflow 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
@@ -147,9 +206,9 @@ lark-cli auth login --domain slides
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
### 模板与脚本优先流程
### 内置模板与脚本优先流程
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
仅在用户没有提供本地/在线模板材料时使用内置模板流程。模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
```bash
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
@@ -159,12 +218,12 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
- 澄清主题、受众、页数、风格;没有用户提供模板材料时,模板需求按“内置模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.mdCreate Workflow 还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- 生成结构化大纲供用户确认;如使用内置模板,标明基于哪个模板改写
- Create Workflow 必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML → 创建
@@ -174,7 +233,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 失败或部分成功按 troubleshooting.md 处理;局部问题用 `+replace-slide` 修正,页面级问题用对应页面流程修正
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
@@ -268,6 +327,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建一个或多个页面:先创建新页到旧页前,再删除旧页;适合模板二创、页面级重写和素材保留,不新建链接 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
@@ -280,13 +340,17 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**必须`<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
1. **先判定工作流**:新建/空白生成/明确重设计走 Create WorkflowPPTX/PDF/slides/existing Slides 模板二创走 Template Rewrite Workflow。不要把二创塞回 `slide_plan.json` 工作流。
2. **Create Workflow 先规划再写 XML**:必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;内置模板、风格和大纲只能作为规划输入,不能绕过规划层。
3. **Template Rewrite 不生成 plan artifact**:唯一事实源是 `source.xml`,唯一执行输入是 `pages.json`,替换后用 `readback.xml` 验证。默认 preserve source, replace content, local adjustment only。
4. **Template Rewrite 不能本地清空重画**禁止`python-pptx` 删除模板页、从 blank layout 重建、只借用尺寸/主题色后生成本地 PPTX 再导入。`source.xml` 不可得时停止,不要伪装成模板二创。
5. **Template Rewrite 不能通用卡片覆盖模板**:新内容必须映射到源页已有文本框、图形标签、节点、箭头、时间线、图表/table 或注释容器。源页 dominant structure 仍要承担表达,不允许只把它留在背景里。
6. **创建流程**简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
7. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
8. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
9. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
10. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
11. **编辑已有页面优先原链接更新**:模板二创、页面级重写、素材保留用 `+replace-pages`;修改单个 shape/img/text block 才用 `+replace-slide``block_replace` / `block_insert`)。不要用 `slides +create` 新建脱离模板的 deck。
12. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果 `file_token` 来自同一个 `xml_presentation_id` 的旧页,可以在 Template Rewrite 的新页 XML 中直接复用;如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -1,9 +1,11 @@
# Asset Planning
新建演示文稿大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
本文件默认只供 Create Workflow / `planning-layer.md` 使用。新建演示文稿、从零大幅改写或用户明确要求重设计时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
模板二创不要为了 `asset_need` 重新规划或替换旧素材。模板二创中的旧素材、旧容器、旧样式以 `source.xml` 为准,由 `template-rewrite-workflow.md` 处理。
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
@@ -12,6 +14,8 @@
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
- In Template Rewrite Workflow, `fallback_if_missing` cannot cover, replace, or obscure existing source img/table/chart/whiteboard/shape/motif/text containers.
- In Template Rewrite Workflow, reference fallback ideas only when the source page has no usable carrying area and a new visual element is truly required.
## JSON Shape

View File

@@ -1,6 +1,8 @@
# 编辑已有 PPT读-改-写闭环
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
模板/底稿/PPTX/PDF/existing Slides 二创必须先读 [`template-rewrite-workflow.md`](template-rewrite-workflow.md):以 `source.xml` 为源页骨架生成 replacement slide不允许用 `python-pptx` 清空模板页、从 blank layout 重画、再导入新本地 PPTX 作为最终产物;也不允许把模板当背景后覆盖一套通用卡片层。
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
@@ -11,6 +13,7 @@
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement``id` 由 CLI 自动注入为 `block_id` |
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace``block_insert` 可混用 |
| 模板二创、页面级改写、整页坐标调整 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old模板场景必须从源页 XML 骨架向外改,把新内容贴回源页已有容器、节点、箭头、时间线、图表/table 或注释,不生成新 Slides 链接 |
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
@@ -136,6 +139,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
## 相关文档
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token

View File

@@ -0,0 +1,100 @@
# slides +replace-pages多页整页重建
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合模板二创、页面级改写、坐标调整和素材保留;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
> 重要这是多步编排不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
> 模板二创重要边界:`+replace-pages` 消费完整 replacement slide XML但这个 XML 应以 `source.xml` 的源页结构为骨架。不要用 `python-pptx` 清空模板页、从 blank layout 重画、生成新本地 PPTX 再导入来替代本命令;也不要把模板当背景,再覆盖一套通用卡片系统。
## 命令
```bash
lark-cli slides +replace-pages \
--as user \
--presentation <slides_url_or_xml_presentation_id> \
--pages @pages.json
```
## 参数
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id``/slides/` URL 或 `/wiki/` URL |
| `--pages` | 是 | JSON 数组,每项包含 `slide_id``content`;支持 literal、`@file`、stdin `-` |
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
## pages.json
```json
[
{
"slide_id": "slide_short_id_1",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
},
{
"slide_id": "slide_short_id_2",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
}
]
```
规则:
- 每项必须提供 `slide_id`;不支持 `slide_number`
- `content` 必须是完整 `<slide>...</slide>` XML。
- 模板二创时,`content` 应复用源页 `<style>``<img src>`、chart/table/whiteboard、shape、line/icon、文本容器等结构只替换必要文本或局部元素。
- 模板二创时,新内容应贴回源页已有 text container、图形标签、节点、箭头、时间线、chart/table 或注释容器;源页的 dominant structure 不能只留作背景装饰。
- 同一批次不能重复 `slide_id`
- CLI 不会回读整份 presentation如果 `slide_id` 已失效create/delete 阶段会返回对应错误。
## Dry Run
```bash
lark-cli slides +replace-pages --as user \
--presentation "$PID" \
--pages @pages.json \
--dry-run
```
输出包含 `xml_presentation_id``pages_count``plan`,以及每页的 `old_slide_id``insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
## 成功输出
```json
{
"xml_presentation_id": "xxx",
"pages_count": 2,
"status": "completed",
"summary": {
"replaced": 2,
"failed": 0,
"total": 2
},
"results": [
{
"old_slide_id": "old3",
"new_slide_id": "new3",
"status": "replaced"
}
],
"revision_id": 123
}
```
如果使用 `--continue-on-error` 且任一页面失败CLI 会继续处理后续页,但最终以 partial failure 非零退出stdout 仍保留完整 `results`,顶层 `ok``false``status``partial_failure`
`status` 可能为:
- `replaced`:新页创建成功,旧页删除成功。
- `create_failed`:新页创建失败,旧页保留。
- `delete_failed`:新页已创建,但旧页删除失败。
## 使用建议
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML并记录要替换页面的 `slide_id`
2. 生成只含 `slide_id``pages.json` 后先跑 `--dry-run``--validate-only`
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
4. 模板二创不要把源页改成通用两卡、三卡、2x2 卡片,也不要用大白卡或大色块覆盖模板主体素材。源页如果有箭头、节点、时间线、图表、表格、几何结构、设备图或人物图,优先替换这些结构上的标签、数字和注释。
5. 替换后再回读全文 XML并按 `validation-checklist.md` 对比 `source.xml``readback.xml`;需要确认视觉一致性时,用 `slides +screenshot` 抽查封面、典型内容页、复杂结构页和结尾页。

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径
## 命令

View File

@@ -1,13 +1,15 @@
# Planning Layer
新建演示文稿大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
本文件只适用于 Create Workflow新建演示文稿、从零大幅改写、用户明确要求重设计,或没有模板保留诉求的场景。进入本工作流时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构仍然需要规划层
如果用户提供 PPTX/PDF/slides 作为模板、底稿或二创对象,或要求保留原版式/素材/结构,请走 `template-rewrite-workflow.md`。不要在 `slide_plan.json` 工作流中处理模板二创
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。模板二创也不使用本规划层;它以 `source.xml` 为事实源、`pages.json` 为执行输入。
## Required Flow
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
2. 如果没有用户提供本地/在线模板材料且适合内置模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
@@ -15,7 +17,9 @@
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML`layout_type``visual_focus``text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
内置模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
如果用户提供 PPTX/PDF/slides 作为模板、底稿或二创对象,请走 `references/template-rewrite-workflow.md`。不要在本工作流中复制 `source.xml` 的素材清单、bbox、层级或样式也不要生成 `page_rewrite_plan.json` / `rewrite_manifest.json`
## Plan Path
@@ -24,7 +28,7 @@ Use a separate plan directory per deck or task so multiple presentations in the
Recommended IDs:
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
- Existing PPT rewrite: the `xml_presentation_id`.
- Existing PPT redesign after the user explicitly abandons template preservation: the `xml_presentation_id` plus a short redesign slug.
- Ambiguous or untitled task: short task slug plus date/time.
Rules:
@@ -40,7 +44,7 @@ Rules:
Keep:
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
- A small creation status note when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status. Do not create a rewrite manifest for Template Rewrite Workflow.
Clean or avoid keeping:

View File

@@ -150,7 +150,7 @@
<xs:simpleType name="FontFamilyType">
<xs:annotation>
<xs:documentation>
字体族名称, 支持任意字体。
字体族名称, 支持下列字体。
常用中文字体:
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、

View File

@@ -0,0 +1,218 @@
# Template Rewrite Workflow
本工作流只服务基于真实模板或底稿的二次创作。适用场景:
- 用户上传 PPTX / PDF / slides。
- 用户给 existing Slides。
- 用户说“基于这个模板生成”。
- 用户说“保留这个版式 / 底稿 / 原 PPT / 模板风格和结构”。
- 用户要对已有 PPT 做二次创作、改写、替换内容。
禁止默认行为:
- 不默认走 `planning-layer.md`
- 不默认读取 `visual-planning.md`
- 不默认读取 `asset-planning.md`
- 不生成 `slide_plan.json`
- 不生成 `page_rewrite_plan.json`
- 不生成 `rewrite_manifest.json`
- 不默认用 `slides +create` 新建脱离模板的 deck。
- 不通过 `python-pptx` / PowerPoint 自动化把模板页全部删除后从空白 layout 重画。
- 不生成一个“借用模板尺寸/主题色”的本地 PPTX 再导入,并声称它是模板二创。
- 不把模板页当成背景板再在上面覆盖一套通用标题栏、两卡、三卡、2x2 卡片或大白卡系统。
- 不把每页内容粘贴进一套重复组件,而让源页的箭头、节点、时间线、图形、图表、留白关系失去表达作用。
模板二创的数据流固定为:
```text
source.xml
-> generate replacement slide XML from each source slide skeleton
-> pages.json
-> slides +replace-pages
-> readback.xml validation
```
## 1. Import / Readback
- PPTX 必须先导入为 Slides。导入命令本身走 `lark-drive``drive +import --type slides`,但导入后的二创由本工作流负责。
- PDF 如果作为模板、底稿、原 PPT 或视觉参考使用,也先导入为 Slides只有明显是长文档资料而非演示稿时才不进入本工作流。
- Existing Slides 用 `xml_presentations.get` 回读。
- 保存回读 XML 为:
```text
.lark-slides/plan/<xml_presentation_id>/source.xml
```
如果无法得到 `source.xml`Template Rewrite Workflow 不能继续。不要退化为:
-`python-pptx` 打开模板后删除所有原 slides。
- `prs.part.drop_rel(...)` / `del prs.slides._sldIdLst[...]` 清空模板页。
-`prs.slide_layouts[...]` 或 blank layout 重新 `add_slide(...)`
- 只保留画布尺寸、少量主题色、少量母版占位符后重画整套内容页。
- 输出一个新的本地 PPTX再用 `drive +import --type slides` 当最终产物。
正确处理是:停止 Template Rewrite说明 `source.xml` 不可用,并让用户选择导入失败排障、只交付原导入 deck或明确切换到 Create Workflow / 只参考风格重做。
## 2. Treat source.xml As Truth
`source.xml` 是唯一布局和素材事实源。
- 不要把 `source.xml` 里的素材 token、bbox、层级、样式再复制到新的 plan 文件。
- 不要让模型手写素材清单来替代 `source.xml`
- 所有保留判断以 `source.xml` 为准。
- 不要用 `layout_type``visual_focus``visual_system` 来驱动模板二创。
- 可以在上下文中临时分析每页的源结构,但不要把它保存成新的 JSON / Markdown plan artifact。
## 3. Rewrite From Source Outward
以源页 XML 为骨架生成 replacement slide。默认顺序
1. 先复制源页的 `<style>`
2. 复制源页的 `<img src="...">``<chart>``<table>``<whiteboard>`
3. 复制 recurring shapes / motifs、line / icon / separator、card container、reusable text container。
4. 识别源页中承载表达的 dominant structure例如箭头流、节点关系、时间线、漏斗、三角形、圆环、曲线、坐标/表格、左右对照、设备图、人物/场景分组。
5. 把新内容映射到源页已有文本容器、图形标签、数字标签、节点标签或图表/table 数据上。
6. 替换旧文案所在文本容器里的 `<content>`
7. 最后只在必要时添加局部新元素。
不要把源页改成通用两卡、三卡、2x2 卡片。不要把“保留模板”简化成“保留背景图 + 重新画业务卡片”。
生成 replacement slide 时,页面级结构必须来自源页 XML。可以替换或缩短文字、更新图表数据、局部补充元素不能把源页删除后按自定义 `rect()` / `circle()` / `line()` / `add_text()` helper 重新搭一套卡片、流程、指标版式。
### Source-Connected Rewrite
每一页必须先在工作上下文中做源页结构判断。这个判断不是新 artifact不写入文件它只用于约束生成
- page role封面、目录、过渡、数据页、流程页、对比页、总结页等。
- dominant source structure源页最主要的视觉结构例如图、表、箭头、节点、时间线、几何结构、产品图、人物图、设备图、曲线或对比版式。
- content-bearing containers真正承载文字和数字的源文本框、图形标签、图表标签、表格单元格。
- source visual hierarchy标题、核心结论、主视觉、支撑信息、脚注的原始层级。
- safe insertion zones只有在源页没有合适容器且用户内容必须出现时才可使用的局部空白区域。
生成 replacement slide 时必须满足:
1. 新文案优先进入已有 text container、图形标签、节点标签、数字标签、表格单元格或 chart labels / data。
2. 如果源页有三角形、箭头、节点、时间线、曲线、地图、设备图、产品图或人物分组,新内容必须贴到这些源结构的对应标签/节点/注释上,而不是覆盖一组三张新卡片。
3. 如果源页是数据图形页,优先更新原图表、数字标签、曲线节点、坐标标签和注释;不要另造一个白色数据卡片区遮住原图。
4. 如果源页是流程/关系页,优先替换每个步骤、箭头、节点、关系说明;不要把流程压在背景里,再另起 bullet 卡片。
5. 如果源页是封面或章节页保留原图片、标题容器、logo / slogan / 装饰关系;不要把标题挪进不相干的新色块。
6. 如果原文本容器空间不足,先缩短文案、降低层级、拆到邻近源容器或用源页已有注释容器承载;不要默认新增大卡片。
7. 新增元素只能补足源结构的局部空缺,不能成为覆盖源结构的主版式。
8. 多页之间应保留源模板原本的页型差异;不要把整套 deck 归一成同一套顶栏 + 三卡片。
### Source-Connectedness Gate
生成 `pages.json` 前,对每个 replacement slide 做一次失败门检查。出现以下任一情况,必须重写该页:
- 页面主体内容主要落在新增 shape/card 中,而不是源页已有容器或源结构节点里。
- 源页的箭头、节点、时间线、图形、图表、设备图、人物图仍在,但已经只是背景装饰,没有承载新内容。
- 新增卡片、白板、大色块或信息面板覆盖了源页 dominant structure。
- 多个源页被改成同一套顶栏、三卡、2x2 卡片或大段 bullet 容器。
- 页内关键源容器还在,但其 bbox、层级、字号、颜色、对齐关系被无理由改写。
- 源页明明有图文关系、箭头关系或坐标关系,却把内容独立堆放到空白区域,导致互相错位或遮挡。
## 4. Generate pages.json
`pages.json` 是唯一执行输入。结构只保留 `slides +replace-pages` 需要的字段:
```json
[
{
"slide_id": "<old slide id>",
"content": "<full replacement slide XML>"
}
]
```
不要把 planning metadata 放进 `pages.json`
## 5. Execute replace-pages
- 默认用 `slides +replace-pages`
- `replace-pages` 消费 `pages.json`,不消费 `slide_plan.json`
- `replace-slide` 只用于小型块级编辑,例如改一个标题、插入一个图、替换已知 block。
## 6. Readback Validation
替换后必须用 `xml_presentations.get` 回读,保存为:
```text
.lark-slides/plan/<xml_presentation_id>/readback.xml
```
`readback.xml``source.xml` 对比验证模板结构没有被破坏。验证细则见 `validation-checklist.md` 的 Template Rewrite validation 小节。
## Preservation Rules
除非用户明确要求重设计,否则模板二创必须:
- preserve source layout
- preserve source assets
- preserve source style
- preserve source text containers
- preserve source visual hierarchy
- replace content only
- local adjustment only
具体规则:
1. `<style>` 默认保留。
2. `<img src="...">` 默认保留尤其是背景图、截图、装饰图、产品图、logo、模板视觉。
3. 同一个 `xml_presentation_id` 内复用 `<img src>` 时,直接复制原 `src` / token不要重新上传不要替换成外部 URL。
4. `<chart>` / `<table>` 默认保留;除非用户要求更新数据,才改 labels / data。
5. `<whiteboard>` 默认保留其位置和外层结构;注意 readback XML 未必包含内部 SVG / Mermaid。
6. shape / line / icon / separator / card container / motif 默认保留。
7. 旧文案所在 text container 默认保留 bbox、layer、textType、fontFamily、fontSize、color、alignment只替换 `<content>`
8. 如果源页已有卡片容器,优先复用源容器。
9. 如果源页已有图文结构,优先替换原文本。
10. 如果必须新增元素,新增元素必须局部且不破坏源页主要视觉结构。
11. 不允许以“模板文件只是内容容器”为由清空原页;模板页本身就是必须保留的设计资产。
12. 不允许把模板当成 wallpaper。源页的 dominant structure 必须继续承载内容和语义。
## Local PPTX Is Not A Rewrite Target
Template Rewrite 的写入目标是导入后或已有的 Slides presentation。默认最终写入动作是 `slides +replace-pages`,不是创建一个新的本地 PPTX。
禁止的本地 PPTX 生成模式:
```python
while len(prs.slides._sldIdLst):
r_id = prs.slides._sldIdLst[0].rId
prs.part.drop_rel(r_id)
del prs.slides._sldIdLst[0]
blank = prs.slide_layouts[...]
slide = prs.slides.add_slide(blank)
```
上面这种模式会删除背景图、截图、装饰图、产品图、logo、shape、文本框、层级和页内结构。它最多是 Create Workflow 的“新建 PPT”不是模板二创。
## No Full-Page Wash / Mask
禁止默认添加:
- full-page wash
- near-full-page overlay
- 全页半透明白色蒙版
- 全页半透明黑色蒙版
- 覆盖页面主体区域的大矩形
- `rgba(255,255,255,0.x)` 大面积遮罩
- `rgba(0,0,0,0.x)` 大面积遮罩
原因:模板二创时,模板素材是优先保留对象。全页 wash 会视觉遮盖模板素材,即使 token 仍然存在,也等同于破坏模板。
允许的可读性增强仅包括:
- 局部 text backing
- 局部 card backing
- 调整文字颜色
- 调整字重
- 文字阴影
- 缩短文案
- 复用源页已有文本容器
- 复制 `source.xml` 中原本存在的 overlay
如果新增 overlay 覆盖了大部分画布,应判定为失败,除非:
- 该 overlay 来自 `source.xml` 原有元素;或
- 用户明确要求统一加蒙版 / 遮罩。

View File

@@ -23,6 +23,37 @@ lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
## Template Rewrite Validation
模板二创用 `slides +replace-pages` 后必须回读全文 XML并保存
```text
.lark-slides/plan/<xml_presentation_id>/readback.xml
```
同时对比同目录的 `source.xml`。通过标准:
1. `readback.xml` 中仍存在 `source.xml` 的关键 `<img src>` token。
2. `readback.xml` 中仍存在关键 `<style>`、chart、table、whiteboard、shape motif、card container。
3. 旧 text container 的 bbox、layer、font、color、alignment 没有无理由变化。
4. 没有新增 full-page / near-full-page overlay、wash、mask。
5. 没有把多页改成同质化两卡、三卡、2x2 卡片。
6. 没有用大白卡、大色块覆盖模板主体素材。
7. 没有把 source img 重新上传或替换成外部 URL。
8. `pages.json` item 只包含 `slide_id` / `content`
9. `replace-pages` 使用 create-before-delete 语义时,确认最终页数正确。
10. 如果发现模板素材 token 存在但被新增遮罩视觉遮盖,应判定为失败。
11. 没有出现 `python-pptx` 清空模板页、blank layout 重建、本地生成 PPTX 再导入的路径。
12. 源模板的媒体资产数量、关键图片 token、主要 shape/table/chart/whiteboard/text container 没有断崖式丢失。若原模板有大量媒体资产而结果只剩极少数媒体资产,应判定为失败,除非用户明确要求移除这些素材。
13. 每页的 dominant source structure 仍然存在并承载内容,例如三角形、箭头、节点、时间线、曲线、图表、表格、设备图、人物图、产品图或左右对照结构没有退化成背景装饰。
14. 新内容主要落在源页已有 text container、图形标签、节点标签、数字标签、chart/table 数据或源页注释容器里,而不是新增的通用卡片层里。
15. 没有把多页改成同一套“顶栏 + 三卡片 / 2x2 卡片 / 大 bullet 面板”的重复版式。
16. 源页有箭头流、节点关系、时间线、图形结构或坐标关系时,新文案与这些结构对齐,没有漂浮在不相关空白区域或互相遮挡。
17. 能获取截图时,至少抽查封面、典型内容页、复杂结构页和结尾页;如果总页数不超过 8 页,逐页截图检查。截图验收重点是源模板视觉结构是否仍可见且承载内容。
18. 验证记录必须说明“与 source.xml 的模板结构/素材保留对比结果”。只记录页数、关键词存在、`xml_text_overlap_lint.py error_count=0` 不足以通过 Template Rewrite validation。
允许的可读性增强仅限局部 text backing、局部 card backing、文字颜色/字重调整、文字阴影、缩短文案、复用源页已有文本容器,或复制 `source.xml` 中原本存在的 overlay。
## Automated XML Text Overlap Lint
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
@@ -36,6 +67,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
- 该工具不能验证模板视觉一致性。Template Rewrite Workflow 中,即使 `error_count == 0`只要源页背景、图片、shape、文本框、结构层级或主要媒体资产被清空/重画/遮挡,仍然必须判定失败。
常见 code 的处理方向:
@@ -46,7 +78,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
## Page Count And Structure
- 实际页数必须等于用户要求 `slide_plan.json` 的页数
- 实际页数必须等于用户要求。Create Workflow 对照 `slide_plan.json`Template Rewrite Workflow 对照 `source.xml` / `pages.json` 和 replace-pages 结果
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
@@ -54,7 +86,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
## Expected Elements
`slide_plan.json` 和用户要求逐页核对:
Create Workflow `slide_plan.json` 和用户要求逐页核对:
- 标题或主结论存在,并能对应 `key_message`
- `layout_type` 对应的主要结构已生成。
@@ -62,6 +94,15 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
Template Rewrite Workflow 按 `source.xml``pages.json` 和用户替换要求逐页核对:
- 标题或主结论存在,并写入源页对应的标题/结论容器。
- 源页 dominant structure 仍是页面中最醒目或最大的信息区域之一。
- 新内容映射到源页已有文本容器、图形标签、节点、箭头、时间线、图表/table 或注释容器。
- 源页原有图文关系、分组关系、层级关系仍然可读,没有被新增卡片层覆盖或挤散。
- 多页之间保留源模板的页型差异,没有被统一改成同质化卡片页。
- 当源容器装不下时,优先缩短文本、降低层级或复用邻近源容器;不能用大白卡、大色块或新面板覆盖模板主体。
如果用户指定了关键页例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
## Blank Or Broken Page Signals
@@ -72,6 +113,11 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 关键文本没有出现在回读 XML 中。
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
- 页面依赖的图片区域为空,且没有 fallback visual。
- Template Rewrite 结果只保留模板尺寸/主题色丢失大部分源页图片、背景、shape、文本容器或媒体资产。
- Template Rewrite 结果由新生成本地 PPTX 导入,而不是对导入/已有 Slides 使用 `+replace-pages`
- Template Rewrite 结果把内容贴进新增通用卡片层,源页的箭头、节点、时间线、图表、几何结构、设备图或人物图只剩背景作用。
- Template Rewrite 结果多页出现重复的顶栏、三卡片、2x2 卡片或大 bullet 面板,压过源模板原有页型差异。
- Template Rewrite 结果中源页关键结构仍存在,但新内容没有贴回对应标签、节点、数字、表格或注释位置。
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
- 大量形状坐标完全相同,导致主体内容重叠。
- 渐变背景回退成空白或白底,导致文字不可读。
@@ -94,6 +140,8 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 高密度页使用单个长 bullet list没有分栏、表格或分组。
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
- 所有内容页都是同一套标题加 bullets 坐标。
- Template Rewrite 中,新增元素漂浮在源结构上方,没有和源页图形、节点、表格、图片或注释形成对应关系。
- Template Rewrite 中,源页主体视觉被新增白卡、色块、面板或文字框切断。
## Verification Record
@@ -105,6 +153,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
- 模板二创:逐页或抽样说明 source.xml 的 dominant structure 是否仍承载内容,是否存在通用卡片层覆盖源结构;如已截图,说明抽查页范围。
```
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。

View File

@@ -74,7 +74,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
| `textAlign` | 文本对齐方式 |
| `lineSpacing` | 行间距schema 默认 `multiple:1.5` |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `fontFamily` | 字体,必须来自 `slides_xml_schema_definition.xml``FontFamilyType` 清单 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |

Some files were not shown because too many files have changed in this diff Show More