Compare commits

..

29 Commits

Author SHA1 Message Date
shanglei
217f4e5567 fix(qualitygate): pin the examples surface with an allowlist
examples-surface-only promised that demos may consume only the assembled CLI
and the public plugin SDK, but it enforced two denied prefixes instead, so
every tree nobody thought to deny was permitted. A demo importing `events`,
`errs` or a `cmd` subpackage passed the gate, and because
extension-zero-internal exempts these packages from the transitive check,
nothing examined what those imports dragged in either. The exemption was
therefore unbounded in what it covered, the same defect as the directory-name
skip it replaced.

- Add Rule.AllowedRepoDeps, which inverts the check: any dependency inside
  this module that is not listed is a violation. Standard library and
  third-party packages, including same-organisation modules that are not this
  one, stay outside the rule.
- Pin examples-surface-only to exactly `cmd` and `extension/platform`, so the
  rule name matches what it enforces and the inherited chain stays bounded by
  a direct surface of two packages.
- Cover the reproducers as contract cases: other repository trees, `cmd`
  subpackages, other `extension` subtrees, and the module root are rejected,
  while the two allowed packages plus non-module imports are not.

layering-edges.txt stays at 39 rows; the demos already import only the two
allowed packages.
2026-07-25 14:30:29 +08:00
shanglei
820305536c fix(qualitygate): scope the examples exemption to wrapper mains
The extension rule skipped any package whose import path contained
"/examples/", which let the gate miss two things: a directory named
examples anywhere under extension escaped the rule outright, and the
sanctioned demos were exempt from every denial rather than only from the
internal packages they inherit through cmd.

- Drop SkipFrom (and containsAny) so no rule can exempt by directory name.
- Exempt the two wrapper-main demos from extension-zero-internal by exact
  import path. Their cmd import is the pattern they exist to demonstrate,
  and seeding those edges instead would wedge the ratchet: the edges track
  cmd's transitive set, so a new internal package under cmd would demand a
  new row that check-layering-ratchet.sh refuses by design.
- Add examples-surface-only: demos may consume cmd and extension/platform
  but must not directly import internal or shortcuts. Zero violations today.

layering-edges.txt stays at 39 rows, so the ratchet bootstrap snapshot
still matches.
2026-07-25 12:18:26 +08:00
shanglei
d48c218d0d fix: close layering quality gate gaps 2026-07-24 17:49:33 +08:00
shanglei
abe0d09d4b fix(qualitygate): harden layering edge parsing and release-target checks
Layering edge parsing and graph coverage:
- Reject whitespace-padded exception fields instead of silently trimming
  them, so a padded row is a malformed row rather than a coerced identity;
  add a padded-field parse test.
- Fail loud when any release target/tag combination lists zero packages,
  which would otherwise let the layering graph silently under-cover.
- Document the build-tag scope (demo tags excluded), the SkipFrom substring
  semantics, and the toolchain-derived support set behind the drift check.

GoReleaser drift checks:
- Reject custom build commands and per-target overrides as unsupported.
- Detect --tags in addition to -tags when rejecting release build tags.
- Reject any GO* build environment variable (except CGO_ENABLED=0) through a
  single default branch instead of an explicit allowlist.
- Validate the GoReleaser global env block, and make the go-list stderr test
  table-driven across the default and authsidecar graphs.
2026-07-24 17:21:21 +08:00
shanglei
7c2ca4e465 test(qualitygate): cover default release graph 2026-07-24 16:34:22 +08:00
shanglei
cbe0fb12df test(qualitygate): reject unsupported release variants 2026-07-24 16:29:47 +08:00
shanglei
59b6393250 test(qualitygate): fail closed on release target drift 2026-07-24 16:24:30 +08:00
shanglei
f1ce88b48e test(qualitygate): pin release target coverage 2026-07-24 16:16:55 +08:00
shanglei
c09b0d5dd3 test(qualitygate): tolerate coverage helper diagnostics 2026-07-24 16:09:25 +08:00
shanglei
1772afe22d fix(qualitygate): cover release build graphs
Check all seven published GOOS and GOARCH combinations, and keep go list diagnostics separate from its JSON output for cold caches.\n\nMake the bootstrap snapshot immutable in CI, propagate shell failures explicitly, isolate sourced execution, and add deterministic regression tests for each contract.
2026-07-24 16:01:45 +08:00
shanglei
acd50f25fa fix(qualitygate): harden layering ratchet enforcement 2026-07-24 15:41:51 +08:00
shanglei
e488cf4cd3 feat(qualitygate): enforce six-layer package dependency boundaries
Add a data-driven architecture layering test that builds the full import
graph (go list -json -tags authsidecar) and evaluates six rules:

- extension must not depend on internal (transitive; keeps it extractable
  as a standalone SDK module)
- events must not depend on shortcuts (transitive)
- shortcuts must not directly import auth/keychain/credential/client/vfs
  (direct; must go through the shortcuts/common RuntimeContext gate)
- cmd subpackages must not import shortcuts (assembly point + cmd/auth only)
- errs must stay a leaf
- internal must not depend on cmd/shortcuts/events

Pre-existing violations are seeded into layering-edges.txt (37 rows). The
test rejects any unseeded violation (new debt) and any stale row (removed
debt), and CI locks the effective row count to only ever decrease. Removes
the tautological circular-dependency check from arch-audit.yml, since Go
already forbids import cycles at compile time.
2026-07-24 15:18:45 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
81 changed files with 6221 additions and 511 deletions

View File

@@ -62,19 +62,6 @@ jobs:
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md

View File

@@ -119,6 +119,8 @@ 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: Enforce layering ratchet
run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run source-contract lint guards (lintcheck)

View File

@@ -9,7 +9,40 @@ permissions:
contents: read
jobs:
goreleaser:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -26,35 +59,79 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file.
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1608,6 +1638,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -49,9 +49,10 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/check-layering-ratchet.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,40 @@
# from denied owner reason added_at
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/charcheck arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/core arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/envvars arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/i18n arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/keychain arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/validate arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/vfs arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/vfs/localfileio arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/charcheck arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/core arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/envvars arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/i18n arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/keychain arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/validate arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/vfs arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/vfs/localfileio arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/transport/sidecar github.com/larksuite/cli/internal/envvars arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/events github.com/larksuite/cli/shortcuts/im/convert_lib arch-migration events root aggregates the existing im conversion dependency 2026-07-24
github.com/larksuite/cli/events github.com/larksuite/cli/shortcuts/common arch-migration events root aggregates the existing im conversion dependency 2026-07-24
github.com/larksuite/cli/events/im github.com/larksuite/cli/shortcuts/im/convert_lib arch-migration events->shortcuts inversion via convert_lib 2026-07-24
github.com/larksuite/cli/events/im github.com/larksuite/cli/shortcuts/common arch-migration events->shortcuts inversion via convert_lib 2026-07-24
github.com/larksuite/cli/shortcuts/im github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/mail github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/minutes github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/vc github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/apps github.com/larksuite/cli/internal/keychain arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/drive github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/im github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/minutes github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/vc github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/apps github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/drive github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/im github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/mail github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/markdown github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/task github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/apps github.com/larksuite/cli/internal/vfs arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/event github.com/larksuite/cli/internal/vfs arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/mail github.com/larksuite/cli/internal/vfs arch-migration shortcut bypasses RuntimeContext gate 2026-07-24

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
return "", fmt.Errorf("%s: %w", flagName, err)
}
return value, nil
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

7
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.76",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.76",
"cpu": [
"x64",
"arm64"
"arm64",
"riscv64"
],
"hasInstallScript": true,
"license": "MIT",

View File

@@ -1,12 +1,13 @@
{
"name": "@larksuite/cli",
"version": "1.0.74",
"version": "1.0.76",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js",
"release:check": "node scripts/release-preflight.js"
},
"os": [
"darwin",

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
layering_ratchet_extract_keys() {
local source_file="$1"
local output_file="$2"
awk -F '\t' '
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
{
content = trim($0)
if (content == "" || substr(content, 1, 1) == "#") {
next
}
if (NF != 5) {
printf "Malformed layering ratchet row at %s:%d: expected five tab-separated fields.\n", FILENAME, FNR > "/dev/stderr"
exit 2
}
from = $1
denied = $2
owner = $3
reason = $4
added_at = $5
sub(/\r+$/, "", added_at)
if (from != trim(from) || denied != trim(denied) || owner != trim(owner) || reason != trim(reason) || added_at != trim(added_at)) {
printf "Malformed layering ratchet row at %s:%d: fields must not have surrounding whitespace.\n", FILENAME, FNR > "/dev/stderr"
exit 2
}
if (from == "" || denied == "" || owner == "" || reason == "" || added_at !~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/) {
printf "Malformed layering ratchet row at %s:%d: fields must be non-empty and added_at must use YYYY-MM-DD.\n", FILENAME, FNR > "/dev/stderr"
exit 2
}
print from "\t" denied
}
' "$source_file" | LC_ALL=C sort >"$output_file" || return
local duplicates
duplicates="$(uniq -d "$output_file")" || return
if [[ -n "$duplicates" ]]; then
echo "Layering ratchet contains duplicate (from, denied) keys: $source_file" >&2
printf '%s\n' "$duplicates" >&2
return 1
fi
}
layering_ratchet_hash_keys() {
local source_file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$source_file" | awk '{ print $1 }' || return
return
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$source_file" | awk '{ print $1 }' || return
return
fi
echo "Layering ratchet requires sha256sum or shasum." >&2
return 1
}
layering_ratchet_validate_bootstrap_snapshot() {
local current_keys="$1"
local expected_count="$2"
local expected_hash="$3"
local current_count
local current_hash
current_count="$(wc -l <"$current_keys" | tr -d '[:space:]')" || return
current_hash="$(layering_ratchet_hash_keys "$current_keys")" || return
if [[ "$current_count" != "$expected_count" || "$current_hash" != "$expected_hash" ]]; then
echo "::error::Layering ratchet bootstrap differs from the approved $expected_count-edge snapshot." >&2
return 1
fi
}
layering_ratchet_main() (
set -euo pipefail
local ratchet_file="internal/qualitygate/deptest/layering-edges.txt"
if root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
cd "$root" || return
else
echo "Layering ratchet must run inside a Git worktree." >&2
return 1
fi
local base_revision="${1:-${QUALITY_GATE_CHANGED_FROM:-}}"
local approved_initial_count="${2:-39}"
local approved_initial_hash="${3:-5636d50d10b9de1e08dc9f06cd66671b3f438650fa5b7f28b95aa7d5a69a1c21}"
if [[ -z "$base_revision" ]]; then
echo "Layering ratchet requires a base revision." >&2
return 1
fi
if ! git cat-file -e "$base_revision^{commit}" 2>/dev/null; then
echo "Layering ratchet base revision does not exist: $base_revision" >&2
return 1
fi
if [[ ! -f "$ratchet_file" ]]; then
echo "Layering ratchet file is missing: $ratchet_file" >&2
return 1
fi
local tmp_dir
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/layering-ratchet.XXXXXX")" || return
local base_file="$tmp_dir/base.txt"
local base_keys="$tmp_dir/base.keys"
local current_keys="$tmp_dir/current.keys"
local additions="$tmp_dir/additions.keys"
layering_ratchet_cleanup_current_run() {
rm -f "$base_file" "$base_keys" "$current_keys" "$additions"
rmdir "$tmp_dir"
}
trap layering_ratchet_cleanup_current_run EXIT
layering_ratchet_extract_keys "$ratchet_file" "$current_keys" || return
if ! git cat-file -e "$base_revision:$ratchet_file" 2>/dev/null; then
layering_ratchet_validate_bootstrap_snapshot "$current_keys" "$approved_initial_count" "$approved_initial_hash" || return
return
fi
git show "$base_revision:$ratchet_file" >"$base_file" || return
layering_ratchet_extract_keys "$base_file" "$base_keys" || return
LC_ALL=C comm -13 "$base_keys" "$current_keys" >"$additions" || return
if [[ -s "$additions" ]]; then
echo "::error::Layering ratchet contains new (from, denied) keys. Fix the dependency instead of adding rows." >&2
while IFS=$'\t' read -r from denied; do
printf 'from=%s denied=%s\n' "$from" "$denied" >&2
done <"$additions"
return 1
fi
)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
layering_ratchet_main "${1:-${QUALITY_GATE_CHANGED_FROM:-}}"
fi

View File

@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/check-layering-ratchet.sh"
ratchet_file="internal/qualitygate/deptest/layering-edges.txt"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/check-layering-ratchet-test.XXXXXX")"
source "$script"
cleanup_tmp() {
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
row() {
printf '%s\t%s\towner\treason\t2026-07-24\n' "$1" "$2"
}
git_init() {
local dir="$1"
git init -q -b main "$dir"
git -C "$dir" config user.name test
git -C "$dir" config user.email test@example.com
mkdir -p "$dir/$(dirname "$ratchet_file")"
}
write_rows() {
local dir="$1"
shift
{
printf '# from\tdenied\towner\treason\tadded_at\n'
while (( $# > 0 )); do
row "$1" "$2"
shift 2
done
} >"$dir/$ratchet_file"
}
commit_ratchet() {
local dir="$1"
git -C "$dir" add "$ratchet_file"
git -C "$dir" commit -q -m "ratchet"
}
expect_pass() {
local dir="$1"
local base="$2"
if ! (cd "$dir" && bash "$script" "$base"); then
echo "Expected layering ratchet check to pass in $dir." >&2
return 1
fi
}
expect_fail() {
local dir="$1"
local base="$2"
local expected="$3"
local output
if output="$(cd "$dir" && bash "$script" "$base" 2>&1)"; then
echo "Expected layering ratchet check to fail in $dir." >&2
return 1
fi
if ! grep -Fq "$expected" <<<"$output"; then
printf 'Layering ratchet failure did not include %q:\n%s\n' "$expected" "$output" >&2
return 1
fi
}
hash_file() {
local source_file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$source_file" | awk '{ print $1 }'
else
shasum -a 256 "$source_file" | awk '{ print $1 }'
fi
}
bootstrap_keys() {
local source_file="$1"
awk -F '\t' 'NF == 5 && $1 !~ /^[[:space:]]*#/ { print $1 "\t" $2 }' "$source_file" | LC_ALL=C sort
}
expect_bootstrap_pass() {
local dir="$1"
local base="$2"
local count="$3"
local hash="$4"
if ! (cd "$dir" && layering_ratchet_main "$base" "$count" "$hash"); then
echo "Expected layering ratchet bootstrap check to pass in $dir." >&2
return 1
fi
}
expect_bootstrap_fail() {
local dir="$1"
local base="$2"
local count="$3"
local hash="$4"
local output
if output="$(cd "$dir" && layering_ratchet_main "$base" "$count" "$hash" 2>&1)"; then
echo "Expected layering ratchet bootstrap check to fail in $dir." >&2
return 1
fi
if ! grep -Fq "bootstrap differs from the approved" <<<"$output"; then
printf 'Unexpected layering ratchet bootstrap failure:\n%s\n' "$output" >&2
return 1
fi
}
expect_sourced_main_fail() {
local dir="$1"
local base="$2"
local expected="$3"
local output
if output="$(cd "$dir" && layering_ratchet_main "$base" 2>&1)"; then
echo "Expected sourced layering ratchet main to fail in $dir." >&2
return 1
fi
if ! grep -Fq "$expected" <<<"$output"; then
printf 'Sourced layering ratchet failure did not include %q:\n%s\n' "$expected" "$output" >&2
return 1
fi
}
test_unchanged_and_metadata_changes_pass() {
local dir="$tmp/unchanged"
git_init "$dir"
write_rows "$dir" from/a denied/a from/b denied/b
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
expect_pass "$dir" "$base"
sed -i.bak 's/\towner\treason\t/\tnew-owner\tnew-reason\t/' "$dir/$ratchet_file"
rm -f "$dir/$ratchet_file.bak"
expect_pass "$dir" "$base"
}
test_deletion_passes() {
local dir="$tmp/deletion"
git_init "$dir"
write_rows "$dir" from/a denied/a from/b denied/b
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a
expect_pass "$dir" "$base"
}
test_addition_fails() {
local dir="$tmp/addition"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a from/b denied/b
expect_fail "$dir" "$base" "from=from/b denied=denied/b"
}
test_equal_count_replacement_fails() {
local dir="$tmp/replacement"
git_init "$dir"
write_rows "$dir" from/a denied/a from/b denied/b
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a from/c denied/c
expect_fail "$dir" "$base" "from=from/c denied=denied/c"
}
test_malformed_and_missing_current_file_fail() {
local dir="$tmp/malformed"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
printf 'from/a\tdenied/a\towner\treason\n' >"$dir/$ratchet_file"
expect_fail "$dir" "$base" "expected five tab-separated fields"
expect_sourced_main_fail "$dir" "$base" "expected five tab-separated fields"
rm -f "$dir/$ratchet_file"
expect_fail "$dir" "$base" "Layering ratchet file is missing"
}
test_surrounding_whitespace_fails() {
local dir="$tmp/surrounding-whitespace"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
printf '# from\tdenied\towner\treason\tadded_at\nfrom/a\t denied/a \towner\treason\t2026-07-24\n' >"$dir/$ratchet_file"
expect_fail "$dir" "$base" "fields must not have surrounding whitespace"
expect_sourced_main_fail "$dir" "$base" "fields must not have surrounding whitespace"
}
test_crlf_rows_pass() {
local dir="$tmp/crlf"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
printf '# from\tdenied\towner\treason\tadded_at\r\nfrom/a\tdenied/a\towner\treason\t2026-07-24\r\n' >"$dir/$ratchet_file"
expect_pass "$dir" "$base"
}
test_duplicate_key_fails() {
local dir="$tmp/duplicate"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a from/a denied/a
expect_fail "$dir" "$base" "duplicate (from, denied) keys"
}
test_bootstrap_requires_the_approved_snapshot() {
local dir="$tmp/bootstrap"
git_init "$dir"
printf 'base\n' >"$dir/base.txt"
git -C "$dir" add base.txt
git -C "$dir" commit -q -m "base"
local base
base="$(git -C "$dir" rev-parse HEAD)"
local args=()
local index
for index in $(seq 1 39); do
args+=("from/$index" "denied/$index")
done
write_rows "$dir" "${args[@]}"
local keys_file="$dir/initial.keys"
bootstrap_keys "$dir/$ratchet_file" >"$keys_file"
local expected_hash
expected_hash="$(hash_file "$keys_file")"
expect_bootstrap_pass "$dir" "$base" 39 "$expected_hash"
args[76]="from/replacement"
write_rows "$dir" "${args[@]}"
expect_bootstrap_fail "$dir" "$base" 39 "$expected_hash"
args[76]="from/39"
args+=("from/40" "denied/40")
write_rows "$dir" "${args[@]}"
expect_bootstrap_fail "$dir" "$base" 39 "$expected_hash"
}
test_invalid_base_fails() {
local dir="$tmp/invalid-base"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
expect_fail "$dir" missing-revision "base revision does not exist"
}
test_unchanged_and_metadata_changes_pass
test_deletion_passes
test_addition_fails
test_equal_count_replacement_fails
test_malformed_and_missing_current_file_fail
test_surrounding_whitespace_fails
test_crlf_rows_pass
test_duplicate_key_fails
test_bootstrap_requires_the_approved_snapshot
test_invalid_base_fails

View File

@@ -170,6 +170,21 @@ if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
exit 1
fi
if ! grep -Fq 'bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"' <<<"$lint_section"; then
echo "lint should enforce the layering ratchet with the tested key-set checker"
exit 1
fi
if grep -Fq "grep -vcE" <<<"$lint_section"; then
echo "lint should not enforce the layering ratchet by row count alone"
exit 1
fi
if grep -Fq "LAYERING_RATCHET_INITIAL_" "$workflow"; then
echo "CI must use the immutable checked-in layering bootstrap snapshot"
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

View File

@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error("[SECURITY] Expected checksum is missing or invalid");
}
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
throw new Error(
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
);
}
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
);
});
it("returns null when checksums.txt does not exist", () => {
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("matches case-insensitively", () => {
it("accepts a valid uppercase 64-character hex hash", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
for (const [name, expectedHash] of [
["null", null],
["empty", ""],
["non-string", 123],
]) {
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Expected checksum is missing or invalid/);
return true;
}
);
});
}
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "abc123"),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY] format Error for a non-hex hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "g".repeat(64)),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
const observed = {
packageVersion: packageVersion ?? null,
lockVersion: lockVersion ?? null,
lockRootVersion: lockRootVersion ?? null,
tagVersion: null,
};
for (const [field, value] of [
["package.json.version", packageVersion],
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
);
}
}
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
return releaseError(
"Package version fields do not match",
observed,
"Synchronize package.json.version and both package-lock.json version fields.",
);
}
if (tag === undefined) {
return { ok: true, data: observed };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
);
}
const tagVersion = tag.slice(1);
if (tagVersion !== packageVersion) {
return releaseError(
"Tag version does not match the package version",
{ ...observed, tagVersion, tag },
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
}
function writeResult(result) {
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
}
function main() {
const args = process.argv.slice(2);
let tag;
if (args.length === 2 && args[0] === "--tag") {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
return;
}
const repoRoot = path.resolve(__dirname, "..");
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
} catch (error) {
writeResult(releaseError(
"Could not read release package metadata",
{ reason: error.message },
"Ensure package.json and package-lock.json exist and contain valid JSON.",
));
}
}
module.exports = { validateReleasePreflight };
if (require.main === module) main();

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
packageJson: { version },
packageLockJson: {
version,
packages: { "": { version } },
},
};
}
function assertRejected(result) {
assert.equal(result.ok, false);
assert.equal(result.error.type, "release_preflight");
assert.equal(typeof result.error.message, "string");
}
describe("validateReleasePreflight", () => {
it("accepts matching stable package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata();
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
{
ok: true,
data: {
packageVersion: "1.2.3",
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
});
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});

View File

@@ -3,49 +3,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
VERSION=$(node -p "require('./package.json').version")
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
CURRENT_BRANCH=$(git branch --show-current)
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
exit 1
fi
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
if ! git diff --quiet HEAD -- package.json package-lock.json; then
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
exit 1
fi
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
git fetch origin main
echo "Successfully created and pushed tag ${TAG}"
HEAD_SHA=$(git rev-parse HEAD)
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
echo "Error: HEAD must exactly match origin/main before tagging." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Error: local tag ${TAG} already exists." >&2
exit 1
fi
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
if [ -n "${REMOTE_TAG}" ]; then
echo "Error: remote tag ${TAG} already exists." >&2
exit 1
fi
git tag "${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}"
echo "Successfully pushed tag ${TAG}"

View File

@@ -12,10 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,7 +14,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
return err
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -2435,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "write",
Risk: "high-risk-write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -801,7 +801,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
},
},
{
@@ -850,8 +851,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -865,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"Happy path field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"Batch create supports max 200 records per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -2055,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2356,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2424,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2472,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2483,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2519,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2553,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for fields with options, such as select or multi-select fields.",
"Use only for select fields, whether multiple is false or true.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
},
Tips: append([]string{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Happy path field: create_records is an array of independent record field maps.",
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"Batch create supports max 200 records per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"io"
"io/fs"
"math"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
)
// ValidateLocalFileFlag validates that a local input path exists, is a regular
// file, and does not exceed maxBytes. Absolute and relative paths use
// the process filesystem namespace.
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return err
}
info, err := cmdutil.StatLocalFile(path)
if err != nil {
return localFileReadError(param, path, "inspect", err)
}
if err := localFileRegularError(param, path, info.Mode()); err != nil {
return err
}
if info.Size() > maxBytes {
return localFileSizeError(param, path, info.Size(), maxBytes)
}
return nil
}
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
// shortcuts. It accepts absolute and relative paths, enforces a hard size
// limit, and returns command-facing typed errors.
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return nil, err
}
f, err := cmdutil.OpenLocalFile(path)
if err != nil {
return nil, localFileReadError(param, path, "open", err)
}
defer func() {
if err := f.Close(); err != nil && retErr == nil {
data = nil
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
}
}()
openedInfo, err := f.Stat()
if err != nil {
return nil, localFileReadError(param, path, "inspect opened", err)
}
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
return nil, err
}
if openedInfo.Size() > maxBytes {
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
}
readLimit := maxBytes + 1
if maxBytes == math.MaxInt64 {
readLimit = maxBytes
}
data, err = io.ReadAll(io.LimitReader(f, readLimit))
if err != nil {
return nil, localFileReadError(param, path, "read", err)
}
if int64(len(data)) > maxBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
WithParam(param)
}
return data, nil
}
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
name, param, err := localFileFlagNames(flagName)
if err != nil {
return "", "", err
}
if ctx == nil || ctx.Cmd == nil {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
}
path = strings.TrimSpace(ctx.Str(name))
if path == "" {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
}
if _, err := validate.LocalInputPath(path); err != nil {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
WithParam(param).
WithCause(err)
}
if maxBytes < 0 {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
}
return path, param, nil
}
func localFileRegularError(param, path string, mode fs.FileMode) error {
if mode.IsRegular() {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is not a regular file", param, path).
WithParam(param)
}
func localFileReadError(param, path, op string, cause error) error {
if errors.Is(cause, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
WithParam(param).
WithCause(cause)
}
if errors.Is(cause, fs.ErrNotExist) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
WithParam(param).
WithCause(cause)
}
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
}
func localFileSizeError(param, path string, size, limit int64) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
WithParam(param)
}
func localFileFlagNames(flagName string) (name, param string, err error) {
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
if name == "" {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
}
return name, "--" + name, nil
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/spf13/cobra"
)
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
rctx := localFileTestRuntime(t, path)
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
}
got, err := rctx.ReadLocalFileFlag("file", 7)
if err != nil || string(got) != "content" {
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
}
}
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("file", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
}

View File

@@ -356,26 +356,11 @@ func TestValidateUpdateV2Contract(t *testing.T) {
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
{
name: "XML str_replace rejects multiline pattern",
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
wantParam: "--pattern",
},
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
{
name: "block_delete rejects empty ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
wantParam: "--block-id",
},
{
name: "block_delete rejects duplicate ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
wantParam: "--block-id",
},
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},

View File

@@ -17,46 +17,6 @@ import (
// ── V2 (OpenAPI) tests ──
func TestStripTopLevelXMLTitles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want string
}{
{
name: "single title",
content: "<title>Content title</title><p>body</p>",
want: "<p>body</p>",
},
{
name: "multiple titles",
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
want: "<p>body</p>",
},
{
name: "nested title is preserved",
content: "<callout><title>Nested</title></callout><p>body</p>",
want: "<callout><title>Nested</title></callout><p>body</p>",
},
{
name: "malformed XML is preserved",
content: "<title>Content title</title><p>A & B</p>",
want: "<title>Content title</title><p>A & B</p>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
}
})
}
}
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()

View File

@@ -7,8 +7,6 @@ import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"strings"
"github.com/larksuite/cli/errs"
@@ -18,7 +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; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
{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: "reference-map", Desc: docsReferenceMapFlagDesc, 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"}},
@@ -110,9 +108,6 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
if title == "" {
return content
}
if runtime.Str("doc-format") == "xml" {
content = stripTopLevelXMLTitles(content)
}
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
if content == "" {
@@ -121,62 +116,6 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
return titleTag + "\n" + content
}
type docContentRange struct {
start int64
end int64
}
// stripTopLevelXMLTitles preserves the established --title-wins contract while
// avoiding duplicate-title warnings from XML content. If the fragment is not
// well-formed XML, it is left untouched for the service to diagnose.
func stripTopLevelXMLTitles(content string) string {
const wrapperStart = "<root>"
wrapped := wrapperStart + content + "</root>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
wrapperLen := int64(len(wrapperStart))
depth := 0
activeStart := int64(-1)
ranges := make([]docContentRange, 0, 1)
for {
tokenStart := decoder.InputOffset()
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return content
}
switch value := token.(type) {
case xml.StartElement:
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
activeStart = tokenStart - wrapperLen
}
depth++
case xml.EndElement:
depth--
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
activeStart = -1
}
}
}
if len(ranges) == 0 {
return content
}
var result strings.Builder
cursor := int64(0)
for _, item := range ranges {
result.WriteString(content[int(cursor):int(item.start)])
cursor = item.end
}
result.WriteString(content[int(cursor):])
return strings.TrimSpace(result.String())
}
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))

View File

@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
@@ -73,16 +73,10 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if pattern == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
if err := validateBlockDeleteIDs(blockID); err != nil {
return err
}
case "block_insert_after":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
@@ -130,29 +124,6 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
func validateBlockDeleteIDs(raw string) error {
seen := make(map[string]struct{})
for _, part := range strings.Split(raw, ",") {
blockID := strings.TrimSpace(part)
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
}
if _, ok := seen[blockID]; ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
}
seen[blockID] = struct{}{}
}
return nil
}
func normalizeBlockDeleteIDs(raw string) string {
parts := strings.Split(raw, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, ",")
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
@@ -228,9 +199,6 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
body["pattern"] = v
}
if blockID != "" {
if cmd == "block_delete" {
blockID = normalizeBlockDeleteIDs(blockID)
}
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {

View File

@@ -3,11 +3,24 @@
package slides
import "github.com/larksuite/cli/shortcuts/common"
import (
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var presentationFlagAliases = []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
all := []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
@@ -18,4 +31,39 @@ func Shortcuts() []common.Shortcut {
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
for i := range all {
if hasPresentationFlag(all[i].Flags) {
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
}
}
return all
}
func hasPresentationFlag(flags []common.Flag) bool {
for _, flag := range flags {
if flag.Name == "presentation" {
return true
}
}
return false
}
// withPresentationFlagAliases accepts common agent-generated spellings for
// --presentation without registering extra flags. The aliases therefore stay
// out of help and completion while resolving to the canonical flag at parse
// time, matching the zero-round-trip compatibility used by Sheets.
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
for _, alias := range presentationFlagAliases {
if name == alias {
return pflag.NormalizedName("presentation")
}
}
return pflag.NormalizedName(name)
})
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWithPresentationFlagAliases(t *testing.T) {
for _, alias := range presentationFlagAliases {
t.Run(alias, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("presentation", "", "presentation reference")
withPresentationFlagAliases(nil)(cmd)
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Fatalf("read --presentation: %v", err)
}
if got != "presABC" {
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
}
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
}
})
}
}
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
count := 0
for _, shortcut := range Shortcuts() {
if !hasPresentationFlag(shortcut.Flags) {
continue
}
count++
if shortcut.PostMount == nil {
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
continue
}
cmd := &cobra.Command{Use: shortcut.Command}
cmd.Flags().String("presentation", "", "presentation reference")
shortcut.PostMount(cmd)
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
continue
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
continue
}
if got != "presABC" {
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
}
}
if count == 0 {
t.Fatal("expected at least one slides shortcut with --presentation")
}
}

View File

@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -100,6 +101,40 @@ func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
func parseTaskGUID(input string) (string, error) {
input = strings.TrimSpace(input)
invalid := func(format string, args ...interface{}) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
WithParam("--task-id").
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
}
if input == "" {
return "", invalid("task ID is empty")
}
lowerInput := strings.ToLower(input)
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
u, err := url.Parse(input)
if err != nil {
return "", invalid("invalid task applink: %v", err).WithCause(err)
}
guid := strings.TrimSpace(u.Query().Get("guid"))
if guid == "" {
return "", invalid("task applink is missing a non-empty guid query parameter")
}
return guid, nil
}
if taskDisplayNumberPattern.MatchString(input) {
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
}
return input, nil
}
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})

View File

@@ -4,8 +4,11 @@
package task
import (
"errors"
"net/url"
"testing"
"github.com/larksuite/cli/errs"
"github.com/smartystreets/goconvey/convey"
)
@@ -15,3 +18,80 @@ func TestShortcutsRegistration(t *testing.T) {
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}
func TestParseTaskGUID(t *testing.T) {
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
{
name: "task applink",
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
want: "task-guid-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTaskGUID(tt.input)
if err != nil {
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
})
t.Run("rejects unusable task identifiers", func(t *testing.T) {
for _, input := range []string{
"",
"https://applink.larksuite.com/client/todo/detail",
"https://%",
"t12345",
} {
t.Run(input, func(t *testing.T) {
_, err := parseTaskGUID(input)
if err == nil {
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
if problem.Hint == "" {
t.Fatal("problem hint is empty")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--task-id" {
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
}
})
}
})
t.Run("preserves applink parse cause", func(t *testing.T) {
_, err := parseTaskGUID("https://%")
if err == nil {
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
}
var urlErr *url.Error
if !errors.As(err, &urlErr) {
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
}
})
}

View File

@@ -25,45 +25,59 @@ var CompleteTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUID(runtime.Str("task-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCompleteBody()
taskId := url.PathEscape(runtime.Str("task-id"))
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskID := url.PathEscape(taskGUID)
return common.NewDryRunAPI().
GET("/open-apis/task/v2/tasks/" + taskId).
GET("/open-apis/task/v2/tasks/" + taskID).
Desc("get current task status").
Params(map[string]interface{}{"user_id_type": "open_id"}).
PATCH("/open-apis/task/v2/tasks/" + taskId).
PATCH("/open-apis/task/v2/tasks/" + taskID).
Desc("complete task if not completed").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return err
}
taskID := url.PathEscape(taskGUID)
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
completedAtStr, _ := taskData["completed_at"].(string)
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
// 2. If already completed, directly return success
if completedAtStr != "" && completedAtStr != "0" {
if alreadyCompleted {
data = getData
} else {
// 3. Complete the task
body := buildCompleteBody()
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
if err != nil {
return err
}
@@ -73,11 +87,19 @@ var CompleteTask = common.Shortcut{
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
completedAt, _ := task["completed_at"].(string)
status := "todo"
if completedAt != "" && completedAt != "0" {
status = "done"
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
"guid": guid,
"url": urlVal,
"status": status,
"completed_at": completedAt,
"already_completed": alreadyCompleted,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {

View File

@@ -4,9 +4,12 @@
package task
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -45,6 +48,9 @@ func TestCompleteTask(t *testing.T) {
formatFlag: "json",
expectedOutput: []string{
`"guid": "task-789"`,
`"status": "done"`,
`"completed_at": "1775174400000"`,
`"already_completed": false`,
},
},
}
@@ -109,3 +115,98 @@ func TestCompleteTask(t *testing.T) {
})
}
}
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
for _, method := range []string{"GET", "PATCH"} {
reg.Register(&httpmock.Stub{
Method: method,
URL: "/open-apis/task/v2/tasks/task-guid-applink",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-applink",
"summary": "Applink task",
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
"url": "https://example.com/task-guid-applink",
},
},
},
})
}
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete",
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
}
}
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-guid-done",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-done",
"summary": "Already done",
"completed_at": "1775174400000",
"url": "https://example.com/task-guid-done",
},
},
},
})
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
}
}
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("CompleteTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}

View File

@@ -27,27 +27,42 @@ var UpdateTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "data", Desc: "JSON payload for task object"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUIDs(runtime.Str("task-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
return common.NewDryRunAPI().
PATCH("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
preview := common.NewDryRunAPI()
for _, taskID := range taskIDs {
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return preview
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return err
}
body, err := buildTaskUpdateBody(runtime)
if err != nil {
// buildTaskUpdateBody already returns a typed validation error;
@@ -55,17 +70,11 @@ var UpdateTask = common.Shortcut{
return err
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
var updatedTasks []map[string]interface{}
for _, taskId := range taskIds {
taskId = strings.TrimSpace(taskId)
if taskId == "" {
continue
}
for _, taskID := range taskIDs {
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
if err != nil {
return err
}
@@ -76,19 +85,28 @@ var UpdateTask = common.Shortcut{
}
}
updateFields, _ := body["update_fields"].([]string)
var tasks []map[string]interface{}
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
confirmed := make(map[string]interface{})
for _, field := range updateFields {
if value, ok := task[field]; ok {
confirmed[field] = value
}
}
tasks = append(tasks, map[string]interface{}{
"guid": guid,
"url": urlVal,
"guid": guid,
"url": urlVal,
"confirmed": confirmed,
})
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"tasks": tasks,
"updated_fields": updateFields,
"tasks": tasks,
}
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
@@ -112,6 +130,26 @@ var UpdateTask = common.Shortcut{
},
}
func parseTaskGUIDs(input string) ([]string, error) {
parts := strings.Split(input, ",")
taskGUIDs := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
guid, err := parseTaskGUID(part)
if err != nil {
return nil, err
}
taskGUIDs = append(taskGUIDs, guid)
}
if len(taskGUIDs) == 0 {
_, err := parseTaskGUID("")
return nil, err
}
return taskGUIDs, nil
}
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
taskObj := make(map[string]interface{})
var updateFields []string

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestParseTaskGUIDs(t *testing.T) {
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
if err != nil {
t.Fatalf("parseTaskGUIDs() error = %v", err)
}
want := []string{"task-guid-1", "task-guid-2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
}
_, err = parseTaskGUIDs("task-guid-1,t12345")
if err == nil {
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
}
}
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
cmd.Flags().String("summary", "updated", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("data", "", "")
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
payload, err := json.Marshal(preview)
if err != nil {
t.Fatalf("marshal dry-run preview: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("decode dry-run preview: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
}
wantURLs := []string{
"/open-apis/task/v2/tasks/task-guid-1",
"/open-apis/task/v2/tasks/task-guid-2",
}
for i, call := range got.API {
if call.Method != "PATCH" {
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
}
if call.URL != wantURLs[i] {
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
}
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
t.Errorf("api[%d].params = %#v", i, call.Params)
}
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
}
}
}
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
first := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-1",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-1",
"url": "https://example.com/task-guid-1",
"summary": "server summary one",
"description": "server description one",
},
},
},
}
second := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-2",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-2",
"url": "https://example.com/task-guid-2",
"summary": "server summary two",
},
},
},
}
reg.Register(first)
reg.Register(second)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
"--summary", "requested summary",
"--description", "requested description",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("UpdateTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", envelope["data"])
}
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
t.Fatalf("updated_fields = %v, want [summary description]", got)
}
tasks, ok := data["tasks"].([]interface{})
if !ok || len(tasks) != 2 {
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
}
firstTask := tasks[0].(map[string]interface{})
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
t.Fatalf("first task identifiers = %#v", firstTask)
}
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary one", "description": "server description one",
}) {
t.Fatalf("first confirmed = %#v", got)
}
secondTask := tasks[1].(map[string]interface{})
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary two",
}) {
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
}
}
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,t12345",
"--summary", "must not be written",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("UpdateTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}
func stringSlice(value interface{}) []string {
items, _ := value.([]interface{})
result := make([]string, 0, len(items))
for _, item := range items {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}

View File

@@ -28,7 +28,7 @@
## 各命令
### +file-list
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
```bash
lark-cli apps +file-list --app-id app_xxx

View File

@@ -29,7 +29,7 @@ metadata:
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL简单命令由命令自身的参数、tips 和错误恢复承接
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
@@ -104,20 +104,18 @@ metadata:
## 写入前置规则
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 删除、角色更新、字段更新、表单提交(`+form-submit`等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
## 表单与视图细节
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。

View File

@@ -8,7 +8,7 @@
- `--json` 必须是 JSON 对象。
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`
- `+record-batch-create``rows``CellValue[][]`,列顺序由 `fields` 决定
- `+record-batch-create`使用 `create_records`,其每个元素都是 `Map<FieldNameOrID, CellValue>`
- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`
- 一次 payload 里同一字段只用一种 key字段名或字段 ID不要重复。
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
@@ -48,7 +48,7 @@ text 字段的 `style.type` 影响单元格检查逻辑:
### 2.3 select单选/多选)
单选用选项名字符串;多选用选项名数组。选项名建议与字段配置一致;写入未知选项时平台可能自动新增选项,因此不要把自然语言近义词当成已有选项传入
`select` 字段用 `multiple` 区分单选和多选:`multiple=false` 时传选项名字符串,`multiple=true` 时传选项名数组。只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
```json
{

View File

@@ -4,6 +4,8 @@
通过表单分享链接填写并提交多维表格表单。仅支持分享模式share_token支持填写普通字段值和上传本地文件作为附件。
> **⚠️ 高风险写操作high-risk-write** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
## 填写前必读:先获取表单详情
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
@@ -21,10 +23,11 @@ lark-cli base +form-detail --share-token <share_token>
# 2⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
# 3⃣ 再提交
# 3⃣ 再提交(高风险写操作,必须带 --yes
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}'
--json '{"fields":{...}}' \
--yes
```
`+form-detail` 的返回中要重点读取 `questions[].type``questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`
@@ -35,7 +38,8 @@ lark-cli base +form-submit \
# 基本提交(填写普通字段)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
--yes
# 带附件提交(需要额外提供 --base-token
lark-cli base +form-submit \
@@ -47,15 +51,17 @@ lark-cli base +form-submit \
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}'
}' \
--yes
# 使用应用身份bot
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--as bot
--as bot \
--yes
# 预览 API 调用(不实际执行)
# 预览 API 调用(不实际执行dry-run 无需 --yes
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
@@ -69,6 +75,7 @@ lark-cli base +form-submit \
| `--share-token <token>` | 是 | 表单分享 Token必填从表单分享链接中提取 |
| `--base-token <token>` | 条件必填 | Base token**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write不带 `--yes` 会返回 `confirmation_required` |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
@@ -138,7 +145,8 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```bash
lark-cli base +form-submit \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
--json '{"fields":{...}}'
--json '{"fields":{...}}' \
--yes
```
## 输出格式
@@ -158,6 +166,7 @@ lark-cli base +form-submit \
## 提示
- **本命令为高风险写操作high-risk-write必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
- 本命令仅支持通过表单分享链接share_token提交不支持通过 base_token + table_id + view_id 方式提交
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
- 附件字段只需在 `--json.attachments` 中提供本地路径即可CLI 自动完成校验、并行上传、Token 获取和合并写入

View File

@@ -7,13 +7,13 @@
## 适用场景(重点)
- 适合导入 CSV / Excel、外部系统一次性写入新数据。
- 先把输入数据映射到合适的字段类型,再组装 `fields + rows`
- 先把每条输入数据映射为独立的字段对象,再组装 `create_records`
## 推荐命令
```bash
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> \
--json '{"fields":["标题","状态"],"rows":[["任务 A","Open"],["任务 B","Done"]]}'
--json '{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}'
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> --json @batch-create.json
```
@@ -34,23 +34,25 @@ lark-cli base +record-batch-create --base-token <base_token> --table-id <table_i
本节只说明 `+record-batch-create` 的外层 JSON 形状CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
对象形态:`{"fields":[...],"rows":[...]}`
对象形态:
```json
{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `fields` | `string[]` | 是 | 字段 ID 或字段名数组 |
| `rows` | `CellValue[][]` | 是 | 二维数组,每一行按 `fields` 同序给 cell单次最多 200 行 |
| `create_records` | `Array<Map<FieldNameOrID, CellValue>>` | 是 | 记录字段对象数组;每条记录可以提交不同字段,单次最多 200 条 |
## 返回重点
返回 `fields``field_id_list``record_id_list``data`,其中 `data``fields` 列顺序对齐
返回 `record_id_list` 和可选的 `ignored_fields`
## 坑点
- `fields` 与每行 `rows` 的列顺序必须一一对应
- 空单元格必须显式用 `null` 填充
- 单次最多 200 行,超出需分批写入
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先确认真实选项名。
- 每个 `create_records` 元素都是独立的记录字段对象,只提交该记录需要写入的字段
- 单次最多 200 条,超出需分批写入
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
## 参考

View File

@@ -55,7 +55,7 @@ lark-cli base +record-upsert --base-token <base_token> --table-id <table_id> --r
## 坑点
-`--record-id` 就一定更新;不传就一定创建,不会自动查重或按业务键 upsert。
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先用 `+field-list` / `+field-search-options` 确认真实选项名
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` `+field-search-options` 确认目标选项存在
- 这是写入操作,执行前必须确认目标表和字段。
## 参考

View File

@@ -96,6 +96,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
每轮扩展都要保留非污染、可解释的 evidenceURL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
扩展 query 时,优先保留用户已经指定的空间、文件夹、群聊、人员、时间和类型等 filter确需放宽检索范围时先向用户说明原因并征得确认。
## 参数

View File

@@ -29,7 +29,7 @@ Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声
"title": { "tag": "plain_text", "content": "卡片标题" },
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
"icon": { "tag": "standard_icon", "token": "lark-logo_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
]

View File

@@ -105,12 +105,12 @@
"header": {
"title": { "tag": "plain_text", "content": "卡片标题" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "mail_colorful" }
"icon": { "tag": "standard_icon", "token": "calendar_colorful" }
}
```
- `token``resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称
- 常用速查:通知 `notice_colorful`告警 `warning_colorful`、审批 `approve_colorful`日历 `calendar_colorful`数据 `chart_colorful`任务 `todo_colorful`、AI `myai_colorful`
- `token` 必须`resource/icons.md` 的精确枚举中选择;禁止根据名称规律自行拼接 token。没有合适的 token 时省略 icon
- 场景速查:日历 `calendar_colorful`、待办 `todo_colorful`、投票 `vote_colorful`妙记 `file-lark-minutes_colorful`、多维表格 `wiki-bitable_colorful`表单 `file-form_colorful`社区 `larkcommunity_colorful`招聘 `hirelogo_colorful`、飞书品牌 `lark-logo_colorful`、Meego `meego_colorful`、AI `myai_colorful`、aPaaS `apaas_colorful`、审批 `approval_colorful`、通用 AI `ai-common_colorful`
### 1. 配色纪律(服务 P6 语义一致)
@@ -212,7 +212,7 @@ header 有三层能力,**尽量用满**(至少用 `title` + `icon``subtit
"title": { "tag": "plain_text", "content": "发版审批" },
"subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "approve_colorful" },
"icon": { "tag": "standard_icon", "token": "approval_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" }
]

View File

@@ -34,5 +34,19 @@
| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` |
| 附件 | `attachment_outlined` | 审批 | `approval_outlined` |
## 彩色图标(精确 token
彩色图标必须从下表按**完整字符串**选择,禁止根据名称规律自行拼接。彩色 token 自带颜色,不要再推导其他后缀或变体。
| 含义 | token | 含义 | token |
|---|---|---|---|
| 日历 | `calendar_colorful` | 待办 | `todo_colorful` |
| 投票 | `vote_colorful` | 飞书妙记 | `file-lark-minutes_colorful` |
| 多维表格 | `wiki-bitable_colorful` | 表单 | `file-form_colorful` |
| 飞书社区 | `larkcommunity_colorful` | 招聘 | `hirelogo_colorful` |
| 飞书品牌 | `lark-logo_colorful` | Meego | `meego_colorful` |
| AI | `myai_colorful` | aPaaS | `apaas_colorful` |
| 审批 | `approval_colorful` | 通用 AI | `ai-common_colorful` |
> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准:
> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons

View File

@@ -101,9 +101,9 @@ metadata:
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口。**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口`summary.warning_count > 0` 时必须先做对应页面的截图复核**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**

View File

@@ -19,7 +19,7 @@
2.`slides +xml-get` 回读,确认是否已有部分页面写入。
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 `xml_text_overlap_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。

View File

@@ -25,19 +25,32 @@ lark-cli slides +xml-get --as user \
--json
```
## Automated XML Text Overlap Lint
## Automated XML Layout Lint
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。
```bash
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
```
通过标准:
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收
准出规则:
- `summary.error_count > 0``summary.release_ready == false`:阻断创建、替换或交付,必须先修复
- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。
- `slides[].status``blocked``needs_screenshot_review``passed`,可直接决定逐页后续动作。
- CLI 在存在 `error` 时退出码为 1只有 `warning` 时仍输出 JSON 并退出 0供截图复核链路继续执行。
每条 `error` / `warning` 都包含:
- `element_ids`:相关 XML 元素 ID
- `rule`:规则 ID、名称、阈值和比较关系
- `measurement`:越界量、交叠面积、覆盖率等实测值;
- `related_objects`:相关对象的类型与坐标框;
- `target``message``hint`:页码、语义说明和处理建议。
`sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。
常见 code 的处理方向:
@@ -51,6 +64,10 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
| `icon_transparent_fill_color` | `<icon>``fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |
## Screenshot QA

View File

@@ -188,6 +188,13 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<shadow>`
- `<content>`
`type` 常用取值:`text`(文本框)、`rect``round-rect`(圆角矩形)、`ellipse`(椭圆/圆)、`triangle``diamond``parallelogram``trapezoid``custom`(配合 `path` 属性写 SVG 路径串)。箭头、星形、标注气泡、`chevron``flow-chart-*` 等更多形状见 XSD `ShapeType` 枚举。
其它可选属性:
- `presetHandlers`:控制点,用于圆角等。例如 `<shape type="rect" presetHandlers="60">` = 圆角半径 60px 的圆角矩形;多个控制点用逗号分隔。
- `path`:仅 `type="custom"` 时使用SVG 路径串。
### line
```xml
@@ -198,6 +205,16 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`
### polyline
折线 / 曲线连接线,用外接矩形定位(`topLeftX` / `topLeftY` / `width` / `height`),不是端点坐标;`<border>` 必填(无 border 不可见)。`type` 默认 `bent-connector2`(可选 `bent-connector2-5` 折线 / `curved-connector2-5` 曲线)。
```xml
<polyline topLeftX="120" topLeftY="120" width="200" height="100">
<border color="rgb(43, 47, 54)" width="2"/>
</polyline>
```
### img
```xml
@@ -238,6 +255,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<colgroup>` 直接子元素只有 `<col width="...">`width 定义列宽,默认 110。
- `<tr height="...">` 直接子元素只有 `<td>`height 定义行高,默认 37。
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>``<img>``<icon>`
- 合并单元格:`<td>` 上用 `colspan`(跨列,默认 1`rowspan`(跨行,默认 1被合并覆盖的单元格不再写对应 `<td>`
表头默认的白底白字视觉效果极差,必须设置背景和文字颜色,需在首行每个 `<td>` 上加 `<fill>`(配合 `bold` 与对比文字色)与正文行区分。

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Validate Slides XML structure and page layout through one release gate."""
from __future__ import annotations
@@ -42,18 +43,25 @@ ROUNDTRIP_SXSD_ATTRS = {
("chart", "updated"),
("chartData", "isStaticData"),
}
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
# deck, so treating it as an unsupported tag would block per-slide linting document-wide.
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
class XmlTextOverlapLintError(Exception):
class XmlLayoutLintError(Exception):
pass
def fail(message: str) -> None:
raise XmlTextOverlapLintError(message)
raise XmlLayoutLintError(message)
def read_file(file_path: str | Path) -> str:
@@ -79,8 +87,12 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
def extract_attribute(tag_source: str, name: str) -> str | None:
match = re.search(fr'{re.escape(name)}="([^"]+)"', tag_source)
return match.group(1) if match else None
match = re.search(
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
)
if not match:
return None
return match.group(1) if match.group(1) is not None else match.group(2)
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
@@ -372,6 +384,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
tag_name = xml_local_name(element.tag)
current_path = f"{path}/{tag_name}" if path else tag_name
if tag_name in ROUNDTRIP_SXSD_TAGS:
return
if tag_name not in supported_tags:
issues.append(
{
@@ -632,8 +646,9 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
kind, attrs = match.group(1), match.group(2)
is_self_closing = attrs.rstrip().endswith("/")
content = ""
if kind in {"shape", "table"}:
if kind in {"shape", "table"} and not is_self_closing:
close_index = slide_xml.find(f"</{kind}>", match.end())
if close_index != -1:
content = slide_xml[match.end() : close_index]
@@ -1039,6 +1054,19 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
return vertical_overlap >= min_vertical_overlap
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
source, target = sorted([left, right], key=lambda element: element["x"])
visual_width = estimate_text_max_line_width(source)
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
width = intersection_width(source_visual_bbox, target)
height = intersection_height(source_visual_bbox, target)
return {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
}
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
if is_text_element(left) and not has_text_content(left):
return False
@@ -1166,9 +1194,6 @@ def detect_whiteboard_external_overlaps(
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
@@ -1194,12 +1219,7 @@ def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] == "text")
):
for element in elements:
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
@@ -1208,7 +1228,9 @@ def detect_elements_out_of_canvas(
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
}
overflow_details = [
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
f"{side} by {amount:g}px"
for side, amount in overflow.items()
if amount > CANVAS_OVERFLOW_TOLERANCE
]
if not overflow_details:
continue
@@ -1330,62 +1352,714 @@ def lint_slide(
"code": "bbox_overlap",
"elements": [left["id"], right["id"]],
"message": f'{left["id"]} overlaps {right["id"]}',
"hint": "Move or resize the elements so their visual bounds no longer intersect.",
**(
{"measurement": horizontal_text_overflow_measurement(left, right)}
if horizontal_overflow
else {}
),
}
)
return {"slide_number": slide_number, "element_count": len(elements), "issues": issues}
return {
"slide_number": slide_number,
"element_count": len(elements),
"elements": elements,
"issues": issues,
}
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
root, xml_error = parse_xml_root(xml)
if xml_error:
return {
"file": source_path,
"slide_size": {"width": 960, "height": 540},
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
"issues": [xml_error],
"slides": [],
}
namespace_issues = validate_sml_tag_prefixes(xml)
sxsd_issues = validate_sxsd_tag_attributes(root) if root is not None else []
iconpark_issues = validate_iconpark_icon_types(root) if root is not None else []
top_level_issues = [*namespace_issues, *sxsd_issues, *iconpark_issues]
if namespace_issues:
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
return {
"file": source_path,
"slide_size": {"width": 960, "height": 540},
"summary": {
"slide_count": 0,
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
},
"issues": top_level_issues,
"slides": [],
}
presentation = parse_presentation(xml)
slides = [
lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
for index, slide_xml in enumerate(presentation["slides"])
MIN_CONTAINER_WIDTH = 140
MIN_CONTAINER_HEIGHT = 160
MIN_SHORT_CARD_HEIGHT = 80
MIN_CONTAINER_AREA = 20_000
MIN_CONTENT_COVERAGE_RATIO = 0.15
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
MIN_SIMILAR_SHORT_CARD_COUNT = 2
LARGE_VISUAL_CHILD_RATIO = 0.35
LAYOUT_PANEL_SPAN_RATIO = 0.90
IMAGE_OVERLAY_MATCH_RATIO = 0.90
DENSITY_CONTAINMENT_TOLERANCE = 8
def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
left = max(element["x"], container["x"])
top = max(element["y"], container["y"])
right = min(element["x"] + element["width"], container["x"] + container["width"])
bottom = min(element["y"] + element["height"], container["y"] + container["height"])
if right <= left or bottom <= top:
return None
return {"x": left, "y": top, "width": right - left, "height": bottom - top}
def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
area = 0
for left, right in zip(x_coordinates, x_coordinates[1:]):
intervals = sorted(
(rect["y"], rect["y"] + rect["height"])
for rect in rectangles
if rect["x"] < right and rect["x"] + rect["width"] > left
)
covered_height = 0
interval_end: int | float | None = None
for top, bottom in intervals:
if interval_end is None:
covered_height += bottom - top
interval_end = bottom
elif bottom > interval_end:
covered_height += bottom - max(top, interval_end)
interval_end = bottom
area += (right - left) * covered_height
return area
def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
return sum(
other is not element
and is_visually_rendered(other)
and other["kind"] == "shape"
and other["type"] == "rect"
and other["width"] >= MIN_CONTAINER_WIDTH
and other["height"] >= MIN_SHORT_CARD_HEIGHT
and element_area(other) >= MIN_CONTAINER_AREA
and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
for other in elements
) >= MIN_SIMILAR_SHORT_CARD_COUNT
def is_layout_container(
element: dict[str, Any],
slide_width: int | float,
slide_height: int | float,
elements: list[dict[str, Any]] | None = None,
) -> bool:
has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
elements is not None
and element["height"] >= MIN_SHORT_CARD_HEIGHT
and has_similar_short_card_peer(element, elements)
)
return (
element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and element["width"] >= MIN_CONTAINER_WIDTH
and has_supported_height
and element_area(element) >= MIN_CONTAINER_AREA
and not (
element["x"] <= 2
and element["y"] <= 2
and element["width"] >= slide_width - 4
and element["height"] >= slide_height - 4
)
)
def is_edge_spanning_layout_panel(
element: dict[str, Any], slide_width: int | float, slide_height: int | float
) -> bool:
touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
)
def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
container_area = element_area(container)
return any(
element["kind"] == "img"
and is_visually_rendered(element)
and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
for element in elements
)
def is_nested_in_layout_panel(
container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> bool:
return any(
element is not container
and element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and is_edge_spanning_layout_panel(element, slide_width, slide_height)
and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
for element in elements
)
def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
elements = extract_elements(slide_xml)
elements_by_id = {element["id"]: element for element in elements}
root = ET.fromstring(slide_xml)
for node in root.iter():
if xml_local_name(node.tag) != "shape":
continue
element = elements_by_id.get(node.attrib.get("id", ""))
if element is None:
continue
content_node = next(
(child for child in node if xml_local_name(child.tag) == "content"),
None,
)
paragraphs = (
[
" ".join("".join(paragraph.itertext()).split())
for paragraph in content_node.iter()
if xml_local_name(paragraph.tag) == "p"
]
if content_node is not None
else []
)
raw_font_size = (
content_node.attrib.get("fontSize") if content_node is not None else None
) or node.attrib.get("fontSize")
try:
base_font_size = float(raw_font_size or 16)
except ValueError:
base_font_size = 16.0
element.update(
{
"textType": content_node.attrib.get("textType") if content_node is not None else None,
"textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
"autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
"fontSize": base_font_size,
"text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
}
)
if not has_text_content(element):
continue
declared_font_sizes = []
for descendant in node.iter():
raw_declared_font_size = descendant.attrib.get("fontSize")
if raw_declared_font_size is None:
continue
try:
declared_font_sizes.append(float(raw_declared_font_size))
except ValueError:
continue
if declared_font_sizes:
element["fontSize"] = max(declared_font_sizes)
for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
attrs = match.group(1)
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
if any(value is None for value in (x, y, width, height)):
continue
icon_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
"kind": "icon",
"type": "icon",
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
"alpha": icon_alpha if icon_alpha is not None else 1,
"order": len(elements),
}
)
for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
attrs = match.group(1)
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
if any(value is None for value in (x, y, width, height)):
continue
polyline_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
"kind": "polyline",
"type": "polyline",
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
"alpha": polyline_alpha if polyline_alpha is not None else 1,
"order": len(elements),
}
)
for line_element in extract_line_elements(slide_xml):
line_element["order"] = len(elements)
elements.append(line_element)
return elements
def is_visually_rendered(element: dict[str, Any]) -> bool:
return element.get("alpha", 1) > 0
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
if is_text_element(element):
estimated = estimate_text_visual_bbox(element)
return clipped_bbox(estimated, container) if estimated else None
return clipped_bbox(element, container)
def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
if container["kind"] != "shape" or not has_text_content(container):
return None
text_proxy = {**container, "type": "text"}
estimated = estimate_text_visual_bbox(text_proxy)
return clipped_bbox(estimated, container) if estimated else None
def slide_content_visual_bbox(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
if is_text_element(element):
estimated = estimate_text_visual_bbox(element)
return clipped_bbox(estimated, slide_bbox) if estimated else None
if element["kind"] == "shape" and has_text_content(element):
estimated = own_text_visual_bbox(element)
return clipped_bbox(estimated, slide_bbox) if estimated else None
if element["kind"] == "line":
# a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
# treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
return clipped_bbox(line_stroke_bbox(element), slide_bbox)
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
return clipped_bbox(element, slide_bbox)
return None
def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
def is_slide_content_present(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> bool:
# Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
# *anything* rendered here", not the richer "counts toward meaningful content density" bar
# that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
# decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
# count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
# instead of maintaining an allow-list that silently treats unlisted kinds as blank.
if not is_visually_rendered(element):
return False
if (
element["kind"] == "shape"
and element["type"] == "rect"
and not has_text_content(element)
and element["x"] <= 2
and element["y"] <= 2
and element["width"] >= slide_bbox["width"] - 4
and element["height"] >= slide_bbox["height"] - 4
):
# A full-canvas plain rect is a background panel, not content -- same reasoning as
# is_layout_container's existing background exclusion. A slide with nothing else on it
# is still effectively blank.
return False
bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
return clipped_bbox(bbox, slide_bbox) is not None
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
return False
if not is_visually_rendered(element):
return False
return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO
def detect_sparse_container_content(
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for container in (
element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
):
if (
is_edge_spanning_layout_panel(container, slide_width, slide_height)
or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
or has_matching_image_overlay(container, elements)
):
continue
children = [
element
for element in elements
if element is not container
and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
]
if any(is_large_visual_child(child, container) for child in children):
continue
own_text_bbox = own_text_visual_bbox(container)
rectangles = ([own_text_bbox] if own_text_bbox else []) + [
bbox for child in children if (bbox := visual_bbox(child, container)) is not None
]
content_area = rectangle_union_area(rectangles) if rectangles else 0
coverage_ratio = content_area / element_area(container)
if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
continue
issues.append(
{
"level": "warning",
"code": "sparse_container_content",
"target": {
"slide_number": slide_number,
"container_id": container["id"],
"container_type": container["type"],
"bbox": {key: container[key] for key in ("x", "y", "width", "height")},
},
"rule": {
"name": "large_container_visible_content_coverage",
"threshold": MIN_CONTENT_COVERAGE_RATIO,
"comparison": "content_coverage_ratio < threshold",
},
"measurement": {
"container_area": element_area(container),
"visible_content_area": round(content_area, 3),
"content_coverage_ratio": round(coverage_ratio, 3),
"content_element_count": len(children) + (1 if own_text_bbox else 0),
},
"elements": [container["id"], *[child["id"] for child in children]],
}
)
return issues
def detect_sparse_slide_content(
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
content = [
(element, bbox)
for element in elements
if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
]
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
result = {
if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
return []
content_area = rectangle_union_area([bbox for _, bbox in content])
slide_area = slide_width * slide_height
coverage_ratio = content_area / slide_area
if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
return []
return [
{
"level": "warning",
"code": "sparse_slide_content",
"target": {
"slide_number": slide_number,
"bbox": slide_bbox,
},
"rule": {
"name": "slide_visible_content_coverage",
"threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
"comparison": "content_coverage_ratio < threshold",
},
"measurement": {
"slide_area": slide_area,
"visible_content_area": round(content_area, 3),
"content_coverage_ratio": round(coverage_ratio, 3),
"content_element_count": len(content),
},
"elements": [element["id"] for element, _ in content],
}
]
def detect_blank_slide(
elements: list[dict[str, Any]],
slide_number: int,
slide_width: int | float,
slide_height: int | float,
) -> list[dict[str, Any]]:
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
visible_elements = [
element for element in elements if is_slide_content_present(element, slide_bbox)
]
if visible_elements:
return []
return [
{
"level": "error",
"code": "blank_slide",
"schema_version": "2.0",
"target": {"slide_number": slide_number},
"rule": {
"name": "slide_has_visible_content",
"comparison": "visible_element_count == 0",
},
"measurement": {
"visible_element_count": 0,
"declared_element_count": len(elements),
},
"elements": [element["id"] for element in elements],
"message": "slide has no visible content beyond empty layout shapes",
"hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
}
]
RULE_METADATA: dict[str, dict[str, Any]] = {
"xml_not_well_formed": {
"name": "xml_is_well_formed",
"comparison": "xml_parse_error == false",
},
"sml_prefixed_tag": {
"name": "sml_uses_default_namespace",
"comparison": "prefixed_sml_tag_count == 0",
},
"sxsd_unsupported_tag": {
"name": "tag_is_supported_by_slides_xml_schema",
"comparison": "unsupported_tag_count == 0",
},
"sxsd_unsupported_attr": {
"name": "attribute_is_supported_by_slides_xml_schema",
"comparison": "unsupported_attribute_count == 0",
},
"icon_missing_fill_color": {
"name": "icon_has_visible_fill_color",
"comparison": "fill_color_present == true",
},
"icon_transparent_fill_color": {
"name": "icon_has_visible_fill_color",
"comparison": "fill_alpha > 0",
},
"iconpark_unsupported_icon_type": {
"name": "iconpark_type_is_supported",
"comparison": "icon_type in iconpark_index",
},
"bbox_overlap": {
"name": "text_visual_bounds_do_not_overlap",
"comparison": "intersection_area == 0",
},
"text_may_overflow_shape": {
"name": "estimated_text_fits_declared_shape",
"comparison": "estimated_height <= available_height",
},
"whiteboard_external_overlap": {
"name": "whiteboard_does_not_cross_sibling_content",
"comparison": "external_overlap_count == 0",
},
"image_covers_text": {
"name": "image_does_not_cover_text",
"comparison": "intersection_area == 0",
},
"image_may_cover_vertical_text": {
"name": "image_vertical_text_occlusion_requires_review",
"comparison": "intersection_area == 0",
},
"table_resolved_size_mismatch": {
"name": "table_declared_size_matches_resolved_grid",
"comparison": "declared_size == resolved_size",
},
"blank_slide": {
"name": "slide_has_visible_content",
"comparison": "visible_element_count > 0",
},
}
def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
if issue.get("rule"):
return {**issue["rule"], "id": issue["code"]}
if issue["code"].endswith("_out_of_canvas"):
return {
"id": issue["code"],
"name": "element_stays_within_slide_canvas",
"comparison": "max(left, top, right, bottom overflow) == 0",
}
return {
"id": issue["code"],
**RULE_METADATA.get(
issue["code"],
{"name": issue["code"], "comparison": "violation_count == 0"},
),
}
def issue_measurement(
issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
) -> dict[str, Any]:
if issue.get("measurement") is not None:
return issue["measurement"]
if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
left = elements_by_id.get(issue["elements"][0])
right = elements_by_id.get(issue["elements"][1])
if left and right:
left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
width = intersection_width(left_box, right_box)
height = intersection_height(left_box, right_box)
return {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
}
if issue["code"].endswith("_out_of_canvas"):
return {
"canvas": issue.get("canvas"),
"bbox": issue.get("bbox"),
"overflow": issue.get("overflow"),
}
measurement_keys = (
"line",
"column",
"tag",
"attr",
"iconType",
"line_count",
"line_height",
"estimated_height",
"available_height",
"overflow",
"dimension",
"declared_size",
"resolved_size",
"resolved_sizes",
"overlaps",
)
measured = {key: issue[key] for key in measurement_keys if key in issue}
return measured or {"violation_count": 1}
def related_object(element: dict[str, Any]) -> dict[str, Any]:
return {
"element_id": element["id"],
"kind": element["kind"],
"type": element["type"],
"bbox": {key: element[key] for key in ("x", "y", "width", "height")},
}
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
elements: list[dict[str, Any]] = []
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
attrs = match.group(1)
start_x = extract_numeric_attribute(attrs, "startX")
start_y = extract_numeric_attribute(attrs, "startY")
end_x = extract_numeric_attribute(attrs, "endX")
end_y = extract_numeric_attribute(attrs, "endY")
if any(value is None for value in (start_x, start_y, end_x, end_y)):
continue
line_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
"kind": "line",
"type": "line",
"x": min(start_x, end_x),
"y": min(start_y, end_y),
"width": abs(end_x - start_x),
"height": abs(end_y - start_y),
"rotation": 0,
"alpha": line_alpha if line_alpha is not None else 1,
"order": len(elements),
}
)
return elements
def normalize_issue(
issue: dict[str, Any],
slide_number: int | None,
elements_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
normalized = dict(issue)
if normalized.get("level") == "info":
normalized["level"] = "warning"
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
normalized["schema_version"] = "2.0"
normalized["element_ids"] = element_ids
normalized["target"] = {
**({"slide_number": slide_number} if slide_number is not None else {}),
**normalized.get("target", {}),
}
normalized["rule"] = issue_rule(normalized)
normalized["measurement"] = issue_measurement(normalized, elements_by_id)
normalized["related_objects"] = [
related_object(elements_by_id[element_id])
for element_id in element_ids
if element_id in elements_by_id
]
if normalized["code"] == "sparse_container_content":
ratio = normalized["measurement"]["content_coverage_ratio"]
threshold = normalized["rule"]["threshold"]
container_id = normalized["target"].get("container_id", "unknown")
normalized.setdefault(
"message",
f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
)
normalized.setdefault(
"hint",
"Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
)
elif normalized["code"] == "sparse_slide_content":
ratio = normalized["measurement"]["content_coverage_ratio"]
threshold = normalized["rule"]["threshold"]
normalized.setdefault(
"message",
f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
)
normalized.setdefault(
"hint",
"Review the rendered screenshot to decide whether the page is intentionally sparse.",
)
else:
normalized.setdefault("message", normalized["code"].replace("_", " "))
normalized.setdefault(
"hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
)
return normalized
def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
if errors:
return "blocked"
if warnings:
return "needs_screenshot_review"
return "passed"
def build_result(
source_path: str | None,
slide_size: dict[str, int | float],
top_level_issues: list[dict[str, Any]],
slides: list[dict[str, Any]],
) -> dict[str, Any]:
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
status = slide_status(all_errors, all_warnings)
result: dict[str, Any] = {
"schema_version": "2.0",
"tool": "xml_text_overlap_lint",
"file": source_path,
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
"slide_size": slide_size,
"summary": {
"slide_count": len(slides),
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
"status": status,
"release_ready": error_count == 0,
"screenshot_review_required": warning_count > 0,
},
"document": {
"errors": document_errors,
"warnings": document_warnings,
},
"slides": slides,
}
@@ -1394,6 +2068,107 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
return result
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
root, xml_error = parse_xml_root(xml)
if xml_error:
issue = normalize_issue(xml_error, None, {})
return build_result(
source_path,
{"width": 960, "height": 540},
[issue],
[],
)
if root is None:
raise AssertionError("parse_xml_root must return a root or error")
namespace_issues = validate_sml_tag_prefixes(xml)
sxsd_issues = validate_sxsd_tag_attributes(root)
iconpark_issues = validate_iconpark_icon_types(root)
top_level_issues = [
normalize_issue(issue, None, {})
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
]
if any(issue["level"] == "error" for issue in top_level_issues):
return build_result(
source_path,
{"width": 960, "height": 540},
top_level_issues,
[],
)
presentation = parse_presentation(xml)
slides: list[dict[str, Any]] = []
for index, slide_xml in enumerate(presentation["slides"]):
slide_number = index + 1
geometry = lint_slide(
slide_xml,
slide_number,
presentation["width"],
presentation["height"],
)
density_elements = extract_density_elements(slide_xml)
extra_elements = [
element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
]
elements_by_id = {
element["id"]: element for element in [*density_elements, *extra_elements]
}
# geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
# decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
# with whatever actually triggered the issue, instead of density_elements' separate re-parse.
elements_by_id.update({element["id"]: element for element in geometry["elements"]})
extra_overflow_issues = detect_elements_out_of_canvas(
extra_elements,
presentation["width"],
presentation["height"],
)
raw_issues = [
*geometry["issues"],
*extra_overflow_issues,
*detect_blank_slide(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
*detect_sparse_container_content(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
*detect_sparse_slide_content(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
]
issues = [
normalize_issue(issue, slide_number, elements_by_id)
for issue in raw_issues
]
errors = [issue for issue in issues if issue["level"] == "error"]
warnings = [issue for issue in issues if issue["level"] == "warning"]
slides.append(
{
"slide_number": slide_number,
"status": slide_status(errors, warnings),
"element_count": len(elements_by_id),
"errors": errors,
"warnings": warnings,
"issues": issues,
}
)
return build_result(
source_path,
{"width": presentation["width"], "height": presentation["height"]},
top_level_issues,
slides,
)
def print_usage() -> None:
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
@@ -1416,6 +2191,6 @@ def run_cli(argv: list[str] | None = None) -> None:
if __name__ == "__main__":
try:
run_cli()
except XmlTextOverlapLintError as error:
except XmlLayoutLintError as error:
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
raise SystemExit(1) from error

View File

@@ -12,8 +12,8 @@ from pathlib import Path
import xml_text_overlap_lint
class XmlTextOverlapLintTest(unittest.TestCase):
def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None:
class XmlTextOverlapLintGeometryTest(unittest.TestCase):
def assertNoXmlTextOverlapLintErrors(self, result: dict, sample_name: str) -> None:
issue_summaries = []
for slide in result.get("slides", []):
for issue in slide.get("issues", []):
@@ -28,11 +28,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
0,
f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
)
self.assertEqual(
result["summary"]["warning_count"],
0,
f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
)
def test_cli_suggests_input_flag_for_positional_argument(self) -> None:
script_path = Path(xml_text_overlap_lint.__file__).resolve()
@@ -49,7 +44,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(completed.stdout, "")
self.assertEqual(
completed.stderr,
f"xml-text-overlap-lint error: unexpected argument: {input_path}need --input\n",
f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n",
)
def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
@@ -101,7 +96,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
sample_xml,
sample_name,
)
self.assertNoXmlTextOverlapLintIssues(result, sample_name)
self.assertNoXmlTextOverlapLintErrors(result, sample_name)
def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -221,7 +216,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
def test_lint_xml_single_slide_reports_out_of_canvas_and_blank_slide_errors(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -235,8 +230,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
self.assertEqual(result["summary"]["slide_count"], 1)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["errors"]],
["shape_out_of_canvas", "blank_slide"],
)
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -618,8 +616,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "bbox_overlap")
self.assertEqual(issue["elements"], ["source", "target"])
self.assertGreater(issue["measurement"]["intersection_area"], 0)
self.assertIsNotNone(issue.get("hint"))
def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -752,7 +753,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
"第一行\n第二行\n第三行",
)
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -770,8 +771,9 @@ class XmlTextOverlapLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
elements = xml_text_overlap_lint.extract_elements(
@@ -805,7 +807,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -817,9 +819,10 @@ class XmlTextOverlapLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -832,9 +835,13 @@ class XmlTextOverlapLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["errors"]],
["img_out_of_canvas", "img_out_of_canvas"],
)
def test_lint_xml_allows_full_bleed_images(self) -> None:
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -846,7 +853,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -863,15 +871,36 @@ class XmlTextOverlapLintTest(unittest.TestCase):
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
[
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
],
)
def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content fontSize="18"><p>Visible content</p></content>
</shape>
<line id="connector" startX="80" startY="120" endX="980" endY="120"/>
</data>
</slide>
"""
)
issue = result["slides"][0]["errors"][0]
self.assertEqual(issue["code"], "line_out_of_canvas")
self.assertEqual(issue["element_ids"], ["connector"])
self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
self.assertEqual(issue["related_objects"][0]["kind"], "line")
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -893,6 +922,27 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
</presentation>
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1057,9 +1107,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "info")
self.assertEqual(result["summary"]["warning_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "warning")
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
@@ -1084,7 +1133,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
@@ -1153,7 +1201,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
}
script_path = Path(xml_text_overlap_lint.__file__).resolve()
with tempfile.TemporaryDirectory() as temp_dir:
for name, (table_xml, expected_info_count) in cases.items():
for name, (table_xml, expected_warning_count) in cases.items():
with self.subTest(case=name):
input_path = Path(temp_dir) / f"{name}.xml"
input_path.write_text(
@@ -1173,10 +1221,9 @@ class XmlTextOverlapLintTest(unittest.TestCase):
result = json.loads(completed.stdout)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], expected_info_count)
self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
self.assertTrue(
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
result["slides"][0]["issues"],
)
@@ -1209,7 +1256,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None:
def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
@@ -1221,9 +1268,710 @@ class XmlTextOverlapLintTest(unittest.TestCase):
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
self.assertEqual(issue["level"], "info")
self.assertEqual(issue["level"], "warning")
self.assertEqual(result["summary"]["error_count"], 0)
class XmlTextOverlapLintDensityTest(unittest.TestCase):
def test_lint_xml_blocks_blank_slide(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide id="content-slide">
<data>
<shape id="title" type="text" topLeftX="60" topLeftY="60" width="400" height="50">
<content fontSize="28"><p>Investment report</p></content>
</shape>
</data>
</slide>
<slide id="blank-slide">
<style><fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></style>
<data/>
<note><content/></note>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["slide_count"], 2)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["status"], "blocked")
self.assertFalse(result["summary"]["release_ready"])
self.assertEqual(result["slides"][0]["issues"], [])
self.assertEqual(result["slides"][1]["element_count"], 0)
issue = result["slides"][1]["errors"][0]
self.assertEqual(issue["level"], "error")
self.assertEqual(issue["code"], "blank_slide")
self.assertEqual(issue["element_ids"], [])
self.assertEqual(issue["rule"]["id"], "blank_slide")
self.assertEqual(issue["measurement"]["visible_element_count"], 0)
self.assertEqual(issue["related_objects"], [])
def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img id="ghost" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
issue = result["slides"][0]["errors"][0]
self.assertEqual(issue["code"], "blank_slide")
def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="trend-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
<shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
<content fontSize="15"><p>Core trends</p></content>
</shape>
<shape id="trend-copy" type="text" topLeftX="515" topLeftY="177" width="380" height="315">
<content fontSize="12"><p>First point</p><p>Second point</p><p>Third point</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "sparse_container_content")
self.assertEqual(issue["target"]["container_id"], "trend-card")
self.assertEqual(issue["target"], {
"slide_number": 1,
"container_id": "trend-card",
"container_type": "rect",
"bbox": {"x": 500, "y": 135, "width": 410, "height": 370},
})
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15)
self.assertEqual(issue["rule"], {
"name": "large_container_visible_content_coverage",
"threshold": 0.15,
"comparison": "content_coverage_ratio < threshold",
"id": "sparse_container_content",
})
self.assertEqual(issue["measurement"]["container_area"], 151700)
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(
[obj["element_id"] for obj in issue["related_objects"]],
["trend-card", "trend-title", "trend-copy"],
)
self.assertEqual(result["slides"][0]["status"], "needs_screenshot_review")
self.assertEqual(result["slides"][0]["warnings"], result["slides"][0]["issues"])
def test_lint_xml_warns_for_sparse_short_cards(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card-1" type="rect" topLeftX="60" topLeftY="180" width="400" height="105"/>
<shape id="text-1" type="text" topLeftX="80" topLeftY="220" width="360" height="30">
<content fontSize="14"><p>期待认识大家</p></content>
</shape>
<shape id="card-2" type="rect" topLeftX="490" topLeftY="180" width="400" height="105"/>
<shape id="text-2" type="text" topLeftX="510" topLeftY="220" width="360" height="30">
<content fontSize="14"><p>化学一起讨论</p></content>
</shape>
<shape id="card-3" type="rect" topLeftX="60" topLeftY="310" width="400" height="105"/>
<shape id="text-3" type="text" topLeftX="80" topLeftY="350" width="360" height="30">
<content fontSize="14"><p>吉他随时交流</p></content>
</shape>
<shape id="card-4" type="rect" topLeftX="490" topLeftY="310" width="400" height="105"/>
<shape id="text-4" type="text" topLeftX="510" topLeftY="350" width="360" height="30">
<content fontSize="14"><p>共度美好四年</p></content>
</shape>
</data>
</slide>
"""
)
container_issues = [
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
]
self.assertEqual(
[issue["target"]["container_id"] for issue in container_issues],
["card-1", "card-2", "card-3", "card-4"],
)
self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues))
self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues))
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["issues"]],
[
"sparse_container_content",
"sparse_container_content",
"sparse_container_content",
"sparse_container_content",
"sparse_slide_content",
],
)
def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
<content fontSize="14"><p>One short line</p></content>
</shape>
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
<content fontSize="14"><p>Another line</p></content>
</shape>
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
<content fontSize="14"><p>Third line</p></content>
</shape>
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
<content fontSize="14"><p>Fourth line</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"]
self.assertEqual(len(issues), 1)
issue = issues[0]
self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540})
self.assertEqual(issue["rule"]["threshold"], 0.035)
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035)
self.assertEqual(issue["measurement"]["content_element_count"], 4)
self.assertNotIn("background", issue["elements"])
def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="summary-bar" type="rect" topLeftX="52" topLeftY="82" width="856" height="105"/>
<shape id="summary" type="text" topLeftX="72" topLeftY="115" width="816" height="30">
<content fontSize="14"><p>One concise summary</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="18">
<p>被吊物</p>
<p><span fontSize="36">32.0 t</span></p>
<p>钢结构模块</p>
</content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="18">
<p>被吊物</p>
<p>32.0 t</p>
<p>钢结构模块</p>
</content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
self.assertEqual(issue["measurement"]["content_element_count"], 1)
self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0)
def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="sparse-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["target"]["container_id"], "sparse-card")
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
self.assertEqual(issue["measurement"]["content_element_count"], 1)
self.assertEqual(issue["elements"], ["sparse-card"])
def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None:
self_only = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
</data>
</slide>
"""
)
with_overlapping_child = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
<shape id="child" type="text" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p>A</p></content>
</shape>
</data>
</slide>
"""
)
self_issue = self_only["slides"][0]["issues"][0]
mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
self.assertEqual(
mixed_issue["measurement"]["visible_content_area"],
self_issue["measurement"]["visible_content_area"],
)
self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2)
def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None:
elements = xml_text_overlap_lint.extract_density_elements(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
<content fontSize="12"><p><span fontSize="36">32.0 t</span></p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(elements[0]["fontSize"], 36)
def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None:
elements = xml_text_overlap_lint.extract_density_elements(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
<shape id="title" type="text" topLeftX="80" topLeftY="160" width="180" height="30">
<content fontSize="18"><p>Following title</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(elements[0]["text"], "")
self.assertEqual(elements[1]["text"], "Following title")
def test_lint_xml_allows_container_with_large_visual_child(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
<content fontSize="20"><p>Section title</p></content>
</shape>
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0"/>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["target"]["container_id"], "chart-card")
def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="letter-placeholder" type="rect" topLeftX="520" topLeftY="180" width="200" height="200"/>
<shape id="letter" type="text" topLeftX="540" topLeftY="250" width="160" height="70">
<content fontSize="46"><p>Z</p></content>
</shape>
<shape id="empty-placeholder" type="rect" topLeftX="744" topLeftY="180" width="144" height="200"/>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(
[issue["target"]["container_id"] for issue in issues],
["letter-placeholder", "empty-placeholder"],
)
self.assertEqual(issues[1]["measurement"]["content_element_count"], 0)
def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="70" topLeftY="184" width="260" height="288"/>
<shape id="title" type="text" topLeftX="90" topLeftY="215" width="220" height="30">
<content fontSize="18"><p>梦境与现实</p></content>
</shape>
<shape id="copy" type="text" topLeftX="90" topLeftY="330" width="220" height="70">
<content fontSize="13"><p>边界溶解,逻辑失效。观众被拽入潜意识的迷宫。</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["target"]["container_id"], "card")
self.assertEqual(issue["rule"]["threshold"], 0.15)
def test_lint_xml_allows_image_overlay_rect(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img id="hero" topLeftX="560" topLeftY="0" width="400" height="540"/>
<shape id="tint" type="rect" topLeftX="560" topLeftY="0" width="400" height="540"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
<content fontSize="20"><p>Section title</p></content>
</shape>
<shape id="card" type="rect" topLeftX="330" topLeftY="120" width="300" height="300"/>
<img id="ghost-overlay" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["target"]["container_id"], "card")
def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="panel" type="rect" topLeftX="600" topLeftY="0" width="360" height="540"/>
<shape id="decoration" type="rect" topLeftX="660" topLeftY="150" width="240" height="240"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_counts_icons_as_visible_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_count_transparent_icon_as_visible_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
<content fontSize="20"><p>Section title</p></content>
</shape>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180" alpha="0">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["target"]["container_id"], "card")
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0)
def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="70" height="70">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["target"]["container_id"], "card")
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122)
self.assertEqual(issue["rule"]["threshold"], 0.15)
def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="100" height="100">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</icon>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
<shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
<content fontSize="12.4"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<line id="l1" startX="100" startY="100" endX="800" endY="100"/>
<line id="l2" startX="100" startY="200" endX="800" endY="200"/>
<line id="l3" startX="100" startY="300" endX="800" endY="300"/>
<line id="l4" startX="100" startY="400" endX="800" endY="400"/>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_reports_bbox_overlap_measurement_from_decision_time_visual_bbox(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="left" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
<content fontSize="14"><p>overlap text <span fontSize="96">big</span></p></content>
</shape>
<shape id="right" type="text" topLeftX="80" topLeftY="80" width="300" height="80">
<content fontSize="14"><p>other overlap text</p></content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "bbox_overlap")
# Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
# from extract_elements), not the fontSize=96 max-descendant value that
# extract_density_elements computes for the same "left" element id.
self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
card_b = {"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100}
card_c = {"kind": "shape", "type": "rect", "x": 0, "y": 200, "width": 300, "height": 100}
self.assertFalse(
xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b])
)
self.assertTrue(
xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b, card_c])
)
def test_lint_xml_reports_schema_version_2_for_sparse_issues(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
</data>
</slide>
"""
)
issue = next(
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
)
self.assertEqual(issue["schema_version"], "2.0")
def test_lint_xml_does_not_report_blank_slide_for_textless_decorative_shapes(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="deco1" type="ellipse" topLeftX="60" topLeftY="60" width="300" height="300">
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
</shape>
<shape id="deco2" type="triangle" topLeftX="500" topLeftY="200" width="200" height="200">
<fill><fillColor color="rgba(220, 38, 38, 1)"/></fill>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_still_warns_for_sparse_slide_content_despite_full_bleed_background(self) -> None:
# A plain textless shape now counts as "not blank" (see the test above), but a
# full-bleed background rect must still NOT count toward sparse_slide_content's
# meaningful-content coverage ratio -- otherwise every slide with a background would
# trivially "pass" that density check.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
<content fontSize="14"><p>One short line</p></content>
</shape>
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
<content fontSize="14"><p>Another line</p></content>
</shape>
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
<content fontSize="14"><p>Third line</p></content>
</shape>
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
<content fontSize="14"><p>Fourth line</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertIn("sparse_slide_content", codes)
def test_lint_xml_accepts_whitespace_around_attribute_equals_sign(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="visible" type="text" topLeftX = "80" topLeftY = "80" width = "300" height = "60">
<content><p>hello</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["slides"][0]["element_count"], 1)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_reports_blank_slide_for_full_canvas_background_only(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540">
<fill><fillColor color="rgba(240, 235, 220, 1)"/></fill>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertIn("blank_slide", codes)
def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None:
visible_card = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
ghost_1 = {
"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100, "alpha": 0,
}
ghost_2 = {
"kind": "shape", "type": "rect", "x": 800, "y": 0, "width": 300, "height": 100, "alpha": 0,
}
self.assertFalse(
xml_text_overlap_lint.has_similar_short_card_peer(
visible_card, [visible_card, ghost_1, ghost_2]
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -38,6 +38,13 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
> **从任务清单定位并修改任务的最短路径**
> 1. 已知任务清单 GUID 时直接使用,不要先搜索;已知任务清单 applink 时,取 URL query 中的 `guid` 作为 `tasklist_guid`。
> 2. 只有清单名称或关键词、没有 GUID/applink 时,才调用一次 `+tasklist-search` 解析目标清单。
> 3. 按原生 API 规则先执行 `lark-cli schema task.tasklists.tasks`,再执行 `lark-cli task tasklists tasks --params '{"tasklist_guid":"<tasklist_guid>"}' --as user`。
> 4. 从清单任务结果中取任务的 `guid`,直接传给 `+update` 或 `+complete`;禁止传客户端展示编号(例如 `t104121`)。这两个 shortcut 也可直接接收包含 `guid=` 的任务 applink。
> 5. `+update` 返回 `updated_fields` 和每个任务的服务端 `confirmed` 字段;`+complete` 返回 `status`、`completed_at`、`already_completed`。这些字段已确认目标状态时,不要例行追加 `tasks get`;仅在服务端未返回所需字段或用户明确要求完整复核时再查询详情。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |

View File

@@ -9,19 +9,23 @@ Mark a task as completed.
```bash
# Complete a task
lark-cli task +complete --task-id "<task_guid>"
# A task applink is accepted directly; the CLI extracts its guid query value
lark-cli task +complete --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>"
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid>` | Yes | The task GUID to complete. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
## Workflow
1. Confirm the task to complete.
2. Execute the command.
3. Report success.
3. Read `data.status`, `data.completed_at`, and `data.already_completed` from the result. `already_completed: true` means the shortcut observed an already-completed task and skipped the PATCH.
4. Do not routinely call `task tasks get` when the result already reports `status: done` and a non-zero `completed_at`. Query details only if confirmation fields are absent or the user explicitly asks for a full verification.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -13,6 +13,9 @@ lark-cli task +update --task-id "<task_guid>" --summary "New Summary"
# Update multiple tasks' due dates
lark-cli task +update --task-id "<task_guid>,<another_task_guid>" --due "+2d"
# A task applink is accepted directly; the CLI extracts its guid query value
lark-cli task +update --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>" --summary "New Summary"
# Update with JSON data
lark-cli task +update --task-id "<task_guid>" --data '{"description": "New description"}'
```
@@ -21,7 +24,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid>` | Yes | The task GUID to update. Comma-separated task GUIDs are supported for multiple tasks. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Comma-separated GUIDs/applinks are supported for multiple tasks. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
| `--summary <text>` | No | New summary/title for the task. |
| `--description <text>` | No | New description for the task. |
| `--due <time>` | No | New due date (supports relative time). |
@@ -31,7 +34,8 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
1. Confirm with the user the tasks to update and the fields.
2. Execute `lark-cli task +update --task-id "..." ...`
3. Report the successful updates.
3. Read `data.updated_fields` and `data.tasks[].confirmed` from the result and report only the fields confirmed by the server.
4. Do not routinely call `task tasks get` after the update when `confirmed` already contains the required state. Query details only if a required field is absent or the user explicitly asks for a full verification.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath(t *testing.T) {
setAppsDryRunEnv(t)
absolutePath := filepath.Join(t.TempDir(), "report.pdf")
require.NoError(t, os.WriteFile(absolutePath, []byte("dry-run-input"), 0o600))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+file-upload",
"--app-id", "app_x",
"--file", absolutePath,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "report.pdf", clie2e.DryRunGet(result.Stdout, "api.0.body.file_name").String())
}
func TestAppsFileUploadDryRun_RejectsMissingHostPath(t *testing.T) {
setAppsDryRunEnv(t)
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "report.pdf")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+file-upload",
"--app-id", "app_x",
"--file", missingAbsolutePath,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--file", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsFileUploadLiveWorkflow(t *testing.T) {
if strings.TrimSpace(os.Getenv("LARKSUITE_CLI_CONFIG_DIR")) == "" {
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated live-test config")
}
appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_FILE_APP_ID"))
if appID == "" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_FILE_APP_ID to a dedicated app for upload/delete testing")
}
fileName := fmt.Sprintf("lark-cli-host-path-e2e-%d.txt", time.Now().UnixNano())
absolutePath := filepath.Join(t.TempDir(), fileName)
content := []byte("host-path-live-e2e")
require.NoError(t, os.WriteFile(absolutePath, content, 0o600))
remotePath := ""
t.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
if remotePath == "" {
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+file-list", "--app-id", appID, "--name", fileName},
DefaultAs: "user",
})
if listErr != nil || listResult.ExitCode != 0 {
clie2e.ReportCleanupFailure(t, "find uploaded file "+fileName, listResult, listErr)
return
}
for _, item := range gjson.Get(listResult.Stdout, "data.items").Array() {
if item.Get("file_name").String() == fileName {
remotePath = item.Get("path").String()
break
}
}
}
if remotePath == "" {
return
}
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+file-delete", "--app-id", appID, "--path", remotePath},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "delete uploaded file "+remotePath, deleteResult, deleteErr)
if deleteErr == nil && deleteResult != nil && gjson.Get(deleteResult.Stdout, "data.results.0.status").String() != "ok" {
t.Errorf("cleanup delete did not report success: %s", deleteResult.Stdout)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+file-upload", "--app-id", appID, "--file", absolutePath},
DefaultAs: "user",
})
require.NoError(t, err)
uploadResult.AssertExitCode(t, 0)
uploadResult.AssertStdoutStatus(t, true)
remotePath = gjson.Get(uploadResult.Stdout, "data.path").String()
require.NotEmpty(t, remotePath, "stdout:\n%s", uploadResult.Stdout)
assert.Equal(t, fileName, gjson.Get(uploadResult.Stdout, "data.file_name").String(), "stdout:\n%s", uploadResult.Stdout)
getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+file-get", "--app-id", appID, "--path", remotePath},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || gjson.Get(result.Stdout, "data.path").String() != remotePath
},
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
assert.Equal(t, int64(len(content)), gjson.Get(getResult.Stdout, "data.size_bytes").Int(), "stdout:\n%s", getResult.Stdout)
}

View File

@@ -1,11 +1,11 @@
# Apps CLI E2E Coverage
## Metrics
- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (18/18)
- API dry-run coverage: 100% (16/16 API-backed commands)
- Denominator: 19 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (19/19)
- API dry-run coverage: 100% (17/17 API-backed commands)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`.
- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup.
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
@@ -14,6 +14,8 @@
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
- `TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath`: dry-run validates an absolute local file and derives its basename without uploading it; a missing source path is rejected before preview.
- `TestAppsFileUploadLiveWorkflow`: fixture-gated absolute-path upload, `+file-get` readback, and `+file-delete` cleanup in a dedicated app.
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
@@ -23,7 +25,7 @@
- `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers.
- `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates.
## Command Table
@@ -35,6 +37,7 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
| ✓ | apps +file-upload | shortcut | apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath; apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_RejectsMissingHostPath; apps_file_upload_live_test.go::TestAppsFileUploadLiveWorkflow | `--app-id`, `--file` (absolute or relative local path) | live workflow uses `LARK_CLI_E2E_APPS_FILE_APP_ID`, reads metadata back, and deletes the uploaded file |
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseFormSubmitDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-submit",
"--share-token", "shrXXXX",
"--json", `{"fields":{"Rating":5}}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/submit")
assert.Contains(t, output, `"share_token"`)
assert.Contains(t, output, "shrXXXX")
assert.Contains(t, output, `"method": "POST"`)
}

View File

@@ -15,6 +15,7 @@ import (
)
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
@@ -35,7 +36,7 @@ func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
"base", "+record-batch-create",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`,
"--json", `{"create_records":[{"Name":"alpha","Status":"Open","Score":10},{"Name":"beta","Status":"Open","Score":15}]}`,
},
DefaultAs: "bot",
})

View File

@@ -56,7 +56,7 @@
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.fields`; `--json.rows` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
| ✓ | base +record-get | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--record-id`; repeated `--field-id`; `--format json` | reads back select and number values after batch update |

View File

@@ -44,6 +44,7 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
"--doc", docToken,
"--doc-format", "markdown",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -91,11 +91,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
"--block-id", "blkA, blkB, blkC",
"--block-id", "blkA,blkB,blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
},
{
name: "history list",
@@ -226,60 +225,3 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--title", "Flag title",
"--content", "<title>Content title</title><p>body</p>",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
}
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
want string
}{
{
name: "multiline XML str_replace",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
want: "must be inline",
},
{
name: "duplicate block delete ID",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
want: "duplicate ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
})
}
}

View File

@@ -366,6 +366,7 @@ func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suf
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
@@ -411,6 +412,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--key-result-id", krID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
@@ -426,6 +428,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--objective-id", obj.ObjectiveID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
@@ -447,6 +450,7 @@ func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suff
"--cycle-id", cycleID,
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live objective")
result.AssertExitCode(t, 0)
@@ -466,6 +470,7 @@ func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string,
"--objective-id", objectiveID,
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live key result")
result.AssertExitCode(t, 0)
@@ -499,6 +504,7 @@ func TestOKR_BatchCreateLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -544,6 +550,7 @@ func TestOKR_CreateLive_Objective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -581,6 +588,7 @@ func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -640,6 +648,7 @@ func TestOKR_ReorderLive(t *testing.T) {
"--level", "objective",
"--ops", string(opsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -650,6 +659,7 @@ func TestOKR_ReorderLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -702,6 +712,7 @@ func TestOKR_WeightLive(t *testing.T) {
"--level", "objective",
"--weights", string(weightsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -712,6 +723,7 @@ func TestOKR_WeightLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlidesPresentationAliasesDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
aliases := []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
for _, alias := range aliases {
t.Run(alias, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+xml-get",
"--" + alias, "presAliasDryRun",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presAliasDryRun",
gjson.Get(result.Stdout, "data.api.0.url").String(),
result.Stdout,
)
})
}
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestTaskIDHandlingDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_id_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_id_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
run := func(t *testing.T, args []string) *clie2e.Result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
return result
}
t.Run("GUID and applink produce equivalent update requests", func(t *testing.T) {
guidResult := run(t, []string{
"task", "+update", "--task-id", "task-guid-123", "--summary", "updated", "--dry-run",
})
guidResult.AssertExitCode(t, 0)
applinkResult := run(t, []string{
"task", "+update", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-123", "--summary", "updated", "--dry-run",
})
applinkResult.AssertExitCode(t, 0)
wantURL := "/open-apis/task/v2/tasks/task-guid-123"
require.Equal(t, wantURL, clie2e.DryRunGet(guidResult.Stdout, "api.0.url").String())
require.Equal(t, wantURL, clie2e.DryRunGet(applinkResult.Stdout, "api.0.url").String())
require.Equal(t, clie2e.DryRunGet(guidResult.Stdout, "api.0.body").Raw, clie2e.DryRunGet(applinkResult.Stdout, "api.0.body").Raw)
})
t.Run("multi-ID update previews every mutation", func(t *testing.T) {
result := run(t, []string{
"task", "+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/task?guid=task-guid-2",
"--summary", "updated",
"--dry-run",
})
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-1", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.1.method").String())
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-2", clie2e.DryRunGet(result.Stdout, "api.1.url").String())
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Raw, clie2e.DryRunGet(result.Stdout, "api.1.params").Raw)
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Raw, clie2e.DryRunGet(result.Stdout, "api.1.body").Raw)
})
t.Run("GUID and applink produce equivalent completion requests", func(t *testing.T) {
guidResult := run(t, []string{
"task", "+complete", "--task-id", "task-guid-456", "--dry-run",
})
guidResult.AssertExitCode(t, 0)
applinkResult := run(t, []string{
"task", "+complete", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-456", "--dry-run",
})
applinkResult.AssertExitCode(t, 0)
wantURL := "/open-apis/task/v2/tasks/task-guid-456"
for _, result := range []*clie2e.Result{guidResult, applinkResult} {
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.0.url").String())
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.1.url").String())
}
})
for _, shortcut := range []string{"+update", "+complete"} {
t.Run(shortcut+" rejects display numbers", func(t *testing.T) {
args := []string{"task", shortcut, "--task-id", "t12345", "--dry-run"}
if shortcut == "+update" {
args = append(args, "--summary", "must not be written")
}
result := run(t, args)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "guid=", "stderr:\n%s", result.Stderr)
require.False(t, gjson.Get(result.Stdout, "data.api").Exists(), "invalid input must not emit a dry-run API request\nstdout:\n%s", result.Stdout)
})
}
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/url"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestTaskIDHandlingWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalSummary := "lark-cli-e2e-task-id-original-" + suffix
updatedSummary := "lark-cli-e2e-task-id-updated-" + suffix
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": originalSummary,
"description": "created by task ID handling workflow",
},
})
taskApplink := "https://applink.larksuite.com/client/todo/task?guid=" + url.QueryEscape(taskGUID)
t.Run("update accepts task applink", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", taskApplink, "--summary", updatedSummary},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.tasks.0.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.tasks.0.confirmed.summary").String(), "stdout:\n%s", result.Stdout)
})
t.Run("display number is rejected without modifying task", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", "t12345", "--summary", "must-not-be-written-" + suffix},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
getResult, getErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, getErr)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
assert.Equal(t, updatedSummary, gjson.Get(getResult.Stdout, "data.task.summary").String(), "stdout:\n%s", getResult.Stdout)
})
}