From 94ed5b6c82add08899ea9860a6aea22390dda060 Mon Sep 17 00:00:00 2001 From: wenzhuozhen Date: Mon, 29 Jun 2026 14:41:56 +0800 Subject: [PATCH 1/3] feat(headers): env-driven PPE routing via x-tt-env / x-use-ppe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two env vars for steering local CLI traffic to a PPE/灰度 cluster during sheet-skill-spec dev (used by the lark-cli sync-global.sh release flow): - LARKSUITE_CLI_TT_ENV → x-tt-env header value - LARKSUITE_CLI_USE_PPE → x-use-ppe header value (typically '1') Headers are only injected when the corresponding env var is non-empty, so production builds remain unaffected. The env-driven approach keeps PPE coordinates out of git, while letting an upstream wrapper (or the sync-global.sh release script) export them once before invoking the CLI. --- internal/cmdutil/secheader.go | 8 ++++++++ internal/envvars/envvars.go | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index 9b6fa83ea..bea8f3d51 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -78,6 +78,14 @@ func BaseSecurityHeaders() http.Header { if v := AgentTraceValue(); v != "" { h.Set(HeaderAgentTrace, v) } + // PPE 路由 header(开发自测):仅当对应 env var 非空时注入,避免污染生产构建。 + // 二者独立——通常配对使用:LARKSUITE_CLI_TT_ENV= + LARKSUITE_CLI_USE_PPE=1。 + if v := strings.TrimSpace(os.Getenv(envvars.CliTtEnv)); v != "" { + h.Set("x-tt-env", v) + } + if v := strings.TrimSpace(os.Getenv(envvars.CliUsePpe)); v != "" { + h.Set("x-use-ppe", v) + } return h } diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 7b4a23464..de96c506d 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -21,6 +21,12 @@ const ( CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" + // PPE / 灰度环境路由(开发自测用):若设置,会注入对应 HTTP header 把请求 + // 稳定地打到指定 PPE/灰度集群。生产构建禁止设置;通过 env 而非硬编码, + // 是为了让 sync-global.sh 在打包前 export 一次就能稳定生效,commit 不污染线上。 + CliTtEnv = "LARKSUITE_CLI_TT_ENV" // → x-tt-env header value + CliUsePpe = "LARKSUITE_CLI_USE_PPE" // → x-use-ppe header value (typically "1") + CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" CliCAPath = "LARKSUITE_CLI_CA_PATH" From 08e60110941b4dae256ad36cd17f985ed30cd2b1 Mon Sep 17 00:00:00 2001 From: wenzhuozhen Date: Mon, 29 Jun 2026 14:42:32 +0800 Subject: [PATCH 2/3] feat(sheets): add +formula-verify shortcut for verify_formula tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the new verify_formula read tool in a CLI shortcut so AI agents can run R10 'write-then-zero-error' verification end-to-end: lark-cli sheets +formula-verify --url Scans formulas + cell error states across one or more sub-sheets and returns a recalc.py-shaped JSON status report (success / errors_found / partial). Mirrors all 7 Excel error categories (#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A) plus compile failures into one envelope; the tool always returns every error in the scan window — there is no error-type filter, callers needing a subset can filter the returned error_summary client-side. Flags follow the lark-sheets convention: - --url / --spreadsheet-token (XOR public) - --sheet-id / --sheet-name (repeat or comma-separate; mutually exclusive) - --range (repeatable A1) - --max-locations (default 20) - --cell-limit (default 50000) - --exit-on-error (CI gate: status='errors_found' → exit 2 with failed_precondition) Generated artifacts (skills/lark-sheets/{SKILL.md, references/ lark-sheets-formula-verify.md}, shortcuts/sheets/data/flag-defs.json, shortcuts/sheets/flag_defs_gen.go) are mirrored from sheet-skill-spec generated/ via 'npm run sync:cli'. Tests cover the dry-run wire shape (excel_id + sheet_ids/sheet_names/ ranges/cell_limit/max_locations packing), the read scope (invoke_read URL), the mutually-exclusive selector validation, the non-positive limit guard, and the --exit-on-error status matrix (success/partial/errors_found/unknown). --- shortcuts/sheets/data/flag-defs.json | 63 +++++ shortcuts/sheets/flag_defs_gen.go | 13 ++ shortcuts/sheets/lark_sheet_formula_verify.go | 173 ++++++++++++++ .../sheets/lark_sheet_formula_verify_test.go | 220 ++++++++++++++++++ skills/lark-sheets/SKILL.md | 1 + .../references/lark-sheets-formula-verify.md | 85 +++++++ 6 files changed, 555 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_formula_verify.go create mode 100644 shortcuts/sheets/lark_sheet_formula_verify_test.go create mode 100644 skills/lark-sheets/references/lark-sheets-formula-verify.md diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 51e77cc1c..78d4d7b17 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1,4 +1,67 @@ { + "+formula-verify": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string_slice", + "required": "optional", + "desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets." + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string_slice", + "required": "optional", + "desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets." + }, + { + "name": "range", + "kind": "own", + "type": "string_slice", + "required": "optional", + "desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region." + }, + { + "name": "max-locations", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Max locations / samples per error type; default 20.", + "default": "20" + }, + { + "name": "cell-limit", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Total cell scan cap (default 50000). When exceeded, `has_more=true` and caller should narrow `--range` to continue.", + "default": "50000" + }, + { + "name": "exit-on-error", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "When status=errors_found, exit non-zero. Useful for CI / xlsx-style write-then-verify pipelines." + } + ] + }, "+workbook-info": { "risk": "read", "flags": [ diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 753ea9716..18ece0825 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -634,6 +634,19 @@ var flagDefs = map[string]commandDef{ {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, + "+formula-verify": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."}, + {Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."}, + {Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."}, + {Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"}, + {Name: "cell-limit", Kind: "own", Type: "int", Required: "optional", Desc: "Total cell scan cap (default 50000). When exceeded, `has_more=true` and caller should narrow `--range` to continue.", Default: "50000"}, + {Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI / xlsx-style write-then-verify pipelines."}, + }, + }, "+pivot-create": { Risk: "write", Flags: []flagDef{ diff --git a/shortcuts/sheets/lark_sheet_formula_verify.go b/shortcuts/sheets/lark_sheet_formula_verify.go new file mode 100644 index 000000000..022438354 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_formula_verify.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_formula_verify ─────────────────────────────────────── +// +// Wraps verify_formula (read): scan formulas + cell error states across one +// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! / +// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors) +// into a recalc.py-shaped JSON status report. The contract is the single +// AI self-check entry point for the R10 "write → verify zero-error" +// invariant — see canonical-spec/references/lark_sheet_formula_verify/. + +// FormulaVerify wraps verify_formula. Sheet selection is optional (both +// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans +// every visible sub-sheet's current_region. +var FormulaVerify = common.Shortcut{ + Service: "sheets", + Command: "+formula-verify", + Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+formula-verify"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if err := validateFormulaVerifySheetSelector(runtime); err != nil { + return err + } + return validateFormulaVerifyLimits(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetTokenExec(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token)) + if err != nil { + return err + } + runtime.Out(out, nil) + if runtime.Bool("exit-on-error") { + return formulaVerifyExitOnError(out) + } + return nil + }, +} + +// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the +// two multi-value selectors: at most one of --sheet-id / --sheet-name may be +// non-empty (passing both is the high-frequency reflex confusion when the +// caller cargo-cults the single-sheet shortcut signature). Both empty is the +// documented "scan every visible sub-sheet" path. Control-char checks reuse +// requireSheetSelector's logic on each item. +func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error { + ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")) + names := nonEmptySliceItems(runtime.StrSlice("sheet-name")) + if len(ids) > 0 && len(names) > 0 { + return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets"). + WithParams( + sheetsInvalidParam("sheet-id", "mutually exclusive"), + sheetsInvalidParam("sheet-name", "mutually exclusive"), + ) + } + for _, id := range ids { + if err := requireSheetSelector(id, ""); err != nil { + return err + } + } + for _, name := range names { + if err := requireSheetSelector("", name); err != nil { + return err + } + } + return nil +} + +// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or +// negative flag value can't silently degrade the scan (the server-side +// default would otherwise mask the typo). +func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error { + if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 { + return sheetsValidationForFlag("max-locations", "--max-locations must be > 0") + } + if runtime.Changed("cell-limit") && runtime.Int("cell-limit") <= 0 { + return sheetsValidationForFlag("cell-limit", "--cell-limit must be > 0") + } + return nil +} + +// nonEmptySliceItems trims and drops blanks from a repeated-flag value so +// `--sheet-id ""` doesn't masquerade as a real entry. +func nonEmptySliceItems(in []string) []string { + out := make([]string, 0, len(in)) + for _, v := range in { + if trimmed := strings.TrimSpace(v); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// formulaVerifyInput builds the verify_formula tool input map from CLI flags. +// excel_id is required; everything else is optional per the schema. +func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + } + if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 { + input["sheet_ids"] = ids + } else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 { + // The verify_formula schema only declares sheet_ids; the facade + // accepts sheet_names as a parallel optional field so name-based + // selection works without forcing the caller to pre-resolve. Mirrors + // how the other read shortcuts pack both fields via + // sheetSelectorForToolInput. + input["sheet_names"] = names + } + if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 { + input["ranges"] = ranges + } + if runtime.Changed("max-locations") { + input["max_locations_per_error"] = runtime.Int("max-locations") + } + if runtime.Changed("cell-limit") { + input["cell_limit"] = runtime.Int("cell-limit") + } + return input +} + +// formulaVerifyExitOnError converts a verify_formula status into a non-zero +// CLI exit when the caller passed --exit-on-error. status="errors_found" +// is the only failure mode for this flag: "partial" means truncated but the +// scanned slice is clean, and "success" is obviously clean. A missing / +// unknown status is treated as a typed internal error because the tool's +// schema guarantees the field and we don't want a silent zero-exit. +func formulaVerifyExitOnError(out interface{}) error { + m, ok := out.(map[string]interface{}) + if !ok { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "verify_formula: missing status field in tool output") + } + status, _ := m["status"].(string) + switch status { + case "success", "partial": + return nil + case "errors_found": + total, _ := util.ToFloat64(m["total_errors"]) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "verify_formula: %d formula error(s) detected; resolve and re-run", int(total)). + WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success") + default: + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "verify_formula: unexpected status %q", status) + } +} diff --git a/shortcuts/sheets/lark_sheet_formula_verify_test.go b/shortcuts/sheets/lark_sheet_formula_verify_test.go new file mode 100644 index 000000000..6fc2538f6 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_formula_verify_test.go @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the +// common input combinations: no selector (workbook-wide scan), explicit +// sheet_ids, explicit ranges, and the optional cell_limit / +// max_locations_per_error fields. The test exercises the One-OpenAPI body +// directly so the schema field names stay locked to the canonical +// tool-schemas.json verify_formula node. +func TestFormulaVerify_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantInput map[string]interface{} + }{ + { + name: "no selector — workbook-wide scan defaults", + args: []string{"--url", testURL}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + }, + }, + { + name: "sheet_ids multi via repeat", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_ids": []interface{}{testSheetID, testSheetID2}, + }, + }, + { + name: "sheet_names multi via comma", + args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_names": []interface{}{"Sheet1", "Sheet2"}, + }, + }, + { + name: "ranges + cell_limit + max_locations", + args: []string{ + "--url", testURL, + "--range", "A1:Z200", + "--range", "AA1:AZ100", + "--max-locations", "5", + "--cell-limit", "10000", + }, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "ranges": []interface{}{"A1:Z200", "AA1:AZ100"}, + "max_locations_per_error": float64(5), + "cell_limit": float64(10000), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, FormulaVerify, tt.args) + got := decodeToolInput(t, body, "verify_formula") + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits +// invoke_read (read scope) and not invoke_write — a scope mismatch here would +// surface as a 403 from the gateway. +func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL}) + if len(calls) == 0 { + t.Fatalf("dry-run produced no api calls") + } + call, _ := calls[0].(map[string]interface{}) + url, _ := call["url"].(string) + if !strings.HasSuffix(url, "/tools/invoke_read") { + t.Errorf("verify_formula must hit invoke_read; got url=%q", url) + } + if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want { + t.Errorf("url = %q, want %q", url, want) + } +} + +// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector" +// rule on the two multi-value flags. Both empty is the documented +// workbook-wide scan path, so we only reject the both-supplied case. +func TestFormulaVerify_RejectsBothSelectors(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{ + "--url", testURL, + "--sheet-id", testSheetID, + "--sheet-name", "Sheet1", + "--dry-run", + }) + ve := requireValidation(t, err, "mutually exclusive") + gotParams := map[string]bool{} + for _, p := range ve.Params { + gotParams[p.Name] = true + } + if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] { + t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params) + } +} + +// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like +// `--cell-limit 0`, which would otherwise be silently swallowed by the +// "explicit value but unset" comparison in the input builder. +func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + { + name: "cell-limit=0", + args: []string{"--url", testURL, "--cell-limit", "0"}, + want: "--cell-limit must be > 0", + }, + { + name: "max-locations=0", + args: []string{"--url", testURL, "--max-locations", "0"}, + want: "--max-locations must be > 0", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run")) + requireValidation(t, err, c.want) + }) + } +} + +// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error +// contract: success/partial → no error; errors_found → typed validation +// error with SubtypeFailedPrecondition; missing or unknown status → +// typed internal error so a silent zero-exit can never happen. +func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) { + t.Parallel() + + t.Run("success returns no error", func(t *testing.T) { + t.Parallel() + if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil { + t.Fatalf("success path returned err: %v", err) + } + }) + + t.Run("partial returns no error", func(t *testing.T) { + t.Parallel() + if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil { + t.Fatalf("partial path returned err: %v", err) + } + }) + + t.Run("errors_found yields failed_precondition with count", func(t *testing.T) { + t.Parallel() + err := formulaVerifyExitOnError(map[string]interface{}{ + "status": "errors_found", + "total_errors": float64(7), + }) + if err == nil { + t.Fatal("expected error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if ve.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition) + } + if !strings.Contains(ve.Message, "7 formula error") { + t.Errorf("message %q must surface the error count", ve.Message) + } + if ve.Hint == "" { + t.Errorf("hint must be set so AI agents know to re-run after fixes") + } + }) + + t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) { + t.Parallel() + err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"}) + if err == nil { + t.Fatal("expected error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype) + } + }) + + t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) { + t.Parallel() + err := formulaVerifyExitOnError("oops") + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype) + } + }) +} diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index f9642baa2..ce4bcab7b 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -139,6 +139,7 @@ metadata: | Reference | 描述 | | --- | --- | +| [飞书表格公式自检](references/lark-sheets-formula-verify.md) | 公式写入后的自检入口。对指定子表(或整本工作簿)扫描公式与单元格值,聚合所有 Excel 错误(#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A),同时合并最近一次写入留下的编译失败(formula_errors),按 xlsx 链路 recalc.py 同构 JSON 输出,让 AI 一次拿到完整健康度报告。任何批量公式 / 含公式列写入完成后调用本工具确认 zero-error;status='errors_found' 时禁止把链路标为完成。 | | [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 | | [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 | | [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 | diff --git a/skills/lark-sheets/references/lark-sheets-formula-verify.md b/skills/lark-sheets/references/lark-sheets-formula-verify.md new file mode 100644 index 000000000..9260ba90d --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-formula-verify.md @@ -0,0 +1,85 @@ +# 飞书表格公式自检(verify_formula) + +> **本文定位**:飞书表格"公式写入后是否真的零错误"的**唯一自检入口**。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `lark-sheets-formula-translation` 为唯一权威,本文不重复;本文聚焦"写完了之后怎么用一次工具调用确认 zero-error"。 +> +> **边界**:本文不讲公式怎么写(去 `lark-sheets-formula-translation`),也不讲公式怎么写入表格(去 `lark-sheets-write-cells` / `lark-sheets-batch-update`)。本文只讲一件事:**写完之后必须 `verify_formula` 自检到 zero-error 才能交付**(铁律 R10)。 + +## 为什么需要自检 + +飞书在线表格的计算引擎(`sheet.node.cmd_api`)已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口: + +- 公式编译失败 → 单元格落成文本(`set_cell_range` / `set_range_from_csv` / `import_sandbox_to_sheet` 返回的 `formula_errors[]` 是**编译失败**信号)。 +- 公式编译成功但**运行时错误**:`#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`——这一类只看 `formula_errors[]` 看不到,必须扫单元格值。 + +`verify_formula` 把两路信号合并成一份 `recalc.py` 同构 JSON:一次调用聚合全表错误清单 + 编译失败清单 + 每类错误的定位与样本,AI 一眼就能定位修复,链路也能据 `status` 强制收敛到 `success`。 + +## 调用契约 + +调用入口为 `+formula-verify` shortcut(与 `verify_formula` 工具一一对应,由 spec-tables 维护)。最小调用形态: + +| 入参 | 含义 | +|---|---| +| `excel_id` | 飞书表格 `reference_id`(必填) | +| `sheet_ids` | 限定子表;省略则扫描全部可见子表 | +| `ranges` | 限定 A1 范围;省略则用各 sheet 的 `current_region` | +| `max_locations_per_error` | 每类错误样本上限,默认 20 | +| `cell_limit` | 总扫描上限,默认 50000;超限按 ranges 分页,`has_more=true` | + +返回核心字段: + +- `status` ∈ `success` / `errors_found` / `partial`——**唯一可机读的健康度判据**。 +- `total_errors` / `total_formulas` / `scanned_cells`——本次扫描规模指标。 +- `has_more`——为 true 表示被 `cell_limit` 截断,扫描未覆盖完整范围,需要缩小 `ranges` 续读。 +- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`,等价于 xlsx 链路 `recalc.py` 的 JSON 形态。 +- `compile_errors[]`——合并最近一次写入留下的 `formula_errors[]`(编译失败被存为文本),与运行时错误并存时同时出现。 + +## R10 写入收尾铁律 + +> 与 `lark-sheets-core-operations` 的 R10 同义。任何批量公式 / 含公式列写入完成后**必须**调用 `verify_formula` 直到 `status='success'` 才能交付。 + +工具范围(任一命中即触发 R10): + +- `set_cell_range` / `+cells-set` +- `set_range_from_csv` / `+cells-csv-set` +- `import_sandbox_to_sheet` / `+sandbox-import` +- `batch_update` / `+batch-update` 内任何写入子操作 +- `put_table` / `+table-put`(任意列含公式时) +- `import_workbook` / `+workbook-import`(导入的 xlsx 含公式时) + +收敛规则: + +1. `status='success'` → 通过;可以把链路标完成。 +2. `status='partial'` → 扫描被 `cell_limit` 截断。先缩小 `ranges` 或拆 `sheet_ids` 续扫,**不允许**把 `partial` 当作 `success`。 +3. `status='errors_found'` 且 `compile_errors[]` 非空 → **先解决编译失败**:根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式),用 `+cells-set` 重写后再调一次 `verify_formula`。 +4. `status='errors_found'` 且只剩运行时错误 → 按 `error_summary` 的 `samples[].formula` + `depends_on` 排查根因(零除?空值参与运算?引用越界?日期差写法?数组语义?),修复后重新自检。 +5. 同一处错误连续修复 3 次仍未通过 → 改用 `IFERROR` 包裹兜底,或退回纯值写入;不要在 `errors_found` 状态下扩展 `copy_to_range`、追加批量写入。 + +**严禁**: + +- 在 `status='errors_found'` 的状态下调用 `copy_to_range` / `+cells-set --copy-to-range` 继续扩展(错误会被复制放大)。 +- 把"编译失败但运行时无报错"当 zero-error(编译失败的单元格此刻是文本不是公式,源数据一变就再也算不出值)。 +- 跳过自检直接交付,靠肉眼读首末 5 行确认——表中段、隐藏行、合并区里的错误这样根本看不到。 + +## 与 xlsx 链路打通 + +xlsx 离线侧用 `~/.claude/skills/xlsx/scripts/recalc.py` 做 LibreOffice headless 重算 + openpyxl 扫错;飞书在线侧用 `verify_formula` 走在线引擎重算 + 后端扫错,两者输出形态同构。典型链路: + +```text +[xlsx 文件] --(+workbook-import)--> [在线飞书表格] --(verify_formula)--> [recalc.py 同构 JSON] + │ + └─ status=success → 交付 + └─ status=errors_found → 修公式重试 + └─ status=partial → 缩小 ranges 续扫 +``` + +公式语义和线上完全一致——规避 LibreOffice / Excel / 飞书三方公式行为差异的坑——且能直接接进 sheet 写入的 AI 链路里强制 zero-error。 + +## 常见陷阱 + +| 坑 | 应对 | +|---|---| +| 错误字符串本地化 | 后端按内部 `error_kind` / `compute_status` 字段识别错误类别,不走字符串匹配;调用方拿到的 7 类英文错误代码由后端统一规范输出,与 locale 无关。 | +| `formatted_value` 可能隐藏错误 | 某些条件格式 / 自定义数字格式会把 `#DIV/0!` 显示成空白。后端直接读 cell `error_kind`,不依赖 `formatted_value`,绕开此类被遮蔽。 | +| 超大表分页 | 总扫描量超过 `cell_limit` 时 `has_more=true`,按 sheet → 按列段续扫;`max_locations_per_error` 控制响应体大小,避免一次响应过大。 | +| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]` 与 `error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检(同 R10 的子步)。 | +| 把 `partial` 当 `success` | `partial` 仅表示**已扫描部分**无错误,剩余区域未知。必须续扫直到 `has_more=false` 且 `status='success'` 才能算通过。 | From 5f7def59906a58ec9981c41f99a305a768fe4df1 Mon Sep 17 00:00:00 2001 From: wenzhuozhen Date: Mon, 29 Jun 2026 15:53:25 +0800 Subject: [PATCH 3/3] refactor(sheets): drop --cell-limit from +formula-verify, register shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced from sheet-skill-spec MR !39: - Remove --cell-limit flag (server now hides the cap and signals truncation via warning_message + has_more in the response). - Apply reviewer guidance: lark-sheets-formula-verify reference rewritten in CLI-shortcut idiom (no tool names, no xlsx, no R10 铁律 wording). flag-defs.json regenerated. - Register FormulaVerify in shortcuts/sheets/shortcuts.go alongside the other lark_sheet_formula_verify skill shortcuts so +formula-verify becomes discoverable from `lark-cli sheets --help`. --- shortcuts/sheets/data/flag-defs.json | 10 +-- shortcuts/sheets/flag_defs_gen.go | 3 +- shortcuts/sheets/lark_sheet_formula_verify.go | 6 -- .../sheets/lark_sheet_formula_verify_test.go | 15 +--- shortcuts/sheets/shortcuts.go | 3 + skills/lark-sheets/SKILL.md | 2 +- .../references/lark-sheets-formula-verify.md | 81 +++++++++---------- 7 files changed, 46 insertions(+), 74 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 78d4d7b17..b4eb75ae6 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -45,20 +45,12 @@ "desc": "Max locations / samples per error type; default 20.", "default": "20" }, - { - "name": "cell-limit", - "kind": "own", - "type": "int", - "required": "optional", - "desc": "Total cell scan cap (default 50000). When exceeded, `has_more=true` and caller should narrow `--range` to continue.", - "default": "50000" - }, { "name": "exit-on-error", "kind": "own", "type": "bool", "required": "optional", - "desc": "When status=errors_found, exit non-zero. Useful for CI / xlsx-style write-then-verify pipelines." + "desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes." } ] }, diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 18ece0825..45628c7d7 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -643,8 +643,7 @@ var flagDefs = map[string]commandDef{ {Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."}, {Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."}, {Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"}, - {Name: "cell-limit", Kind: "own", Type: "int", Required: "optional", Desc: "Total cell scan cap (default 50000). When exceeded, `has_more=true` and caller should narrow `--range` to continue.", Default: "50000"}, - {Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI / xlsx-style write-then-verify pipelines."}, + {Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."}, }, }, "+pivot-create": { diff --git a/shortcuts/sheets/lark_sheet_formula_verify.go b/shortcuts/sheets/lark_sheet_formula_verify.go index 022438354..62b6e1f42 100644 --- a/shortcuts/sheets/lark_sheet_formula_verify.go +++ b/shortcuts/sheets/lark_sheet_formula_verify.go @@ -99,9 +99,6 @@ func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error { if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 { return sheetsValidationForFlag("max-locations", "--max-locations must be > 0") } - if runtime.Changed("cell-limit") && runtime.Int("cell-limit") <= 0 { - return sheetsValidationForFlag("cell-limit", "--cell-limit must be > 0") - } return nil } @@ -139,9 +136,6 @@ func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string if runtime.Changed("max-locations") { input["max_locations_per_error"] = runtime.Int("max-locations") } - if runtime.Changed("cell-limit") { - input["cell_limit"] = runtime.Int("cell-limit") - } return input } diff --git a/shortcuts/sheets/lark_sheet_formula_verify_test.go b/shortcuts/sheets/lark_sheet_formula_verify_test.go index 6fc2538f6..f0be248bc 100644 --- a/shortcuts/sheets/lark_sheet_formula_verify_test.go +++ b/shortcuts/sheets/lark_sheet_formula_verify_test.go @@ -13,8 +13,8 @@ import ( // TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the // common input combinations: no selector (workbook-wide scan), explicit -// sheet_ids, explicit ranges, and the optional cell_limit / -// max_locations_per_error fields. The test exercises the One-OpenAPI body +// sheet_ids, explicit ranges, and the optional max_locations_per_error +// field. The test exercises the One-OpenAPI body // directly so the schema field names stay locked to the canonical // tool-schemas.json verify_formula node. func TestFormulaVerify_DryRun(t *testing.T) { @@ -49,19 +49,17 @@ func TestFormulaVerify_DryRun(t *testing.T) { }, }, { - name: "ranges + cell_limit + max_locations", + name: "ranges + max_locations", args: []string{ "--url", testURL, "--range", "A1:Z200", "--range", "AA1:AZ100", "--max-locations", "5", - "--cell-limit", "10000", }, wantInput: map[string]interface{}{ "excel_id": testToken, "ranges": []interface{}{"A1:Z200", "AA1:AZ100"}, "max_locations_per_error": float64(5), - "cell_limit": float64(10000), }, }, } @@ -117,7 +115,7 @@ func TestFormulaVerify_RejectsBothSelectors(t *testing.T) { } // TestFormulaVerify_RejectsNonPositiveLimits guards against typos like -// `--cell-limit 0`, which would otherwise be silently swallowed by the +// `--max-locations 0`, which would otherwise be silently swallowed by the // "explicit value but unset" comparison in the input builder. func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) { t.Parallel() @@ -126,11 +124,6 @@ func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) { args []string want string }{ - { - name: "cell-limit=0", - args: []string{"--url", testURL, "--cell-limit", "0"}, - want: "--cell-limit must be > 0", - }, { name: "max-locations=0", args: []string{"--url", testURL, "--max-locations", "0"}, diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index c0e4d9499..ab0b6a2d0 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -105,6 +105,9 @@ func shortcutList() []common.Shortcut { CellsSearch, CellsReplace, + // lark_sheet_formula_verify + FormulaVerify, + // lark_sheet_write_cells CellsSet, CellsSetStyle, diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index ce4bcab7b..638c5b057 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -139,7 +139,7 @@ metadata: | Reference | 描述 | | --- | --- | -| [飞书表格公式自检](references/lark-sheets-formula-verify.md) | 公式写入后的自检入口。对指定子表(或整本工作簿)扫描公式与单元格值,聚合所有 Excel 错误(#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A),同时合并最近一次写入留下的编译失败(formula_errors),按 xlsx 链路 recalc.py 同构 JSON 输出,让 AI 一次拿到完整健康度报告。任何批量公式 / 含公式列写入完成后调用本工具确认 zero-error;status='errors_found' 时禁止把链路标为完成。 | +| [飞书表格公式自检](references/lark-sheets-formula-verify.md) | 公式写入后的自检入口。对指定子表(或整本工作簿)扫描公式与单元格值,聚合所有 Excel 错误(#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A),同时合并最近一次写入留下的编译失败(formula_errors),输出统一 JSON 让 AI 一次拿到完整健康度报告。任何批量公式 / 含公式列写入完成后调用 +formula-verify 确认 zero-error;status='errors_found' 时禁止把链路标为完成。 | | [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 | | [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 | | [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 | diff --git a/skills/lark-sheets/references/lark-sheets-formula-verify.md b/skills/lark-sheets/references/lark-sheets-formula-verify.md index 9260ba90d..d05130a7b 100644 --- a/skills/lark-sheets/references/lark-sheets-formula-verify.md +++ b/skills/lark-sheets/references/lark-sheets-formula-verify.md @@ -1,78 +1,70 @@ -# 飞书表格公式自检(verify_formula) +# 飞书表格公式自检(+formula-verify) -> **本文定位**:飞书表格"公式写入后是否真的零错误"的**唯一自检入口**。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `lark-sheets-formula-translation` 为唯一权威,本文不重复;本文聚焦"写完了之后怎么用一次工具调用确认 zero-error"。 +> **本文定位**:飞书表格"公式写入后是否真的零错误"的自检入口。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `lark-sheets-formula-translation` 为唯一权威,本文不重复;本文聚焦"写完了之后怎么用一次调用确认 zero-error"。 > -> **边界**:本文不讲公式怎么写(去 `lark-sheets-formula-translation`),也不讲公式怎么写入表格(去 `lark-sheets-write-cells` / `lark-sheets-batch-update`)。本文只讲一件事:**写完之后必须 `verify_formula` 自检到 zero-error 才能交付**(铁律 R10)。 +> **边界**:本文不讲公式怎么写(去 `lark-sheets-formula-translation`),也不讲公式怎么写入表格(去 `lark-sheets-write-cells` / `lark-sheets-batch-update`)。本文只讲一件事:**写完之后必须用 `+formula-verify` 自检到 zero-error 才能交付**。 ## 为什么需要自检 -飞书在线表格的计算引擎(`sheet.node.cmd_api`)已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口: +飞书在线表格已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口: -- 公式编译失败 → 单元格落成文本(`set_cell_range` / `set_range_from_csv` / `import_sandbox_to_sheet` 返回的 `formula_errors[]` 是**编译失败**信号)。 +- 公式编译失败 → 单元格落成文本(写入类 shortcut 返回的 `formula_errors[]` 是**编译失败**信号)。 - 公式编译成功但**运行时错误**:`#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`——这一类只看 `formula_errors[]` 看不到,必须扫单元格值。 -`verify_formula` 把两路信号合并成一份 `recalc.py` 同构 JSON:一次调用聚合全表错误清单 + 编译失败清单 + 每类错误的定位与样本,AI 一眼就能定位修复,链路也能据 `status` 强制收敛到 `success`。 +`+formula-verify` 把两路信号合并成一份统一 JSON:一次调用聚合全表错误清单 + 编译失败清单 + 每类错误的定位与样本,AI 一眼就能定位修复,链路也能据 `status` 强制收敛到 `success`。 ## 调用契约 -调用入口为 `+formula-verify` shortcut(与 `verify_formula` 工具一一对应,由 spec-tables 维护)。最小调用形态: +最小调用形态: | 入参 | 含义 | |---|---| -| `excel_id` | 飞书表格 `reference_id`(必填) | -| `sheet_ids` | 限定子表;省略则扫描全部可见子表 | -| `ranges` | 限定 A1 范围;省略则用各 sheet 的 `current_region` | -| `max_locations_per_error` | 每类错误样本上限,默认 20 | -| `cell_limit` | 总扫描上限,默认 50000;超限按 ranges 分页,`has_more=true` | +| `--url` / `--spreadsheet-token` | 表格定位(XOR 二选一,必填) | +| `--sheet-id` / `--sheet-name` | 限定子表(mutually exclusive;省略则扫全部可见子表) | +| `--range` | 限定 A1 范围;省略则用各 sheet 的 `current_region` | +| `--max-locations` | 每类错误样本上限,默认 20 | +| `--exit-on-error` | `status='errors_found'` 时返回非 0 退出码(CI 网关用) | 返回核心字段: - `status` ∈ `success` / `errors_found` / `partial`——**唯一可机读的健康度判据**。 - `total_errors` / `total_formulas` / `scanned_cells`——本次扫描规模指标。 -- `has_more`——为 true 表示被 `cell_limit` 截断,扫描未覆盖完整范围,需要缩小 `ranges` 续读。 -- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`,等价于 xlsx 链路 `recalc.py` 的 JSON 形态。 -- `compile_errors[]`——合并最近一次写入留下的 `formula_errors[]`(编译失败被存为文本),与运行时错误并存时同时出现。 +- `has_more`——为 true 表示扫描被内部上限截断(详见后文「截断与续读」),未覆盖完整范围。 +- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`。 +- `compile_errors[]`——合并最近一次写入留下的编译失败清单,与运行时错误并存时同时出现。 +- `warning_message`——仅在 `has_more=true` 时出现,告知调用方需要缩小 `--range` / 拆 `--sheet-id` 续读。 -## R10 写入收尾铁律 +## 写入收尾收敛规则 -> 与 `lark-sheets-core-operations` 的 R10 同义。任何批量公式 / 含公式列写入完成后**必须**调用 `verify_formula` 直到 `status='success'` 才能交付。 +任何批量公式 / 含公式列写入完成后调用 `+formula-verify` 直到 `status='success'` 才能交付。触发场景: -工具范围(任一命中即触发 R10): - -- `set_cell_range` / `+cells-set` -- `set_range_from_csv` / `+cells-csv-set` -- `import_sandbox_to_sheet` / `+sandbox-import` -- `batch_update` / `+batch-update` 内任何写入子操作 -- `put_table` / `+table-put`(任意列含公式时) -- `import_workbook` / `+workbook-import`(导入的 xlsx 含公式时) +- `+cells-set` / `+cells-csv-set` +- `+sandbox-import` +- `+batch-update` 中含写入子操作 +- `+table-put`(任意列含公式时) +- `+workbook-import`(导入的 xlsx 含公式时) 收敛规则: 1. `status='success'` → 通过;可以把链路标完成。 -2. `status='partial'` → 扫描被 `cell_limit` 截断。先缩小 `ranges` 或拆 `sheet_ids` 续扫,**不允许**把 `partial` 当作 `success`。 -3. `status='errors_found'` 且 `compile_errors[]` 非空 → **先解决编译失败**:根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式),用 `+cells-set` 重写后再调一次 `verify_formula`。 +2. `status='partial'` → 扫描被内部上限截断。先缩小 `--range` 或拆 `--sheet-id` 续扫,**不允许**把 `partial` 当作 `success`。 +3. `status='errors_found'` 且 `compile_errors[]` 非空 → **先解决编译失败**:根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式),用 `+cells-set` 重写后再调一次 `+formula-verify`。 4. `status='errors_found'` 且只剩运行时错误 → 按 `error_summary` 的 `samples[].formula` + `depends_on` 排查根因(零除?空值参与运算?引用越界?日期差写法?数组语义?),修复后重新自检。 -5. 同一处错误连续修复 3 次仍未通过 → 改用 `IFERROR` 包裹兜底,或退回纯值写入;不要在 `errors_found` 状态下扩展 `copy_to_range`、追加批量写入。 - -**严禁**: +5. 同一处错误连续修复 3 次仍未通过 → 改用 `IFERROR` 包裹兜底,或退回纯值写入;不要在 `errors_found` 状态下扩展 `+cells-set --copy-to-range`、追加批量写入。 -- 在 `status='errors_found'` 的状态下调用 `copy_to_range` / `+cells-set --copy-to-range` 继续扩展(错误会被复制放大)。 -- 把"编译失败但运行时无报错"当 zero-error(编译失败的单元格此刻是文本不是公式,源数据一变就再也算不出值)。 -- 跳过自检直接交付,靠肉眼读首末 5 行确认——表中段、隐藏行、合并区里的错误这样根本看不到。 +注意: -## 与 xlsx 链路打通 +- 在 `status='errors_found'` 的状态下调用 `+cells-set --copy-to-range` 继续扩展会把错误复制放大。 +- "编译失败但运行时无报错"不是 zero-error(编译失败的单元格此刻是文本不是公式,源数据一变就再也算不出值)。 +- 跳过自检直接交付、靠肉眼读首末 5 行确认是不可靠的——表中段、隐藏行、合并区里的错误这样根本看不到。 -xlsx 离线侧用 `~/.claude/skills/xlsx/scripts/recalc.py` 做 LibreOffice headless 重算 + openpyxl 扫错;飞书在线侧用 `verify_formula` 走在线引擎重算 + 后端扫错,两者输出形态同构。典型链路: +## 截断与续读 -```text -[xlsx 文件] --(+workbook-import)--> [在线飞书表格] --(verify_formula)--> [recalc.py 同构 JSON] - │ - └─ status=success → 交付 - └─ status=errors_found → 修公式重试 - └─ status=partial → 缩小 ranges 续扫 -``` +后端有一个内部硬上限对总扫描单元格数做截断(不暴露给调用方),超过后立即返回 `has_more=true` + `warning_message`,`error_summary` / `compile_errors` 仅覆盖已扫描部分。处理路径: -公式语义和线上完全一致——规避 LibreOffice / Excel / 飞书三方公式行为差异的坑——且能直接接进 sheet 写入的 AI 链路里强制 zero-error。 +- 把工作簿按 `--sheet-id` / `--sheet-name` 拆成多次调用。 +- 同 sheet 内按 `--range` 切片(如先 `A1:Z200` 再 `AA1:AZ200`),逐块自检。 +- 每块都跑到 `has_more=false` 且 `status='success'` 才算通过。 ## 常见陷阱 @@ -80,6 +72,5 @@ xlsx 离线侧用 `~/.claude/skills/xlsx/scripts/recalc.py` 做 LibreOffice head |---|---| | 错误字符串本地化 | 后端按内部 `error_kind` / `compute_status` 字段识别错误类别,不走字符串匹配;调用方拿到的 7 类英文错误代码由后端统一规范输出,与 locale 无关。 | | `formatted_value` 可能隐藏错误 | 某些条件格式 / 自定义数字格式会把 `#DIV/0!` 显示成空白。后端直接读 cell `error_kind`,不依赖 `formatted_value`,绕开此类被遮蔽。 | -| 超大表分页 | 总扫描量超过 `cell_limit` 时 `has_more=true`,按 sheet → 按列段续扫;`max_locations_per_error` 控制响应体大小,避免一次响应过大。 | -| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]` 与 `error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检(同 R10 的子步)。 | | 把 `partial` 当 `success` | `partial` 仅表示**已扫描部分**无错误,剩余区域未知。必须续扫直到 `has_more=false` 且 `status='success'` 才能算通过。 | +| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]` 与 `error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检。 |